diff --git a/packages/vinext/src/plugins/ast-scope.ts b/packages/vinext/src/plugins/ast-scope.ts index 36e60fc4bf..b6e999c69e 100644 --- a/packages/vinext/src/plugins/ast-scope.ts +++ b/packages/vinext/src/plugins/ast-scope.ts @@ -1,10 +1,5 @@ -import { - collectBindingNames, - forEachAstChild, - isAstRecord, - nodeArray, - type AstRecord, -} from "./ast-utils.js"; +import type { ESTree } from "vite"; +import { collectBindingNames, forEachAstChild } from "./ast-utils.js"; export type AstScope = { parent: AstScope | null; @@ -22,7 +17,9 @@ export function hasAstBinding(scope: AstScope, name: string): boolean { return false; } -export function isFunctionNode(node: AstRecord): boolean { +export function isFunctionNode( + node: ESTree.Node, +): node is ESTree.Function | ESTree.ArrowFunctionExpression { return ( node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || @@ -31,25 +28,34 @@ export function isFunctionNode(node: AstRecord): boolean { } export function collectDirectScopeBindings( - node: AstRecord, + node: ESTree.Node, scope: AstScope, - onVariableDeclarator?: (declaration: AstRecord, declarator: AstRecord) => void, + onVariableDeclarator?: ( + declaration: ESTree.VariableDeclaration, + declarator: ESTree.VariableDeclarator, + ) => void, ): void { - for (const statementValue of nodeArray(node.body)) { - const statement = isAstRecord(statementValue) ? statementValue : null; - if (!statement) continue; + const statements = + node.type === "Program" || + node.type === "BlockStatement" || + node.type === "StaticBlock" || + node.type === "TSModuleBlock" + ? node.body + : node.type === "SwitchCase" + ? node.consequent + : []; + + for (const statement of statements) { const declaration = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" - ? isAstRecord(statement.declaration) - ? statement.declaration - : null + ? statement.declaration : statement; if (!declaration) continue; if (declaration.type === "ImportDeclaration") { if (declaration.importKind === "type") continue; - for (const specifier of nodeArray(declaration.specifiers)) { - if (isAstRecord(specifier) && specifier.importKind !== "type") { + for (const specifier of declaration.specifiers) { + if (specifier.type !== "ImportSpecifier" || specifier.importKind !== "type") { collectBindingNames(specifier.local, scope.bindings); } } @@ -59,8 +65,7 @@ export function collectDirectScopeBindings( ) { collectBindingNames(declaration.id, scope.bindings); } else if (declaration.type === "VariableDeclaration" && declaration.declare !== true) { - for (const declarator of nodeArray(declaration.declarations)) { - if (!isAstRecord(declarator)) continue; + for (const declarator of declaration.declarations) { collectBindingNames(declarator.id, scope.bindings); onVariableDeclarator?.(declaration, declarator); } @@ -79,36 +84,40 @@ export function collectDirectScopeBindings( } export function collectLoopScopeBindings( - node: AstRecord, + node: ESTree.ForStatement | ESTree.ForInStatement | ESTree.ForOfStatement, scope: AstScope, - onVariableDeclarator?: (declaration: AstRecord, declarator: AstRecord) => void, + onVariableDeclarator?: ( + declaration: ESTree.VariableDeclaration, + declarator: ESTree.VariableDeclarator, + ) => void, ): void { const declarationValue = node.type === "ForStatement" ? node.init : node.left; - if (!isAstRecord(declarationValue)) return; - if (declarationValue.type !== "VariableDeclaration" || declarationValue.declare === true) return; - for (const declarator of nodeArray(declarationValue.declarations)) { - if (!isAstRecord(declarator)) continue; + if ( + !declarationValue || + declarationValue.type !== "VariableDeclaration" || + declarationValue.declare === true + ) + return; + for (const declarator of declarationValue.declarations) { collectBindingNames(declarator.id, scope.bindings); onVariableDeclarator?.(declarationValue, declarator); } } export function collectSwitchScopeBindings( - node: AstRecord, + node: ESTree.SwitchStatement, scope: AstScope, - onVariableDeclarator?: (declaration: AstRecord, declarator: AstRecord) => void, + onVariableDeclarator?: ( + declaration: ESTree.VariableDeclaration, + declarator: ESTree.VariableDeclarator, + ) => void, ): void { - for (const caseValue of nodeArray(node.cases)) { - if (!isAstRecord(caseValue)) continue; - collectDirectScopeBindings( - { type: "BlockStatement", body: nodeArray(caseValue.consequent) }, - scope, - onVariableDeclarator, - ); + for (const switchCase of node.cases) { + collectDirectScopeBindings(switchCase, scope, onVariableDeclarator); } } -export function collectVarScopeBindings(node: AstRecord, scope: AstScope, root = true): void { +export function collectVarScopeBindings(node: ESTree.Node, scope: AstScope, root = true): void { if ( !root && (isFunctionNode(node) || node.type === "StaticBlock" || node.type === "TSModuleBlock") @@ -116,8 +125,8 @@ export function collectVarScopeBindings(node: AstRecord, scope: AstScope, root = return; } if (node.type === "VariableDeclaration" && node.kind === "var" && node.declare !== true) { - for (const declarator of nodeArray(node.declarations)) { - if (isAstRecord(declarator)) collectBindingNames(declarator.id, scope.bindings); + for (const declarator of node.declarations) { + collectBindingNames(declarator.id, scope.bindings); } } forEachAstChild(node, (child) => collectVarScopeBindings(child, scope, false)); diff --git a/packages/vinext/src/plugins/ast-utils.ts b/packages/vinext/src/plugins/ast-utils.ts index 0c206a58ad..6ba6cf873a 100644 --- a/packages/vinext/src/plugins/ast-utils.ts +++ b/packages/vinext/src/plugins/ast-utils.ts @@ -1,29 +1,9 @@ -export type AstRecord = { - type: string; - start?: number; - end?: number; - [key: string]: unknown; -}; - -export type AstRange = AstRecord & { - start: number; - end: number; -}; +import type { ESTree } from "vite"; export type ScriptParserLanguage = "js" | "jsx" | "ts" | "tsx"; const SCRIPT_MODULE_EXTENSION_RE = /^\.(?:[cm]?[jt]s|[jt]sx)$/i; export const SCRIPT_MODULE_ID_RE = /\.(?:[cm]?[jt]s|[jt]sx)(?:[?#].*)?$/i; -const TRANSPARENT_EXPRESSION_TYPES = new Set([ - "ChainExpression", - "ParenthesizedExpression", - "TSAsExpression", - "TSInstantiationExpression", - "TSNonNullExpression", - "TSSatisfiesExpression", - "TSTypeAssertion", -]); - /** * Cheap pre-parse gate for plugins that only transform *dynamic* `import(...)`. * @@ -75,61 +55,43 @@ export function scriptParserLanguage(id: string): ScriptParserLanguage | null { const SKIP_CHILD_KEYS = new Set(["type", "parent", "loc", "start", "end"]); -function getObjectProperty(value: unknown, key: string): unknown { - if (typeof value !== "object" || value === null) return null; - return Reflect.get(value, key); -} - -export function isAstRecord(value: unknown): value is AstRecord { - return typeof getObjectProperty(value, "type") === "string"; -} - -function toAstRecord(value: unknown): AstRecord | null { - return isAstRecord(value) ? value : null; -} - -export function nodeArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} - -export function hasRange(node: AstRecord | null): node is AstRange { - return node !== null && typeof node.start === "number" && typeof node.end === "number"; -} - -export function isIdentifierNamed(value: unknown, name: string): boolean { - return isAstRecord(value) && value.type === "Identifier" && value.name === name; +export function isIdentifierNamed(value: ESTree.Node | null | undefined, name: string): boolean { + return value?.type === "Identifier" && value.name === name; } -export function getAstName(value: unknown): string | null { - const node = toAstRecord(value); - if (!node) return null; - if (node.type === "Identifier" && typeof node.name === "string") return node.name; - if (typeof node.value === "string") return node.value; +export function getAstName(node: ESTree.Node | null | undefined): string | null { + if (node?.type === "Identifier") return node.name; + if (node?.type === "Literal" && typeof node.value === "string") return node.value; return null; } /** Remove syntax-only wrappers while preserving the underlying expression. */ -export function unwrapExpression(value: unknown): AstRecord | null { - const node = toAstRecord(value); - if (!node || !TRANSPARENT_EXPRESSION_TYPES.has(node.type)) return node; - return unwrapExpression(node.expression); +export function unwrapExpression(node: ESTree.Node | null | undefined): ESTree.Node | null { + if (!node) return null; + if ( + node.type === "ChainExpression" || + node.type === "ParenthesizedExpression" || + node.type === "TSAsExpression" || + node.type === "TSInstantiationExpression" || + node.type === "TSNonNullExpression" || + node.type === "TSSatisfiesExpression" || + node.type === "TSTypeAssertion" + ) { + return unwrapExpression(node.expression); + } + return node; } /** Return the value of a string literal node, without evaluating expressions. */ -export function stringLiteralValue(value: unknown): string | null { - const node = toAstRecord(value); - if ( - (node?.type === "Literal" || node?.type === "StringLiteral") && - typeof node.value === "string" - ) { +export function stringLiteralValue(node: ESTree.Node | null | undefined): string | null { + if (node?.type === "Literal" && typeof node.value === "string") { return node.value; } return null; } /** Return the value of a boolean literal node, without evaluating expressions. */ -export function booleanLiteralValue(value: unknown): boolean | null { - const node = toAstRecord(value); +export function booleanLiteralValue(node: ESTree.Node | null | undefined): boolean | null { return node?.type === "Literal" && typeof node.value === "boolean" ? node.value : null; } @@ -138,39 +100,32 @@ export function booleanLiteralValue(value: unknown): boolean | null { * template literal. This deliberately does not fold concatenations or other * expressions. */ -export function staticStringValue(value: unknown): string | null { - const node = toAstRecord(value); +export function staticStringValue(node: ESTree.Node | null | undefined): string | null { if (!node) return null; const literal = stringLiteralValue(node); if (literal !== null) return literal; - if (node.type !== "TemplateLiteral" || nodeArray(node.expressions).length !== 0) return null; + if (node.type !== "TemplateLiteral" || node.expressions.length !== 0) return null; - const quasis = nodeArray(node.quasis); - if (quasis.length !== 1) return null; - const quasi = toAstRecord(quasis[0]); - if (quasi?.type !== "TemplateElement" || typeof quasi.value !== "object" || !quasi.value) { - return null; - } + const quasi = node.quasis[0]; + if (!quasi || node.quasis.length !== 1) return null; - const cooked = Reflect.get(quasi.value, "cooked"); - const raw = Reflect.get(quasi.value, "raw"); + const { cooked, raw } = quasi.value; return typeof cooked === "string" ? cooked : typeof raw === "string" ? raw : null; } -export function forEachAstChild(node: AstRecord, callback: (child: AstRecord) => void): void { +export function forEachAstChild(node: ESTree.Node, callback: (child: ESTree.Node) => void): void { for (const [key, value] of Object.entries(node)) { if (SKIP_CHILD_KEYS.has(key)) continue; - const child = toAstRecord(value); - if (child) { - callback(child); - continue; - } if (Array.isArray(value)) { for (const item of value) { - const itemNode = toAstRecord(item); - if (itemNode) callback(itemNode); + if (typeof item === "object" && item !== null && "type" in item) { + callback(item as ESTree.Node); + } } + } else if (typeof value === "object" && value !== null && "type" in value) { + // Object.entries() erases the discriminated-union type of Node fields. + callback(value as ESTree.Node); } } } @@ -181,19 +136,22 @@ export function forEachAstChild(node: AstRecord, callback: (child: AstRecord) => * expression. Parent links and source-location metadata are skipped by * {@link forEachAstChild}, so OXC's cyclic `parent` references are safe. */ -export function walkAst(value: unknown, visitor: (node: AstRecord) => boolean | void): void { - const node = toAstRecord(value); - if (!node || visitor(node) === false) return; +export function walkAst(node: ESTree.Node, visitor: (node: ESTree.Node) => boolean | void): void { + if (visitor(node) === false) return; forEachAstChild(node, (child) => walkAst(child, visitor)); } -export function collectBindingNames(pattern: unknown, target: Set): void { - const node = toAstRecord(pattern); +export function collectBindingNames( + node: ESTree.Node | null | undefined, + target: Set, +): void { if (!node) return; + // Binding patterns are a deliberately small subset of the full node union. + // oxlint-disable-next-line typescript/switch-exhaustiveness-check switch (node.type) { case "Identifier": - if (typeof node.name === "string") target.add(node.name); + target.add(node.name); return; case "RestElement": collectBindingNames(node.argument, target); @@ -205,14 +163,12 @@ export function collectBindingNames(pattern: unknown, target: Set): void collectBindingNames(node.parameter, target); return; case "ArrayPattern": - for (const element of nodeArray(node.elements)) collectBindingNames(element, target); + for (const element of node.elements) collectBindingNames(element, target); return; case "ObjectPattern": - for (const property of nodeArray(node.properties)) { - const propertyNode = toAstRecord(property); - if (!propertyNode) continue; + for (const property of node.properties) { collectBindingNames( - propertyNode.type === "Property" ? propertyNode.value : propertyNode.argument, + property.type === "Property" ? property.value : property.argument, target, ); } @@ -220,5 +176,7 @@ export function collectBindingNames(pattern: unknown, target: Set): void case "Property": collectBindingNames(node.value, target); return; + default: + return; } } diff --git a/packages/vinext/src/plugins/dynamic-preload-metadata.ts b/packages/vinext/src/plugins/dynamic-preload-metadata.ts index 5c389e01c4..9eff1579dc 100644 --- a/packages/vinext/src/plugins/dynamic-preload-metadata.ts +++ b/packages/vinext/src/plugins/dynamic-preload-metadata.ts @@ -1,16 +1,13 @@ -import type { Plugin } from "vite"; +import type { ESTree, Plugin } from "vite"; import { parseAst } from "vite"; import MagicString from "magic-string"; import path, { toSlash } from "pathslash"; -import { isUnknownRecord as isRecord } from "../utils/record.js"; import { hasTrailingComma } from "../utils/has-trailing-comma.js"; import { relativeWithinRoot, tryRealpathSync } from "../build/ssr-manifest.js"; import { stripViteModuleQuery } from "../utils/path.js"; -import { getAstName, stringLiteralValue, walkAst } from "./ast-utils.js"; +import { collectBindingNames, forEachAstChild, stringLiteralValue, walkAst } from "./ast-utils.js"; import { magicStringTransformResult } from "./transform-result.js"; -type AstRecord = Record; - type TransformResult = { code: string; map: ReturnType; @@ -18,139 +15,73 @@ type TransformResult = { type ResolveDynamicImport = (specifier: string, importer: string) => Promise; -function getString(node: AstRecord, key: string): string | null { - const value = node[key]; - return typeof value === "string" ? value : null; -} - -function getNumber(node: AstRecord, key: string): number | null { - const value = node[key]; - return typeof value === "number" ? value : null; -} - -function getArray(node: AstRecord, key: string): unknown[] { - const value = node[key]; - return Array.isArray(value) ? value : []; -} - -function getBoolean(node: AstRecord, key: string): boolean { - return node[key] === true; -} - -function importSource(node: AstRecord): string | null { - const source = node.source; - if (!isRecord(source)) return null; - return stringLiteralValue(source); -} - function isNextDynamicSource(source: string | null): boolean { return source === "next/dynamic" || source === "next/dynamic.js"; } -function collectDynamicImportLocals(ast: unknown): Set { +function collectDynamicImportLocals(ast: ESTree.Program): Set { const locals = new Set(); - if (!isRecord(ast)) return locals; - - for (const node of getArray(ast, "body")) { - if (!isRecord(node)) continue; - if (getString(node, "type") !== "ImportDeclaration") continue; - if (!isNextDynamicSource(importSource(node))) continue; - - for (const specifier of getArray(node, "specifiers")) { - if (!isRecord(specifier)) continue; - if (getString(specifier, "type") !== "ImportDefaultSpecifier") continue; - const local = getAstName(specifier.local); - if (local) locals.add(local); + for (const node of ast.body) { + if (node.type !== "ImportDeclaration") continue; + if (!isNextDynamicSource(stringLiteralValue(node.source))) continue; + + for (const specifier of node.specifiers) { + if (specifier.type === "ImportDefaultSpecifier") locals.add(specifier.local.name); } } return locals; } -function isIdentifierNameInSet(node: unknown, names: Set): boolean { - if (!isRecord(node)) return false; - return getString(node, "type") === "Identifier" && names.has(getString(node, "name") ?? ""); +function isIdentifierNameInSet(node: ESTree.Node, names: Set): boolean { + return node.type === "Identifier" && names.has(node.name); } -function isDynamicCall(node: AstRecord, dynamicLocals: Set): boolean { - if (getString(node, "type") !== "CallExpression") return false; - return isIdentifierNameInSet(node.callee, dynamicLocals); +function isDynamicCall( + node: ESTree.Node, + dynamicLocals: Set, +): node is ESTree.CallExpression { + return node.type === "CallExpression" && isIdentifierNameInSet(node.callee, dynamicLocals); } -function addBindingName(pattern: unknown, names: Set): void { - if (!isRecord(pattern)) return; - - const type = getString(pattern, "type"); - if (type === null) return; - - switch (type) { - case "Identifier": { - const name = getString(pattern, "name"); - if (name) names.add(name); - return; - } - case "AssignmentPattern": - addBindingName(pattern.left, names); - return; - case "RestElement": - addBindingName(pattern.argument, names); - return; - case "ArrayPattern": - for (const element of getArray(pattern, "elements")) { - addBindingName(element, names); - } - return; - case "ObjectPattern": - for (const property of getArray(pattern, "properties")) { - if (!isRecord(property)) continue; - if (getString(property, "type") === "RestElement") { - addBindingName(property.argument, names); - continue; - } - addBindingName(property.value, names); - } - return; - default: - return; - } +function addBindingName(pattern: ESTree.Node | null, names: Set): void { + collectBindingNames(pattern, names); } -function addVariableDeclarationBindingNames(node: unknown, names: Set): void { - if (!isRecord(node) || getString(node, "type") !== "VariableDeclaration") return; - for (const declaration of getArray(node, "declarations")) { - if (isRecord(declaration)) addBindingName(declaration.id, names); +function addVariableDeclarationBindingNames(node: ESTree.Node | null, names: Set): void { + if (node?.type !== "VariableDeclaration") return; + for (const declaration of node.declarations) { + addBindingName(declaration.id, names); } } -function collectBlockScopedBindingNames(body: readonly unknown[]): Set { +function collectBlockScopedBindingNames(body: readonly ESTree.Node[]): Set { const names = new Set(); for (const statement of body) { - if (!isRecord(statement)) continue; - - const type = getString(statement, "type"); - if (type === "VariableDeclaration") { - if (getString(statement, "kind") !== "var") { + if (statement.type === "VariableDeclaration") { + if (statement.kind !== "var") { addVariableDeclarationBindingNames(statement, names); } continue; } - if (type === "FunctionDeclaration" || type === "ClassDeclaration") { - const name = getAstName(statement.id); - if (name) names.add(name); + if ( + (statement.type === "FunctionDeclaration" || statement.type === "ClassDeclaration") && + statement.id + ) { + names.add(statement.id.name); } } return names; } -function collectSwitchScopedBindingNames(node: AstRecord): Set { +function collectSwitchScopedBindingNames(node: ESTree.SwitchStatement): Set { const names = new Set(); - for (const switchCase of getArray(node, "cases")) { - if (!isRecord(switchCase)) continue; - for (const statement of getArray(switchCase, "consequent")) { + for (const switchCase of node.cases) { + for (const statement of switchCase.consequent) { for (const name of collectBlockScopedBindingNames([statement])) { names.add(name); } @@ -160,10 +91,10 @@ function collectSwitchScopedBindingNames(node: AstRecord): Set { return names; } -function collectVarBindingNames(value: unknown, names: Set): void { - if (!isRecord(value)) return; +function collectVarBindingNames(value: ESTree.Node | null, names: Set): void { + if (!value) return; - const type = getString(value, "type"); + const type = value.type; if ( type === "FunctionDeclaration" || type === "FunctionExpression" || @@ -172,31 +103,23 @@ function collectVarBindingNames(value: unknown, names: Set): void { return; } - if (type === "VariableDeclaration" && getString(value, "kind") === "var") { + if (type === "VariableDeclaration" && value.kind === "var") { addVariableDeclarationBindingNames(value, names); } - for (const [key, child] of Object.entries(value)) { - if (key === "parent") continue; - if (Array.isArray(child)) { - for (const item of child) { - collectVarBindingNames(item, names); - } - } else if (isRecord(child)) { - collectVarBindingNames(child, names); - } - } + forEachAstChild(value, (child) => collectVarBindingNames(child, names)); } -function collectFunctionScopeBindingNames(node: AstRecord): Set { +function collectFunctionScopeBindingNames( + node: ESTree.Function | ESTree.ArrowFunctionExpression, +): Set { const names = new Set(); - if (getString(node, "type") === "FunctionExpression") { - const name = getAstName(node.id); - if (name) names.add(name); + if (node.type === "FunctionExpression" && node.id) { + names.add(node.id.name); } - for (const param of getArray(node, "params")) { + for (const param of node.params) { addBindingName(param, names); } @@ -204,10 +127,11 @@ function collectFunctionScopeBindingNames(node: AstRecord): Set { return names; } -function collectForBindingNames(node: AstRecord): Set { +function collectForBindingNames( + node: ESTree.ForStatement | ESTree.ForInStatement | ESTree.ForOfStatement, +): Set { const names = new Set(); - addVariableDeclarationBindingNames(node.init, names); - addVariableDeclarationBindingNames(node.left, names); + addVariableDeclarationBindingNames(node.type === "ForStatement" ? node.init : node.left, names); return names; } @@ -225,47 +149,32 @@ function withoutBindings(activeNames: Set, localNames: Set): Set } function visitChildren( - node: AstRecord, + node: ESTree.Node, dynamicLocals: Set, - visitor: (node: AstRecord) => void, + visitor: (node: ESTree.CallExpression) => void, ): void { - for (const [key, child] of Object.entries(node)) { - if (key === "parent") continue; - if (Array.isArray(child)) { - for (const item of child) { - visitDynamicCalls(item, dynamicLocals, visitor); - } - } else if (isRecord(child)) { - visitDynamicCalls(child, dynamicLocals, visitor); - } - } + forEachAstChild(node, (child) => visitDynamicCalls(child, dynamicLocals, visitor)); } function visitDynamicCalls( - value: unknown, + value: ESTree.Node, dynamicLocals: Set, - visitor: (node: AstRecord) => void, + visitor: (node: ESTree.CallExpression) => void, ): void { - if (!isRecord(value) || dynamicLocals.size === 0) return; + if (dynamicLocals.size === 0) return; - const type = getString(value, "type"); + const type = value.type; if (type === "Program") { - const scoped = withoutBindings( - dynamicLocals, - collectBlockScopedBindingNames(getArray(value, "body")), - ); - for (const statement of getArray(value, "body")) { + const scoped = withoutBindings(dynamicLocals, collectBlockScopedBindingNames(value.body)); + for (const statement of value.body) { visitDynamicCalls(statement, scoped, visitor); } return; } if (type === "BlockStatement") { - const scoped = withoutBindings( - dynamicLocals, - collectBlockScopedBindingNames(getArray(value, "body")), - ); - for (const statement of getArray(value, "body")) { + const scoped = withoutBindings(dynamicLocals, collectBlockScopedBindingNames(value.body)); + for (const statement of value.body) { visitDynamicCalls(statement, scoped, visitor); } return; @@ -275,7 +184,7 @@ function visitDynamicCalls( visitDynamicCalls(value.discriminant, dynamicLocals, visitor); const scoped = withoutBindings(dynamicLocals, collectSwitchScopedBindingNames(value)); - for (const switchCase of getArray(value, "cases")) { + for (const switchCase of value.cases) { visitDynamicCalls(switchCase, scoped, visitor); } return; @@ -296,8 +205,7 @@ function visitDynamicCalls( if (type === "ClassDeclaration" || type === "ClassExpression") { const names = new Set(); - const name = getAstName(value.id); - if (name) names.add(name); + if (value.id) names.add(value.id.name); visitChildren(value, withoutBindings(dynamicLocals, names), visitor); return; } @@ -320,12 +228,13 @@ function visitDynamicCalls( visitChildren(value, dynamicLocals, visitor); } -function collectImportSpecifiers(node: unknown): string[] { +function collectImportSpecifiers(node: ESTree.Node | undefined): string[] { const specifiers: string[] = []; const seen = new Set(); + if (!node) return specifiers; walkAst(node, (item) => { - if (getString(item, "type") === "ImportExpression") { + if (item.type === "ImportExpression") { const specifier = stringLiteralValue(item.source); if (specifier && !seen.has(specifier)) { seen.add(specifier); @@ -333,42 +242,35 @@ function collectImportSpecifiers(node: unknown): string[] { } return; } - - if (getString(item, "type") !== "CallExpression") return; - const callee = item.callee; - if (!isRecord(callee) || getString(callee, "type") !== "Import") return; - const firstArg = getArray(item, "arguments")[0]; - const specifier = stringLiteralValue(firstArg); - if (specifier && !seen.has(specifier)) { - seen.add(specifier); - specifiers.push(specifier); - } }); return specifiers; } -function propertyKeyName(property: unknown): string | null { - if (!isRecord(property)) return null; - if (getBoolean(property, "computed")) return null; - return getAstName(property.key); +function propertyKeyName(property: ESTree.ObjectProperty): string | null { + if (property.computed) return null; + const key = property.key; + if (key.type === "Identifier") return key.name; + return stringLiteralValue(key); } -function objectProperties(node: unknown): AstRecord[] { - if (!isRecord(node) || getString(node, "type") !== "ObjectExpression") return []; - return getArray(node, "properties").filter(isRecord); +function objectProperties(node: ESTree.Node | undefined): ESTree.ObjectProperty[] { + if (node?.type !== "ObjectExpression") return []; + return node.properties.filter( + (property): property is ESTree.ObjectProperty => property.type === "Property", + ); } -function hasObjectProperty(node: unknown, name: string): boolean { +function hasObjectProperty(node: ESTree.Node | undefined, name: string): boolean { return objectProperties(node).some((property) => propertyKeyName(property) === name); } -function findObjectProperty(node: unknown, name: string): AstRecord | null { +function findObjectProperty(node: ESTree.Node, name: string): ESTree.ObjectProperty | null { return objectProperties(node).find((property) => propertyKeyName(property) === name) ?? null; } -function dynamicLoaderNode(firstArg: unknown): unknown { - if (!isRecord(firstArg) || getString(firstArg, "type") !== "ObjectExpression") return firstArg; +function dynamicLoaderNode(firstArg: ESTree.Node | undefined): ESTree.Node | undefined { + if (firstArg?.type !== "ObjectExpression") return firstArg; // For the object form `dynamic({ loader })`, scan the `loader` value. The // `modules` fallback mirrors Next.js's react-loadable babel plugin, which // treats `modules` as an alternate loader source (`propertiesMap.modules` → @@ -381,55 +283,39 @@ function dynamicLoaderNode(firstArg: unknown): unknown { return loaderProperty?.value; } -function findLastEndedProperty(node: AstRecord): AstRecord | null { - const properties = objectProperties(node); - for (let index = properties.length - 1; index >= 0; index -= 1) { - if (getNumber(properties[index], "end") !== null) { - return properties[index]; - } - } - return null; +function findLastObjectMember(node: ESTree.ObjectExpression): ESTree.ObjectPropertyKind | null { + return node.properties.at(-1) ?? null; } function appendObjectProperty( output: MagicString, - objectNode: AstRecord, + objectNode: ESTree.ObjectExpression, property: string, ): boolean { - const start = getNumber(objectNode, "start"); - const end = getNumber(objectNode, "end"); - if (start === null || end === null) return false; - - const lastProperty = findLastEndedProperty(objectNode); - if (!lastProperty) { - output.appendLeft(start + 1, property); + const lastMember = findLastObjectMember(objectNode); + if (!lastMember) { + output.appendLeft(objectNode.start + 1, property); return true; } - const propertyEnd = getNumber(lastProperty, "end"); - if (propertyEnd === null) return false; - output.appendLeft(propertyEnd, `, ${property}`); + output.appendLeft(lastMember.end, `, ${property}`); return true; } function insertSecondOptionsArgument( output: MagicString, code: string, - callNode: AstRecord, - firstArg: AstRecord, + callNode: ESTree.CallExpression, + firstArg: ESTree.Argument, optionsLiteral: string, ): boolean { - const callEnd = getNumber(callNode, "end"); - const firstArgEnd = getNumber(firstArg, "end"); - if (callEnd === null || firstArgEnd === null) return false; - // Insert just before the call's closing paren (AST `end` is exclusive, so // `callEnd - 1` is the `)`). This is PAREN-SAFE: a parenthesized first // argument such as `dynamic((() => import("./x")))` reports its `end` BEFORE // the wrapping paren, so inserting at the first arg's end would land inside // those parens and turn the loader into a sequence expression — silently // dropping it. The call's close paren is always past the whole argument list. - const closeParen = callEnd - 1; + const closeParen = callNode.end - 1; // Decide the separator with a COMMENT-AWARE trailing-comma check: // `hasTrailingComma` inspects only the gap between the first argument and the @@ -438,7 +324,7 @@ function insertSecondOptionsArgument( // comma (`dynamic(loader,)`) must NOT get a second one (`,,` is a syntax // error), and a comma living inside a comment must NOT be mistaken for a real // one (the old substring scan overwrote — and thus ate — such comments). - const separator = hasTrailingComma(code.slice(firstArgEnd, closeParen)) ? " " : ", "; + const separator = hasTrailingComma(code.slice(firstArg.end, closeParen)) ? " " : ", "; output.appendLeft(closeParen, `${separator}${optionsLiteral}`); return true; } @@ -467,12 +353,10 @@ function cachedRootRealpath(root: string): string | null { } /** `code` offset -> human `:line:column` (1-based), for build error messages. */ -function formatNodeLocation(code: string, node: AstRecord): string { - const start = getNumber(node, "start"); - if (start === null) return ""; - const before = code.slice(0, start); +function formatNodeLocation(code: string, node: ESTree.Node): string { + const before = code.slice(0, node.start); const line = before.split("\n").length; - const column = start - before.lastIndexOf("\n"); + const column = node.start - before.lastIndexOf("\n"); return `:${line}:${column}`; } @@ -530,7 +414,7 @@ async function resolveManifestModuleIds( return resolvedIds; } -function shouldSkipCall(firstArg: unknown, secondArg: unknown): boolean { +function shouldSkipCall(firstArg: ESTree.Node, secondArg: ESTree.Node | undefined): boolean { if (hasObjectProperty(firstArg, "loadableGenerated")) return true; return hasObjectProperty(secondArg, "loadableGenerated"); } @@ -538,17 +422,17 @@ function shouldSkipCall(firstArg: unknown, secondArg: unknown): boolean { function applyLoadableGenerated( output: MagicString, code: string, - callNode: AstRecord, + callNode: ESTree.CallExpression, moduleIds: readonly string[], ): boolean { - const args = getArray(callNode, "arguments"); + const args = callNode.arguments; const firstArg = args[0]; const secondArg = args[1]; - if (!isRecord(firstArg)) return false; + if (!firstArg) return false; if (shouldSkipCall(firstArg, secondArg)) return false; const property = `loadableGenerated: { modules: ${JSON.stringify(moduleIds)} }`; - const firstArgIsObject = getString(firstArg, "type") === "ObjectExpression"; + const firstArgIsObject = firstArg.type === "ObjectExpression"; if (firstArgIsObject) { return appendObjectProperty(output, firstArg, property); } @@ -557,7 +441,7 @@ function applyLoadableGenerated( return insertSecondOptionsArgument(output, code, callNode, firstArg, `{ ${property} }`); } - if (isRecord(secondArg) && getString(secondArg, "type") === "ObjectExpression") { + if (secondArg?.type === "ObjectExpression") { return appendObjectProperty(output, secondArg, property); } @@ -572,7 +456,7 @@ export async function transformNextDynamicPreloadMetadata( ): Promise { if (!code.includes("next/dynamic")) return null; - let ast: unknown; + let ast: ReturnType; try { // `parseAst` is Vite's bundled oxc parser in plain-JS mode — it does NOT // accept JSX or TS syntax. This is correct ONLY because the plugin runs as a @@ -607,7 +491,7 @@ export async function transformNextDynamicPreloadMetadata( // append after an argument / inside an options object), so insertion order is // irrelevant. Promise ordering is NOT what makes this correct. visitDynamicCalls(ast, dynamicLocals, (node) => { - const args = getArray(node, "arguments"); + const args = node.arguments; // Match Next.js's react-loadable plugin, which throws on >2 arguments. if (args.length > 2) { throw new Error( diff --git a/packages/vinext/src/plugins/extensionless-dynamic-import.ts b/packages/vinext/src/plugins/extensionless-dynamic-import.ts index 5a2749760b..3aac146a4c 100644 --- a/packages/vinext/src/plugins/extensionless-dynamic-import.ts +++ b/packages/vinext/src/plugins/extensionless-dynamic-import.ts @@ -2,16 +2,12 @@ import MagicString from "magic-string"; import fs from "node:fs"; import { createRequire } from "node:module"; import path, { toSlash } from "pathslash"; -import { parseAst, type Alias, type Plugin } from "vite"; +import { parseAst, type Alias, type ESTree, type Plugin } from "vite"; import { DYNAMIC_IMPORT_PRESCAN, - hasRange, - isAstRecord, - nodeArray, SCRIPT_MODULE_ID_RE, scriptParserLanguage, walkAst, - type AstRecord, } from "./ast-utils.js"; import { createTransformCache } from "./transform-cache.js"; import { magicStringTransformResult } from "./transform-result.js"; @@ -122,7 +118,7 @@ function transformExtensionlessImports( ): TransformResult { const lang = scriptParserLanguage(id)!; - let ast: unknown; + let ast: ReturnType; try { ast = parseAst(code, { lang }); } catch { @@ -148,7 +144,7 @@ function transformExtensionlessImports( } function collectExtensionlessImports( - ast: unknown, + ast: ESTree.Program, code: string, config: TransformConfig, id: string, @@ -166,19 +162,17 @@ function collectExtensionlessImports( } function parseExtensionlessImport( - node: AstRecord, + node: ESTree.Node, code: string, config: TransformConfig, id: string, ): ExtensionlessImport | null { - if (node.type !== "ImportExpression" || !hasRange(node)) return null; + if (node.type !== "ImportExpression") return null; if (node.options != null) return null; const source = node.source; - if (!isAstRecord(source) || source.type !== "TemplateLiteral" || !hasRange(source)) return null; - if (nodeArray(source.expressions).length === 0) return null; + if (source.type !== "TemplateLiteral" || source.expressions.length === 0) return null; - const quasis = nodeArray(source.quasis); - const quasiTexts = quasis.map(templateElementText); + const quasiTexts = source.quasis.map(templateElementText); if (quasiTexts.some((text) => text == null)) return null; const texts = quasiTexts as string[]; const first = texts[0]; @@ -666,11 +660,8 @@ function isImportPrefix(value: string): boolean { return true; } -function templateElementText(value: unknown): string | null { - if (!isAstRecord(value) || value.type !== "TemplateElement") return null; - const templateValue = value.value; - if (typeof templateValue !== "object" || templateValue === null) return null; - const cooked = Reflect.get(templateValue, "cooked"); +function templateElementText(value: ESTree.TemplateElement): string | null { + const { cooked } = value.value; return typeof cooked === "string" ? cooked : null; } diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 3488497abc..1ff66ccb1d 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -1,21 +1,17 @@ import path, { toSlash } from "pathslash"; import { fileURLToPath } from "node:url"; import MagicString from "magic-string"; -import { parseAst, type Plugin } from "vite"; +import { parseAst, type ESTree, type Plugin } from "vite"; import { collectBindingNames, DYNAMIC_IMPORT_PRESCAN, forEachAstChild, - hasRange, - isAstRecord, isIdentifierNamed, mayContainDynamicImport, - nodeArray, SCRIPT_MODULE_ID_RE, scriptParserLanguage, stringLiteralValue, unwrapExpression, - type AstRecord, } from "./ast-utils.js"; import { createTransformCache } from "./transform-cache.js"; import { magicStringTransformResult } from "./transform-result.js"; @@ -48,7 +44,7 @@ type Scope = { }; type ConstantBinding = { - initializer: AstRecord; + initializer: ESTree.Node; scope: Scope; }; @@ -63,11 +59,7 @@ type EnvironmentLike = { }; }; -function astNode(value: unknown): AstRecord | null { - return isAstRecord(value) ? value : null; -} - -function stringFromCharCodeValue(value: unknown, scope: Scope): string | null { +function stringFromCharCodeValue(value: ESTree.Node, scope: Scope): string | null { const node = unwrapExpression(value); if (node?.type !== "CallExpression") return null; const callee = unwrapExpression(node.callee); @@ -84,7 +76,7 @@ function stringFromCharCodeValue(value: unknown, scope: Scope): string | null { } let resolved = ""; - for (const argument of nodeArray(node.arguments)) { + for (const argument of node.arguments) { const argumentNode = unwrapExpression(argument); if ( argumentNode?.type !== "Literal" || @@ -100,17 +92,16 @@ function stringFromCharCodeValue(value: unknown, scope: Scope): string | null { return resolved; } -function isUnboundNumericGlobal(node: AstRecord, scope: Scope): boolean { +function isUnboundNumericGlobal(node: ESTree.Node, scope: Scope): boolean { return ( node.type === "Identifier" && - typeof node.name === "string" && !hasAstBinding(scope, node.name) && (isIdentifierNamed(node, "NaN") || isIdentifierNamed(node, "Infinity")) ); } function evaluateStaticString( - value: unknown, + value: ESTree.Node | null | undefined, scope: Scope, resolution: ConstantResolution, ): string | null { @@ -118,15 +109,8 @@ function evaluateStaticString( if (!node) return null; const valueString = stringLiteralValue(node); if (valueString !== null) return valueString; - if (node.type === "TemplateLiteral" && nodeArray(node.expressions).length === 0) { - const quasi = astNode(nodeArray(node.quasis)[0]); - const quasiValue = quasi?.value; - const cooked = - typeof quasiValue === "object" && quasiValue !== null - ? Reflect.get(quasiValue, "cooked") - : null; - const raw = - typeof quasiValue === "object" && quasiValue !== null ? Reflect.get(quasiValue, "raw") : null; + if (node.type === "TemplateLiteral" && node.expressions.length === 0) { + const { cooked, raw } = node.quasis[0]?.value ?? {}; return typeof cooked === "string" ? cooked : typeof raw === "string" ? raw : null; } if (node.type === "BinaryExpression" && node.operator === "+") { @@ -144,9 +128,9 @@ function evaluateStaticString( return consequent !== null && consequent === alternate ? consequent : null; } if (node.type === "SequenceExpression") { - return evaluateStaticString(nodeArray(node.expressions).at(-1), scope, resolution); + return evaluateStaticString(node.expressions.at(-1), scope, resolution); } - if (node.type === "Identifier" && typeof node.name === "string") { + if (node.type === "Identifier") { return resolveConstantBinding(scope, node.name, resolution, null, evaluateStaticString); } return null; @@ -157,14 +141,14 @@ function hasSignificantPathPart(value: string): boolean { return normalized !== "" && normalized !== "/"; } -function templateElementValue(quasi: AstRecord | undefined, raw: boolean): string { +function templateElementValue(quasi: ESTree.TemplateElement | undefined, raw: boolean): string { const value = quasi?.value; if (typeof value !== "object" || value === null) return ""; const elementValue = Reflect.get(value, raw ? "raw" : "cooked"); return typeof elementValue === "string" ? elementValue : ""; } -function isUnboundStringRawTag(value: unknown, scope: Scope): boolean { +function isUnboundStringRawTag(value: ESTree.Node, scope: Scope): boolean { const tag = unwrapExpression(value); const object = tag?.type === "MemberExpression" ? unwrapExpression(tag.object) : null; const property = tag?.type === "MemberExpression" ? unwrapExpression(tag.property) : null; @@ -179,18 +163,16 @@ function isUnboundStringRawTag(value: unknown, scope: Scope): boolean { function hasDynamicRequestIgnoreDirective( code: string, - requestNode: AstRecord, - argumentNode: AstRecord, + requestNode: ESTree.CallExpression | ESTree.ImportExpression, + argumentNode: ESTree.Node, ): boolean { - if (!hasRange(requestNode) || !hasRange(argumentNode)) return false; const comments: string[] = []; - const callee = astNode(requestNode.callee); - let index = - callee && hasRange(callee) - ? callee.end - : requestNode.type === "ImportExpression" - ? requestNode.start + "import".length - : requestNode.start; + const callee = requestNode.type === "CallExpression" ? requestNode.callee : null; + let index = callee + ? callee.end + : requestNode.type === "ImportExpression" + ? requestNode.start + "import".length + : requestNode.start; while (index < argumentNode.start) { if (/\s/.test(code[index])) { @@ -253,20 +235,20 @@ function hasDynamicRequestIgnoreDirective( } function templateHasStaticPart( - node: AstRecord, + node: ESTree.TemplateLiteral, scope: Scope, resolution: ConstantResolution, useRaw = false, ): boolean { - const quasis = nodeArray(node.quasis).filter(isAstRecord); - if (nodeArray(node.expressions).length === 0) { + const quasis = node.quasis; + if (node.expressions.length === 0) { return templateElementValue(quasis[0], useRaw).replaceAll("\\", "/") !== "/"; } if (quasis.some((quasi) => hasSignificantPathPart(templateElementValue(quasi, useRaw)))) { return true; } - return nodeArray(node.expressions).some((expression) => { + return node.expressions.some((expression) => { const expressionNode = unwrapExpression(expression); if (!expressionNode) return false; return requestHasStaticPart(expressionNode, scope, resolution); @@ -274,24 +256,21 @@ function templateHasStaticPart( } function stringRawTemplateHasStaticPart( - node: AstRecord, + node: ESTree.TaggedTemplateExpression, scope: Scope, resolution: ConstantResolution, ): boolean | null { if (node.type !== "TaggedTemplateExpression") return null; if (!isUnboundStringRawTag(node.tag, scope)) return null; - const quasi = astNode(node.quasi); - return quasi?.type === "TemplateLiteral" - ? templateHasStaticPart(quasi, scope, resolution, true) - : null; + return templateHasStaticPart(node.quasi, scope, resolution, true); } -function isLiteralExpression(value: unknown): boolean { +function isLiteralExpression(value: ESTree.Node | null | undefined): boolean { const node = unwrapExpression(value); - return node?.type === "Literal" || node?.type === "StringLiteral"; + return node?.type === "Literal"; } -function isNegativeNumericLiteral(value: unknown): boolean { +function isNegativeNumericLiteral(value: ESTree.Node | null | undefined): boolean { const node = unwrapExpression(value); if (node?.type !== "UnaryExpression" || node.operator !== "-") return false; const argument = unwrapExpression(node.argument); @@ -299,16 +278,16 @@ function isNegativeNumericLiteral(value: unknown): boolean { } function templateTruthiness( - node: AstRecord, + node: ESTree.TemplateLiteral, scope: Scope, resolution: ConstantResolution, useRaw = false, ): boolean | null { - const quasis = nodeArray(node.quasis).filter(isAstRecord); + const quasis = node.quasis; if (quasis.some((quasi) => templateElementValue(quasi, useRaw) !== "")) return true; let hasUnknownExpression = false; - for (const expression of nodeArray(node.expressions)) { + for (const expression of node.expressions) { const string = evaluateStaticString(expression, scope, resolution); if (string !== null) { if (string !== "") return true; @@ -327,29 +306,26 @@ function templateTruthiness( } function staticTruthiness( - value: unknown, + value: ESTree.Node | null | undefined, scope: Scope, resolution = createConstantResolution(), ): boolean | null { const node = unwrapExpression(value); if (!node) return null; - if (node.type === "Literal" || node.type === "StringLiteral") return Boolean(node.value); + if (node.type === "Literal") return Boolean(node.value); if (isUnboundNumericGlobal(node, scope)) return true; if (node.type === "TemplateLiteral") { return templateTruthiness(node, scope, resolution); } if (node.type === "TaggedTemplateExpression" && isUnboundStringRawTag(node.tag, scope)) { - const quasi = astNode(node.quasi); - return quasi?.type === "TemplateLiteral" - ? templateTruthiness(quasi, scope, resolution, true) - : null; + return templateTruthiness(node.quasi, scope, resolution, true); } if (node.type === "BinaryExpression" && node.operator === "+") { const string = evaluateStaticString(node, scope, resolution); return string === null ? null : Boolean(string); } if (isIdentifierNamed(node, "undefined") && !hasAstBinding(scope, "undefined")) return false; - if (node.type === "Identifier" && typeof node.name === "string") { + if (node.type === "Identifier") { return resolveConstantBinding(scope, node.name, resolution, null, staticTruthiness); } if (node.type === "UnaryExpression") { @@ -374,16 +350,16 @@ function staticTruthiness( } function staticNullishness( - value: unknown, + value: ESTree.Node | null | undefined, scope: Scope, resolution = createConstantResolution(), ): boolean | null { const node = unwrapExpression(value); if (!node) return null; - if (node.type === "Literal" || node.type === "StringLiteral") return node.value === null; + if (node.type === "Literal") return node.value === null; if (isUnboundNumericGlobal(node, scope)) return false; if (isIdentifierNamed(node, "undefined") && !hasAstBinding(scope, "undefined")) return true; - if (node.type === "Identifier" && typeof node.name === "string") { + if (node.type === "Identifier") { return resolveConstantBinding(scope, node.name, resolution, null, staticNullishness); } if (node.type === "UnaryExpression") { @@ -419,7 +395,7 @@ function resolveConstantBinding( name: string, resolution: ConstantResolution, fallback: T, - evaluate: (value: unknown, scope: Scope, resolution: ConstantResolution) => T, + evaluate: (value: ESTree.Node, scope: Scope, resolution: ConstantResolution) => T, ): T { const binding = findConstantBinding(scope, name); if ( @@ -439,7 +415,7 @@ function resolveConstantBinding( } function stringConcatHasStaticPart( - node: AstRecord, + node: ESTree.Node, scope: Scope, resolution: ConstantResolution, ): boolean | null { @@ -459,21 +435,21 @@ function stringConcatHasStaticPart( if (!receiver || !isStaticStringExpression(receiver, scope, resolution)) return null; if (requestHasStaticPart(receiver, scope, resolution)) return true; - return nodeArray(node.arguments).some((argument) => { + return node.arguments.some((argument) => { const argumentNode = unwrapExpression(argument); return argumentNode ? requestHasStaticPart(argumentNode, scope, resolution) : false; }); } function isStaticStringExpression( - value: unknown, + value: ESTree.Node | null | undefined, scope: Scope, resolution: ConstantResolution, ): boolean { const node = unwrapExpression(value); if (!node) return false; if (stringLiteralValue(node) !== null || node.type === "TemplateLiteral") return true; - if (node.type === "Identifier" && typeof node.name === "string") { + if (node.type === "Identifier") { return resolveConstantBinding(scope, node.name, resolution, false, isStaticStringExpression); } if (node.type === "BinaryExpression" && node.operator === "+") { @@ -486,7 +462,7 @@ function isStaticStringExpression( ); } if (node.type === "SequenceExpression") { - return isStaticStringExpression(nodeArray(node.expressions).at(-1), scope, resolution); + return isStaticStringExpression(node.expressions.at(-1), scope, resolution); } if (node.type === "CallExpression") { return stringConcatHasStaticPart(node, scope, resolution) !== null; @@ -495,14 +471,14 @@ function isStaticStringExpression( } function additionContainsString( - value: unknown, + value: ESTree.Node | null | undefined, scope: Scope, resolution: ConstantResolution, ): boolean { const node = unwrapExpression(value); if (!node) return false; if (stringLiteralValue(node) !== null || node.type === "TemplateLiteral") return true; - if (node.type === "Identifier" && typeof node.name === "string") { + if (node.type === "Identifier") { return resolveConstantBinding(scope, node.name, resolution, false, additionContainsString); } if (node.type === "BinaryExpression" && node.operator === "+") { @@ -518,13 +494,13 @@ function additionContainsString( ); } if (node.type === "SequenceExpression") { - return additionContainsString(nodeArray(node.expressions).at(-1), scope, resolution); + return additionContainsString(node.expressions.at(-1), scope, resolution); } return stringConcatHasStaticPart(node, scope, resolution) !== null; } function requestHasStaticPart( - value: unknown, + value: ESTree.Node | null | undefined, scope: Scope, resolution = createConstantResolution(), ): boolean { @@ -538,12 +514,15 @@ function requestHasStaticPart( if (node.type === "TemplateLiteral") { return templateHasStaticPart(node, scope, resolution); } - const stringRawHasStaticPart = stringRawTemplateHasStaticPart(node, scope, resolution); + const stringRawHasStaticPart = + node.type === "TaggedTemplateExpression" + ? stringRawTemplateHasStaticPart(node, scope, resolution) + : null; if (stringRawHasStaticPart !== null) return stringRawHasStaticPart; const concatHasStaticPart = stringConcatHasStaticPart(node, scope, resolution); if (concatHasStaticPart !== null) return concatHasStaticPart; if (isIdentifierNamed(node, "undefined") && !hasAstBinding(scope, "undefined")) return true; - if (node.type === "Identifier" && typeof node.name === "string") { + if (node.type === "Identifier") { return resolveConstantBinding(scope, node.name, resolution, false, requestHasStaticPart); } if (node.type === "UnaryExpression") { @@ -595,7 +574,7 @@ function requestHasStaticPart( ); } if (node.type === "SequenceExpression") { - const expressions = nodeArray(node.expressions); + const expressions = node.expressions; if (expressions.length === 0) return false; return ( expressions @@ -608,12 +587,14 @@ function requestHasStaticPart( return false; } -function expressionMayHaveSideEffects(value: unknown, scope: Scope): boolean { +function expressionMayHaveSideEffects( + value: ESTree.Node | null | undefined, + scope: Scope, +): boolean { const node = unwrapExpression(value); if (!node) return false; if ( node.type === "Literal" || - node.type === "StringLiteral" || node.type === "Identifier" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression" @@ -621,9 +602,7 @@ function expressionMayHaveSideEffects(value: unknown, scope: Scope): boolean { return false; } if (node.type === "TemplateLiteral") { - return nodeArray(node.expressions).some((expression) => - expressionMayHaveSideEffects(expression, scope), - ); + return node.expressions.some((expression) => expressionMayHaveSideEffects(expression, scope)); } if (node.type === "UnaryExpression") { return node.operator === "delete" || expressionMayHaveSideEffects(node.argument, scope); @@ -645,34 +624,25 @@ function expressionMayHaveSideEffects(value: unknown, scope: Scope): boolean { ); } if (node.type === "SequenceExpression") { - return nodeArray(node.expressions).some((expression) => - expressionMayHaveSideEffects(expression, scope), - ); + return node.expressions.some((expression) => expressionMayHaveSideEffects(expression, scope)); } if (node.type === "ArrayExpression") { - return nodeArray(node.elements).some((element) => { - const elementNode = astNode(element); - return ( - elementNode?.type === "SpreadElement" || expressionMayHaveSideEffects(elementNode, scope) - ); - }); + return node.elements.some( + (element) => + element?.type === "SpreadElement" || expressionMayHaveSideEffects(element, scope), + ); } if (node.type === "ObjectExpression") { - return nodeArray(node.properties).some((property) => { - const propertyNode = astNode(property); - if (propertyNode?.type === "SpreadElement") { - return expressionMayHaveSideEffects(propertyNode.argument, scope); + return node.properties.some((property) => { + if (property.type === "SpreadElement") { + return expressionMayHaveSideEffects(property.argument, scope); } - if ( - propertyNode?.type !== "Property" || - propertyNode.kind !== "init" || - propertyNode.method === true - ) { + if (property.type !== "Property" || property.kind !== "init" || property.method === true) { return true; } return ( - expressionMayHaveSideEffects(propertyNode.computed ? propertyNode.key : null, scope) || - expressionMayHaveSideEffects(propertyNode.value, scope) + expressionMayHaveSideEffects(property.computed ? property.key : null, scope) || + expressionMayHaveSideEffects(property.value, scope) ); }); } @@ -684,12 +654,8 @@ function expressionMayHaveSideEffects(value: unknown, scope: Scope): boolean { } if (node.type === "TaggedTemplateExpression") { if (isUnboundStringRawTag(node.tag, scope)) { - const quasi = astNode(node.quasi); - return ( - quasi?.type !== "TemplateLiteral" || - nodeArray(quasi.expressions).some((expression) => - expressionMayHaveSideEffects(expression, scope), - ) + return node.quasi.expressions.some((expression) => + expressionMayHaveSideEffects(expression, scope), ); } return true; @@ -698,20 +664,19 @@ function expressionMayHaveSideEffects(value: unknown, scope: Scope): boolean { return true; } -function collectConstantBinding(declaration: AstRecord, declarator: AstRecord, scope: Scope): void { - const identifier = astNode(declarator.id); - const initializer = astNode(declarator.init); - if ( - declaration.kind === "const" && - identifier?.type === "Identifier" && - typeof identifier.name === "string" && - initializer - ) { +function collectConstantBinding( + declaration: ESTree.VariableDeclaration, + declarator: ESTree.VariableDeclarator, + scope: Scope, +): void { + const identifier = declarator.id; + const initializer = declarator.init; + if (declaration.kind === "const" && identifier?.type === "Identifier" && initializer) { scope.constants.set(identifier.name, { initializer, scope }); } } -function collectDirectBindings(node: AstRecord, scope: Scope): void { +function collectDirectBindings(node: ESTree.Node, scope: Scope): void { collectDirectScopeBindings(node, scope, (declaration, declarator) => collectConstantBinding(declaration, declarator, scope), ); @@ -749,13 +714,12 @@ function transformVeryDynamicRequests(code: string, id: string) { const output = new MagicString(code); let changed = false; - const root = astNode(ast); - if (!root) return null; + const root = ast; const rootScope: Scope = { parent: null, bindings: new Set(), constants: new Map() }; collectDirectBindings(root, rootScope); collectVarScopeBindings(root, rootScope); - function visit(node: AstRecord, parentScope: Scope): void { + function visit(node: ESTree.Node, parentScope: Scope): void { let scope = parentScope; if (isFunctionNode(node)) { const parameterScope: Scope = { @@ -764,15 +728,11 @@ function transformVeryDynamicRequests(code: string, id: string) { constants: new Map(), }; collectBindingNames(node.id, parameterScope.bindings); - for (const parameter of nodeArray(node.params)) - collectBindingNames(parameter, parameterScope.bindings); + for (const parameter of node.params) collectBindingNames(parameter, parameterScope.bindings); - for (const parameter of nodeArray(node.params)) { - const parameterNode = astNode(parameter); - if (parameterNode) visit(parameterNode, parameterScope); - } + for (const parameter of node.params) visit(parameter, parameterScope); - const body = astNode(node.body); + const body = node.body; if (body) { const bodyScope: Scope = { parent: parameterScope, @@ -782,31 +742,24 @@ function transformVeryDynamicRequests(code: string, id: string) { collectDirectBindings(body, bodyScope); collectVarScopeBindings(body, bodyScope); if (body.type === "BlockStatement") { - for (const statement of nodeArray(body.body)) { - const statementNode = astNode(statement); - if (statementNode) visit(statementNode, bodyScope); - } + for (const statement of body.body) visit(statement, bodyScope); } else { visit(body, bodyScope); } } return; } else if (node.type === "SwitchStatement") { - const discriminant = astNode(node.discriminant); - if (discriminant) visit(discriminant, parentScope); + visit(node.discriminant, parentScope); const switchScope: Scope = { parent: parentScope, bindings: new Set(), constants: new Map(), }; collectDirectBindings(node, switchScope); - for (const switchCase of nodeArray(node.cases)) { - const switchCaseNode = astNode(switchCase); - if (switchCaseNode) visit(switchCaseNode, switchScope); - } + for (const switchCase of node.cases) visit(switchCase, switchScope); return; } else if ( - (node.type === "BlockStatement" && node !== root) || + node.type === "BlockStatement" || node.type === "StaticBlock" || node.type === "TSModuleBlock" ) { @@ -832,29 +785,25 @@ function transformVeryDynamicRequests(code: string, id: string) { collectBindingNames(node.id, scope.bindings); } - if (node.type === "CallExpression" && hasRange(node)) { + if (node.type === "CallExpression") { const callee = unwrapExpression(node.callee); - const argumentsList = nodeArray(node.arguments); + const argumentsList = node.arguments; + const argument = argumentsList[0]; if ( isIdentifierNamed(callee, "require") && !hasAstBinding(scope, "require") && argumentsList.length === 1 && - astNode(argumentsList[0])?.type !== "SpreadElement" && - !hasDynamicRequestIgnoreDirective(code, node, argumentsList[0] as AstRecord) + argument && + argument.type !== "SpreadElement" && + !hasDynamicRequestIgnoreDirective(code, node, argument) ) { - const resolvedRequest = stringFromCharCodeValue(argumentsList[0], scope); - const argument = astNode(argumentsList[0]); - if ( - resolvedRequest !== null && - resolvedRequest.replaceAll("\\", "/") !== "/" && - argument && - hasRange(argument) - ) { + const resolvedRequest = stringFromCharCodeValue(argument, scope); + if (resolvedRequest !== null && resolvedRequest.replaceAll("\\", "/") !== "/") { output.overwrite(argument.start, argument.end, JSON.stringify(resolvedRequest)); changed = true; return; } - if (!requestHasStaticPart(argumentsList[0], scope)) { + if (!requestHasStaticPart(argument, scope)) { output.overwrite(node.start, node.end, dynamicRequireReplacement()); changed = true; return; @@ -864,8 +813,7 @@ function transformVeryDynamicRequests(code: string, id: string) { if ( node.type === "ImportExpression" && - hasRange(node) && - !hasDynamicRequestIgnoreDirective(code, node, node.source as AstRecord) && + !hasDynamicRequestIgnoreDirective(code, node, node.source) && !requestHasStaticPart(node.source, scope) ) { output.overwrite(node.start, node.end, dynamicImportReplacement()); @@ -876,9 +824,7 @@ function transformVeryDynamicRequests(code: string, id: string) { forEachAstChild(node, (child) => visit(child, scope)); } - for (const statement of nodeArray(root.body)) { - if (isAstRecord(statement)) visit(statement, rootScope); - } + for (const statement of root.body) visit(statement, rootScope); if (!changed) return null; return magicStringTransformResult(output, { hires: "boundary", source: id }); diff --git a/packages/vinext/src/plugins/import-meta-url.ts b/packages/vinext/src/plugins/import-meta-url.ts index 5db660c64e..5d77732784 100644 --- a/packages/vinext/src/plugins/import-meta-url.ts +++ b/packages/vinext/src/plugins/import-meta-url.ts @@ -19,7 +19,7 @@ // deliberately uses emitted identity for bundled dependencies instead: source // paths do not exist in Workers and must not leak from the build host, while an // emitted URL remains meaningful after relocating Node and Nitro output. -import { parseAst, type Plugin, type ResolvedConfig } from "vite"; +import { parseAst, type ESTree, type Plugin, type ResolvedConfig } from "vite"; import MagicString from "magic-string"; import path, { toSlash } from "pathslash"; import { randomUUID } from "node:crypto"; @@ -30,14 +30,9 @@ import { VIRTUAL_MODULE_ID_RE, VIRTUAL_PREFIX } from "../utils/virtual-module.js import { collectBindingNames, forEachAstChild, - hasRange, - isAstRecord, isIdentifierNamed, - nodeArray, SCRIPT_MODULE_ID_RE, scriptParserLanguage, - type AstRange, - type AstRecord, } from "./ast-utils.js"; import { magicStringTransformResult, type MagicStringTransformResult } from "./transform-result.js"; @@ -522,7 +517,7 @@ function rewriteModuleIdentity( cjsGlobalInitializers?: CjsGlobalInitializers; }, ): MagicStringTransformResult | null { - let ast: unknown; + let ast: ReturnType; try { ast = parseAst(code, { lang: scriptParserLanguage(options.id) ?? "jsx", @@ -721,12 +716,10 @@ function importMetaUrlValue( return pathToFileURL(canonicalId).href; } -function collectImportMetaUrlRanges(ast: unknown): Array<{ start: number; end: number }> { +function collectImportMetaUrlRanges(ast: ESTree.Program): Array<{ start: number; end: number }> { const ranges: Array<{ start: number; end: number }> = []; - function visit(value: unknown): void { - if (!isAstRecord(value)) return; - + function visit(value: ESTree.Node): void { if (isImportMetaUrlNode(value)) { ranges.push({ start: value.start, end: value.end }); return; @@ -738,7 +731,7 @@ function collectImportMetaUrlRanges(ast: unknown): Array<{ start: number; end: n } if (isNewUrlExpression(value)) { - const args = nodeArray(value.arguments); + const args = value.arguments; for (let index = 0; index < args.length; index += 1) { if (index === 1 && isImportMetaUrlBaseNode(args[index])) continue; visit(args[index]); @@ -777,7 +770,10 @@ function sourcePathCjsGlobalInitializers(canonicalId: string): CjsGlobalInitiali }; } -function injectServerCjsGlobals(ast: unknown, initializers: CjsGlobalInitializers): string | null { +function injectServerCjsGlobals( + ast: ESTree.Program, + initializers: CjsGlobalInitializers, +): string | null { const analysis = analyzeServerCjsGlobals(ast); const parts = CJS_GLOBALS.filter( (name) => analysis.reads.has(name) && !analysis.moduleBindings.has(name), @@ -794,12 +790,12 @@ type ServerCjsAnalysis = { // - reads: names used as values // - moduleBindings: names bound anywhere in module scope, including `var` // declarations hidden inside top-level blocks and control flow -function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { +function analyzeServerCjsGlobals(ast: ESTree.Program): ServerCjsAnalysis { const reads = new Set(); const moduleBindings = new Set(); // Recursively walks a binding pattern. Each name found is a module binding. - function recordBinding(pattern: unknown): void { + function recordBinding(pattern: ESTree.Node | null): void { const names = new Set(); collectBindingNames(pattern, names); for (const name of names) { @@ -810,30 +806,32 @@ function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { // Records bindings declared directly by a top-level statement. `var` is // handled by the recursive walk below so nested blocks and loops use the // same rule. - function recordDirectTopLevelBindings(statement: AstRecord): void { - if (statement.declare === true) return; + function recordDirectTopLevelBindings(statement: ESTree.Node): void { const t = statement.type; + // This visitor intentionally handles only declarations that introduce module bindings. + // oxlint-disable-next-line typescript/switch-exhaustiveness-check switch (t) { case "ImportDeclaration": - for (const specifier of nodeArray(statement.specifiers)) { - if (!isAstRecord(specifier)) continue; + if (statement.importKind === "type") return; + for (const specifier of statement.specifiers) { + if (specifier.type === "ImportSpecifier" && specifier.importKind === "type") continue; recordBinding(specifier.local); } return; case "VariableDeclaration": - if (statement.kind === "var") return; - for (const declarator of nodeArray(statement.declarations)) { - if (!isAstRecord(declarator) || declarator.type !== "VariableDeclarator") continue; + if (statement.kind === "var" || statement.declare === true) return; + for (const declarator of statement.declarations) { recordBinding(declarator.id); } return; case "FunctionDeclaration": case "ClassDeclaration": + if (statement.declare === true) return; recordBinding(statement.id); return; case "ExportNamedDeclaration": case "ExportDefaultDeclaration": - if (isAstRecord(statement.declaration)) { + if (statement.declaration) { recordDirectTopLevelBindings(statement.declaration); } return; @@ -842,21 +840,21 @@ function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { // Walk only syntax whose `var` declarations remain module-scoped. Function // and class bodies are scope boundaries. - function recordModuleScopedVarBindings(node: unknown): void { - if (!isAstRecord(node)) return; + function recordModuleScopedVarBindings(node: ESTree.Node | null): void { + if (!node) return; const t = node.type; + // This walk follows only syntax in which `var` remains module-scoped. + // oxlint-disable-next-line typescript/switch-exhaustiveness-check switch (t) { case "Program": - for (const statement of nodeArray(node.body)) { - if (!isAstRecord(statement)) continue; + for (const statement of node.body) { recordDirectTopLevelBindings(statement); recordModuleScopedVarBindings(statement); } return; case "VariableDeclaration": if (node.kind !== "var" || node.declare === true) return; - for (const declarator of nodeArray(node.declarations)) { - if (!isAstRecord(declarator) || declarator.type !== "VariableDeclarator") continue; + for (const declarator of node.declarations) { recordBinding(declarator.id); } return; @@ -873,17 +871,19 @@ function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { } } - function moduleScopeChildren(node: AstRecord): unknown[] { + function moduleScopeChildren(node: ESTree.Node): Array { const t = node.type; + // This walk follows only statement containers that may contain module-scoped `var`. + // oxlint-disable-next-line typescript/switch-exhaustiveness-check switch (t) { case "BlockStatement": - return nodeArray(node.body); + return node.body; case "IfStatement": return [node.consequent, node.alternate]; case "SwitchStatement": - return nodeArray(node.cases); + return node.cases; case "SwitchCase": - return nodeArray(node.consequent); + return node.consequent; case "TryStatement": return [node.block, node.handler, node.finalizer]; case "CatchClause": @@ -912,9 +912,11 @@ function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { // The read walker is intentionally broader than the binding walk: it can // over-report names that are already bound locally, and the module binding // set decides whether injection is safe. - function recordReads(value: unknown): void { - if (!isAstRecord(value)) return; + function recordReads(value: ESTree.Node | null): void { + if (!value) return; const t = value.type; + // Special cases distinguish references from syntactic identifiers; the default walks children. + // oxlint-disable-next-line typescript/switch-exhaustiveness-check switch (t) { case "Identifier": if (isCjsGlobalName(value.name)) reads.add(value.name); @@ -946,12 +948,10 @@ function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { // `export { local as exported }` — only `local` references a binding, // and only when there is no `source` (a re-export points at the source // module, not a local). `exported` is always just a name. - if (isAstRecord(value.declaration)) { + if (value.declaration) { recordReads(value.declaration); } else if (!value.source) { - for (const specifier of nodeArray(value.specifiers)) { - if (isAstRecord(specifier)) recordReads(specifier.local); - } + for (const specifier of value.specifiers) recordReads(specifier.local); } return; default: @@ -959,28 +959,23 @@ function analyzeServerCjsGlobals(ast: unknown): ServerCjsAnalysis { } } - if (isAstRecord(ast) && ast.type === "Program") { - recordModuleScopedVarBindings(ast); - } + recordModuleScopedVarBindings(ast); recordReads(ast); return { reads, moduleBindings }; } -function isImportMetaNode(value: unknown): boolean { +function isImportMetaNode(value: ESTree.Node): boolean { return ( - isAstRecord(value) && value.type === "MetaProperty" && isIdentifierNamed(value.meta, "import") && isIdentifierNamed(value.property, "meta") ); } -function isImportMetaUrlNode(value: unknown): value is AstRange { +function isImportMetaUrlNode(value: ESTree.Node): value is ESTree.MemberExpression { return ( - isAstRecord(value) && value.type === "MemberExpression" && - hasRange(value) && isImportMetaNode(value.object) && isIdentifierNamed(value.property, "url") ); @@ -989,14 +984,14 @@ function isImportMetaUrlNode(value: unknown): value is AstRange { // Accepts both import.meta.url (MemberExpression) and import.meta?.url // (ChainExpression wrapping a MemberExpression) so that the new URL() skip // correctly handles optional-chained base arguments. -function isImportMetaUrlOrChainedNode(value: unknown): value is AstRange { +function isImportMetaUrlOrChainedNode( + value: ESTree.Node, +): value is ESTree.MemberExpression | ESTree.ChainExpression { if (isImportMetaUrlNode(value)) return true; - return ( - isAstRecord(value) && value.type === "ChainExpression" && isImportMetaUrlNode(value.expression) - ); + return value.type === "ChainExpression" && isImportMetaUrlNode(value.expression); } -function isImportMetaUrlBaseNode(value: unknown): boolean { +function isImportMetaUrlBaseNode(value: ESTree.Node): boolean { if (isImportMetaUrlOrChainedNode(value)) return true; // Vite rewrites worker constructors to: @@ -1005,10 +1000,8 @@ function isImportMetaUrlBaseNode(value: unknown): boolean { // Replacing it with our source-identity file URL would make the browser // resolve the emitted worker against file:// instead of the deployment origin. return ( - isAstRecord(value) && value.type === "BinaryExpression" && value.operator === "+" && - isAstRecord(value.left) && value.left.type === "Literal" && value.left.value === "" && isImportMetaUrlOrChainedNode(value.right) @@ -1018,43 +1011,29 @@ function isImportMetaUrlBaseNode(value: unknown): boolean { // Catches the ChainExpression wrapper so we record the outer node range // and avoid descending into the inner MemberExpression (which happens // to share the same start/end, but this is more explicit). -function isChainExpressionWrappingImportMetaUrl(value: unknown): value is AstRange { - return ( - isAstRecord(value) && - value.type === "ChainExpression" && - hasRange(value) && - isImportMetaUrlNode(value.expression) - ); +function isChainExpressionWrappingImportMetaUrl( + value: ESTree.Node, +): value is ESTree.ChainExpression { + return value.type === "ChainExpression" && isImportMetaUrlNode(value.expression); } // Only matches bare `new URL(...)`, not `new globalThis.URL(...)` or // `new window.URL(...)`. Matches Vite's own asset-detection scope. -function isNewUrlExpression(value: AstRecord): boolean { +function isNewUrlExpression(value: ESTree.Node): value is ESTree.NewExpression { return value.type === "NewExpression" && isIdentifierNamed(value.callee, "URL"); } -function findDirectivePrologueEnd(ast: unknown): number { - if (!isAstRecord(ast) || ast.type !== "Program") return 0; - +function findDirectivePrologueEnd(ast: ESTree.Program): number { // A shebang (`#!...`) lives outside ast.body but must stay the first bytes of // the file, so the injection floor starts after it. Inserting at offset 0 // would move the shebang off line 1 and produce invalid output. - let end = 0; - const hashbang = ast.hashbang; - const hashbangEnd = - typeof hashbang === "object" && hashbang !== null ? Reflect.get(hashbang, "end") : null; - if (typeof hashbangEnd === "number") { - end = hashbangEnd; - } + let end = ast.hashbang?.end ?? 0; - for (const statement of nodeArray(ast.body)) { + for (const statement of ast.body) { if ( - !isAstRecord(statement) || statement.type !== "ExpressionStatement" || - !isAstRecord(statement.expression) || statement.expression.type !== "Literal" || - typeof statement.expression.value !== "string" || - typeof statement.end !== "number" + typeof statement.expression.value !== "string" ) { break; } diff --git a/packages/vinext/src/plugins/middleware-export-validation.ts b/packages/vinext/src/plugins/middleware-export-validation.ts index 111171f947..f2cfe3cf27 100644 --- a/packages/vinext/src/plugins/middleware-export-validation.ts +++ b/packages/vinext/src/plugins/middleware-export-validation.ts @@ -2,25 +2,6 @@ import { parseAst } from "vite"; import { createMiddlewareMissingExportError } from "../server/middleware-runtime.js"; import { getAstName, scriptParserLanguage } from "./ast-utils.js"; -type AstName = { name?: unknown; value?: unknown } | null | undefined; - -type ExportSpecifier = { - exported?: AstName; - local?: AstName; -}; - -type Declaration = { - type?: string; - id?: AstName; - declarations?: Array<{ id?: AstName }>; -}; - -type Statement = { - type?: string; - declaration?: Declaration | null; - specifiers?: ExportSpecifier[]; -}; - export function hasValidMiddlewareModuleExport( source: string, id: string, @@ -33,7 +14,7 @@ export function hasValidMiddlewareModuleExport( const ast = parseAst(source, { lang: scriptParserLanguage(id) ?? "jsx" }); const expectedExport = isProxy ? "proxy" : "middleware"; - for (const statement of ast.body as Statement[]) { + for (const statement of ast.body) { if (statement.type === "ExportDefaultDeclaration") return true; if (statement.type !== "ExportNamedDeclaration") continue; @@ -45,12 +26,12 @@ export function hasValidMiddlewareModuleExport( return true; } if (declaration?.type === "VariableDeclaration") { - for (const declarator of declaration.declarations ?? []) { + for (const declarator of declaration.declarations) { if (getAstName(declarator.id) === expectedExport) return true; } } - for (const specifier of statement.specifiers ?? []) { - if (getAstName(specifier.exported ?? specifier.local) === expectedExport) return true; + for (const specifier of statement.specifiers) { + if (getAstName(specifier.exported) === expectedExport) return true; } } diff --git a/packages/vinext/src/plugins/optimize-imports.ts b/packages/vinext/src/plugins/optimize-imports.ts index f47296b0d2..8b22232243 100644 --- a/packages/vinext/src/plugins/optimize-imports.ts +++ b/packages/vinext/src/plugins/optimize-imports.ts @@ -11,14 +11,14 @@ * React.createContext() in RSC environments where createContext doesn't exist. */ -import type { Plugin } from "vite"; +import type { ESTree, Plugin } from "vite"; import { parseAst } from "vite"; import { createRequire } from "node:module"; import fs from "node:fs/promises"; import path, { toSlash } from "pathslash"; import MagicString from "magic-string"; import type { ResolvedNextConfig } from "../config/next-config.js"; -import { getAstName } from "./ast-utils.js"; +import { collectBindingNames, getAstName } from "./ast-utils.js"; import { magicStringTransformResult } from "./transform-result.js"; import { escapeRegExp } from "../utils/regex.js"; import { VIRTUAL_MODULE_ID_RE } from "../utils/virtual-module.js"; @@ -54,12 +54,6 @@ type BarrelExportEntry = { type BarrelExportMap = Map; -type DeclarationNode = { - type: string; - id?: { name: string } | null; - declarations?: Array<{ id: { name: string } }>; -}; - /** Caches used by the optimize-imports plugin, scoped to a plugin instance. */ type BarrelCaches = { /** Barrel export maps keyed by resolved entry file path. */ @@ -74,30 +68,6 @@ type BarrelCaches = { subpkgOrigin: Map>; }; -// Shared with Vite's internal AST node types (not publicly exported) -type AstBodyNode = { - type: string; - start: number; - end: number; - source?: { value: unknown }; - specifiers?: Array<{ - type: string; - local: { name: string }; - imported?: { name?: string; value?: string | boolean | number | null }; - exported?: { name?: string; value?: string | boolean | number | null }; - }>; - exported?: { name?: string; value?: string | boolean | number | null }; - /** - * Present on `ExportNamedDeclaration` when the export is an inline declaration: - * export function foo() {} → FunctionDeclaration { id: { name } } - * export class Foo {} → ClassDeclaration { id: { name } } - * export const x = 1, y = 2 → VariableDeclaration { declarations: [{ id: { name } }] } - */ - declaration?: DeclarationNode | null; - id?: { name: string } | null; - declarations?: Array<{ id: { name: string } }>; -}; - // Vite doesn't publicly type `this.environment` on plugin hooks yet. // This cast type is used consistently across resolveId and transform handlers // so that when Vite adds proper typing it can be removed in one place. @@ -389,42 +359,49 @@ async function buildExportMapFromFile( return source.startsWith(".") ? path.join(fileDir, source) : source; } - function recordLocalDeclaration(node: DeclarationNode | null | undefined): void { - if (!node) return; - if (node.id?.name) { - localDeclarations.add(node.id.name); - return; - } - for (const declaration of node.declarations ?? []) { - if (declaration.id?.name) { - localDeclarations.add(declaration.id.name); + function declarationNames(node: ESTree.Node | null): Set { + const names = new Set(); + if (!node) return names; + if (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") { + collectBindingNames(node.id, names); + } else if (node.type === "VariableDeclaration") { + for (const declaration of node.declarations) { + collectBindingNames(declaration.id, names); } } + return names; + } + + function recordLocalDeclaration(node: ESTree.Node | null): void { + for (const name of declarationNames(node)) { + localDeclarations.add(name); + } } // Pre-scan imports and local declarations so export lists can resolve both // imported bindings and same-file aliases like `const Foo = ...; export { Foo as Bar }`. - for (const node of ast.body as AstBodyNode[]) { + for (const node of ast.body) { + // Only declarations can contribute names to this pre-scan. + // oxlint-disable-next-line typescript/switch-exhaustiveness-check switch (node.type) { case "ImportDeclaration": { const rawSource = typeof node.source?.value === "string" ? node.source.value : null; if (!rawSource) break; const source = normalizeSource(rawSource); - for (const spec of node.specifiers ?? []) { + for (const spec of node.specifiers) { switch (spec.type) { case "ImportNamespaceSpecifier": importBindings.set(spec.local.name, { source, isNamespace: true }); break; case "ImportSpecifier": - if (spec.imported) { + { const name = getAstName(spec.imported); - if (name !== null) { - importBindings.set(spec.local.name, { - source, - isNamespace: false, - originalName: name, - }); - } + if (name === null) break; + importBindings.set(spec.local.name, { + source, + isNamespace: false, + originalName: name, + }); } break; case "ImportDefaultSpecifier": @@ -446,10 +423,14 @@ async function buildExportMapFromFile( case "ExportNamedDeclaration": recordLocalDeclaration(node.declaration); break; + default: + break; } } - for (const node of ast.body as AstBodyNode[]) { + for (const node of ast.body) { + // Only re-export declarations contribute entries to a barrel export map. + // oxlint-disable-next-line typescript/switch-exhaustiveness-check switch (node.type) { case "ExportAllDeclaration": { const rawSource = typeof node.source?.value === "string" ? node.source.value : null; @@ -521,23 +502,20 @@ async function buildExportMapFromFile( if (rawSource) { const source = normalizeSource(rawSource); // export { A, B } from "sub-pkg" - for (const spec of node.specifiers ?? []) { - if (spec.exported) { - const exported = getAstName(spec.exported); - const local = getAstName(spec.local); - if (exported !== null) { - exportMap.set(exported, { - source, - isNamespace: false, - originalName: local ?? undefined, - }); - } + for (const spec of node.specifiers) { + const exported = getAstName(spec.exported); + const local = getAstName(spec.local); + if (exported !== null) { + exportMap.set(exported, { + source, + isNamespace: false, + originalName: local ?? undefined, + }); } } - } else if (node.specifiers && node.specifiers.length > 0) { + } else if (node.specifiers.length > 0) { // export { X } — look up X in importBindings for (const spec of node.specifiers) { - if (!spec.exported) continue; const exported = getAstName(spec.exported); const local = getAstName(spec.local); if (exported === null || local === null) continue; @@ -562,28 +540,18 @@ async function buildExportMapFromFile( // Record the file itself as the source so the transform can rewrite // `import { foo } from "barrel"` → `import { foo } from "/abs/path/to/foo.js"`. const decl = node.declaration; - if (decl.id?.name) { - // FunctionDeclaration or ClassDeclaration — single named export - exportMap.set(decl.id.name, { + for (const name of declarationNames(decl)) { + exportMap.set(name, { source: filePath, isNamespace: false, - originalName: decl.id.name, + originalName: name, }); - } else if (decl.declarations) { - // VariableDeclaration — may declare multiple bindings: export const x = 1, y = 2 - for (const d of decl.declarations) { - if (d.id?.name) { - exportMap.set(d.id.name, { - source: filePath, - isNamespace: false, - originalName: d.id.name, - }); - } - } } } break; } + default: + break; } } @@ -752,7 +720,7 @@ export function createOptimizeImportsPlugin( let hasChanges = false; const root = getRoot(); - for (const node of ast.body as AstBodyNode[]) { + for (const node of ast.body) { if (node.type !== "ImportDeclaration") continue; const importSource = typeof node.source?.value === "string" ? node.source.value : null; @@ -815,13 +783,9 @@ export function createOptimizeImportsPlugin( // Check if ALL specifiers can be resolved. If any can't, leave the import unchanged. const specifiers: Array<{ local: string; imported: string }> = []; let allResolved = true; - for (const spec of node.specifiers ?? []) { + for (const spec of node.specifiers) { switch (spec.type) { case "ImportSpecifier": { - if (!spec.imported) { - allResolved = false; - break; - } const imported = getAstName(spec.imported); if (imported === null) { // Malformed AST node — degrade gracefully by skipping the import @@ -851,8 +815,8 @@ export function createOptimizeImportsPlugin( // If any specifier couldn't be resolved, leave the entire import unchanged. if (!allResolved || specifiers.length === 0) { if (allResolved === false) { - for (const spec of node.specifiers ?? []) { - if (spec.type === "ImportSpecifier" && spec.imported) { + for (const spec of node.specifiers) { + if (spec.type === "ImportSpecifier") { const imported = getAstName(spec.imported); if (imported !== null && !exportMap.has(imported)) { console.debug( diff --git a/packages/vinext/src/plugins/pages-node-externals.ts b/packages/vinext/src/plugins/pages-node-externals.ts index 9b7c78718b..cfaa838096 100644 --- a/packages/vinext/src/plugins/pages-node-externals.ts +++ b/packages/vinext/src/plugins/pages-node-externals.ts @@ -1,14 +1,12 @@ import fs from "node:fs"; import path from "pathslash"; -import { parseAst, type Plugin } from "vite"; +import { parseAst, type ESTree, type Plugin } from "vite"; import { - isAstRecord, mayContainDynamicImport, SCRIPT_MODULE_ID_RE, scriptParserLanguage, staticStringValue, walkAst, - type AstRecord, } from "./ast-utils.js"; import { canonicalizeFilePath, isPathInsideOrEqual, stripViteModuleQuery } from "../utils/path.js"; import { packageNameFromSpecifier } from "../utils/package-name.js"; @@ -79,33 +77,28 @@ function moduleDependencySpecifiers(code: string, id: string): string[] { const specifiers: string[] = []; const seen = new Set(); - const sourceSpecifier = (source: unknown): string | null => { - if (!isAstRecord(source)) return null; - return staticStringValue(source); - }; - const addStaticSource = (source: unknown): void => { - const specifier = sourceSpecifier(source); + const addStaticSource = (source: ESTree.Node | null): void => { + const specifier = staticStringValue(source); if (specifier === null || seen.has(specifier)) return; seen.add(specifier); specifiers.push(specifier); }; for (const statement of ast.body) { - if (!isAstRecord(statement)) continue; if ( statement.type === "ImportDeclaration" || statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration" ) { - if (statement.importKind !== "type" && statement.exportKind !== "type") { - addStaticSource(statement.source); - } + if (statement.type === "ImportDeclaration" && statement.importKind === "type") continue; + if (statement.type !== "ImportDeclaration" && statement.exportKind === "type") continue; + addStaticSource(statement.source); } } if (!mayContainDynamicImport(code)) return specifiers; - const visitDynamicImports = (node: AstRecord): void => { + const visitDynamicImports = (node: ESTree.Node): void => { if (node.type === "ImportExpression") { // Only statically known requests participate in the build graph. Never // guess at variable or interpolated dynamic imports. diff --git a/packages/vinext/src/plugins/require-condition-resolution.ts b/packages/vinext/src/plugins/require-condition-resolution.ts index 7f6a90bf08..2165ba0981 100644 --- a/packages/vinext/src/plugins/require-condition-resolution.ts +++ b/packages/vinext/src/plugins/require-condition-resolution.ts @@ -1,19 +1,15 @@ import MagicString from "magic-string"; import { readFile } from "node:fs/promises"; import path from "pathslash"; -import { createIdResolver, parseAst, type Plugin } from "vite"; +import { createIdResolver, parseAst, type ESTree, type Plugin } from "vite"; import { collectBindingNames, forEachAstChild, - hasRange, - isAstRecord, isIdentifierNamed, - nodeArray, SCRIPT_MODULE_ID_RE, scriptParserLanguage, staticStringValue, unwrapExpression, - type AstRecord, } from "./ast-utils.js"; import { collectDirectScopeBindings, @@ -31,7 +27,7 @@ import { stripViteModuleQuery } from "../utils/path.js"; const LITERAL_REQUIRE_RE = /\brequire\s*\(/; const CONDITIONAL_REQUIRE_SCRIPT_ID_RE = /\.vinext-require\.(?:js|jsx|ts|tsx)$/i; type LiteralRequire = { - argument: AstRecord & { start: number; end: number }; + argument: ESTree.Node; specifier: string; }; @@ -50,7 +46,7 @@ function syntheticModuleType(id: string): SyntheticModuleType { return "js"; } -function literalString(value: unknown): string | null { +function literalString(value: ESTree.Node | null | undefined): string | null { const node = unwrapExpression(value); return staticStringValue(node); } @@ -73,32 +69,29 @@ function collectLiteralRequires(code: string, id: string): LiteralRequire[] { return []; } - const root = isAstRecord(ast) ? ast : null; - if (!root) return []; + const root = ast; const requires: LiteralRequire[] = []; const rootScope = createAstScope(null); collectDirectScopeBindings(root, rootScope); collectVarScopeBindings(root, rootScope); - function visit(node: AstRecord, parentScope: AstScope): void { + function visit(node: ESTree.Node, parentScope: AstScope): void { let scope = parentScope; if (isFunctionNode(node)) { const parameterScope = createAstScope(parentScope); collectBindingNames(node.id, parameterScope.bindings); - for (const parameter of nodeArray(node.params)) { + for (const parameter of node.params) { collectBindingNames(parameter, parameterScope.bindings); - if (isAstRecord(parameter)) visit(parameter, parameterScope); + visit(parameter, parameterScope); } - const body = isAstRecord(node.body) ? node.body : null; + const body = node.body; if (body) { const bodyScope = createAstScope(parameterScope); collectDirectScopeBindings(body, bodyScope); collectVarScopeBindings(body, bodyScope); if (body.type === "BlockStatement") { - for (const statement of nodeArray(body.body)) { - if (isAstRecord(statement)) visit(statement, bodyScope); - } + for (const statement of body.body) visit(statement, bodyScope); } else { visit(body, bodyScope); } @@ -107,17 +100,15 @@ function collectLiteralRequires(code: string, id: string): LiteralRequire[] { } if (node.type === "SwitchStatement") { - if (isAstRecord(node.discriminant)) visit(node.discriminant, parentScope); + visit(node.discriminant, parentScope); const switchScope = createAstScope(parentScope); collectSwitchScopeBindings(node, switchScope); - for (const switchCase of nodeArray(node.cases)) { - if (isAstRecord(switchCase)) visit(switchCase, switchScope); - } + for (const switchCase of node.cases) visit(switchCase, switchScope); return; } if ( - (node.type === "BlockStatement" && node !== root) || + node.type === "BlockStatement" || node.type === "StaticBlock" || node.type === "TSModuleBlock" ) { @@ -143,7 +134,7 @@ function collectLiteralRequires(code: string, id: string): LiteralRequire[] { if (node.type === "CallExpression") { const callee = unwrapExpression(node.callee); - const args = nodeArray(node.arguments); + const args = node.arguments; const argument = unwrapExpression(args[0]); const specifier = literalString(argument); if ( @@ -151,7 +142,6 @@ function collectLiteralRequires(code: string, id: string): LiteralRequire[] { !hasAstBinding(scope, "require") && args.length === 1 && argument && - hasRange(argument) && specifier !== null && isPackageSpecifier(specifier) ) { @@ -163,9 +153,7 @@ function collectLiteralRequires(code: string, id: string): LiteralRequire[] { forEachAstChild(node, (child) => visit(child, scope)); } - for (const statement of nodeArray(root.body)) { - if (isAstRecord(statement)) visit(statement, rootScope); - } + for (const statement of root.body) visit(statement, rootScope); return requires; } diff --git a/packages/vinext/src/plugins/require-context.ts b/packages/vinext/src/plugins/require-context.ts index aad575fc92..00a6d3c195 100644 --- a/packages/vinext/src/plugins/require-context.ts +++ b/packages/vinext/src/plugins/require-context.ts @@ -21,25 +21,20 @@ import type { Dirent } from "node:fs"; import { readdir, realpath, stat } from "node:fs/promises"; import path, { toSlash } from "pathslash"; -import { parseAst, type Plugin } from "vite"; +import { parseAst, type ESTree, type Plugin } from "vite"; import MagicString from "magic-string"; import { booleanLiteralValue, - hasRange, - isAstRecord, - nodeArray, SCRIPT_MODULE_ID_RE, scriptParserLanguage, stringLiteralValue, walkAst, - type AstRange, - type AstRecord, } from "./ast-utils.js"; import { stripViteModuleQuery } from "../utils/path.js"; import { magicStringTransformResult } from "./transform-result.js"; type ParsedCall = { - range: AstRange; + range: ESTree.CallExpression; dir: string; recursive: boolean; pattern: string; @@ -137,7 +132,7 @@ type TransformResult = { async function transformRequireContext(code: string, id: string): Promise { const lang = scriptParserLanguage(id)!; - let ast: unknown; + let ast: ReturnType; try { ast = parseAst(code, { lang }); } catch { @@ -174,7 +169,7 @@ async function transformRequireContext(code: string, id: string): Promise { @@ -189,20 +184,10 @@ function collectRequireContextCalls(ast: unknown): ParsedCall[] { return calls; } -function findImportInsertionOffset(ast: unknown): number { - if (!isAstRecord(ast) || ast.type !== "Program") return 0; - - let offset = 0; - if (isAstRecord(ast.hashbang) && hasRange(ast.hashbang)) { - offset = ast.hashbang.end; - } - for (const statement of nodeArray(ast.body)) { - if ( - !isAstRecord(statement) || - statement.type !== "ExpressionStatement" || - typeof statement.directive !== "string" || - !hasRange(statement) - ) { +function findImportInsertionOffset(ast: ESTree.Program): number { + let offset = ast.hashbang?.end ?? 0; + for (const statement of ast.body) { + if (statement.type !== "ExpressionStatement" || typeof statement.directive !== "string") { break; } offset = statement.end; @@ -214,22 +199,17 @@ function findImportInsertionOffset(ast: unknown): number { // is the `require` identifier, optionally wrapped in a `(require as any)` // TypeScript assertion or parentheses. Returns null for anything that does not // match exactly, so unrelated `.context(...)` calls are never rewritten. -function parseRequireContextCall(node: AstRecord): ParsedCall | null { - if (node.type !== "CallExpression" || !hasRange(node)) return null; +function parseRequireContextCall(node: ESTree.Node): ParsedCall | null { + if (node.type !== "CallExpression") return null; const callee = node.callee; - if ( - !isAstRecord(callee) || - callee.type !== "MemberExpression" || - callee.computed === true || - callee.optional === true - ) { + if (callee.type !== "MemberExpression" || callee.computed === true || callee.optional === true) { return null; } if (!isPropertyNamed(callee.property, "context")) return null; if (!isRequireExpression(callee.object)) return null; - const args = nodeArray(node.arguments); + const args = node.arguments; // First arg: the directory string. Required and must be a static, relative // path so each matched file can become a relative static import. A // bare/aliased specifier is left untouched. @@ -274,10 +254,10 @@ function parseRequireContextCall(node: AstRecord): ParsedCall | null { } // `require`, `(require)`, `(require as any)`, `(require as unknown as Foo)`, … -function isRequireExpression(value: unknown): boolean { - let node = value; +function isRequireExpression(value: ESTree.Node): boolean { + let node: ESTree.Node = value; // Unwrap TS assertion / non-null / parenthesized wrappers around `require`. - while (isAstRecord(node)) { + while (true) { if (node.type === "Identifier") { return node.name === "require"; } @@ -295,30 +275,17 @@ function isRequireExpression(value: unknown): boolean { } return false; } - return false; } -function isPropertyNamed(value: unknown, name: string): boolean { - return isAstRecord(value) && value.type === "Identifier" && value.name === name; +function isPropertyNamed(value: ESTree.Node, name: string): boolean { + return value.type === "Identifier" && value.name === name; } -function regexLiteralValue(value: unknown): { pattern: string; flags: string } | null { - if (!isAstRecord(value) || value.type !== "Literal") return null; +function regexLiteralValue(value: ESTree.Node): { pattern: string; flags: string } | null { + if (value.type !== "Literal" || !("regex" in value)) return null; // OXC attaches the regex source as a plain `{ pattern, flags }` object on the - // Literal node — it has no `type` field, so it is NOT an AstRecord. - const regex = value.regex; - if ( - typeof regex === "object" && - regex !== null && - typeof (regex as { pattern?: unknown }).pattern === "string" && - typeof (regex as { flags?: unknown }).flags === "string" - ) { - return { - pattern: (regex as { pattern: string }).pattern, - flags: (regex as { flags: string }).flags, - }; - } - return null; + // RegExp value — unlike the containing Literal, this object is not an AST node. + return value.regex; } // Builds an IIFE that produces a Webpack-compatible require.context function. diff --git a/packages/vinext/src/plugins/styled-jsx.ts b/packages/vinext/src/plugins/styled-jsx.ts index 1a3e13afc4..0b52569d71 100644 --- a/packages/vinext/src/plugins/styled-jsx.ts +++ b/packages/vinext/src/plugins/styled-jsx.ts @@ -38,14 +38,12 @@ function hasStyledJsxTag(source: string, id: string): boolean { walkAst(ast, (node) => { if (found) return false; if (node.type === "JSXOpeningElement") { - const name = node.name as { type?: string; name?: string } | undefined; - if (name?.type === "JSXIdentifier" && name.name === "style") { - const attributes = node.attributes as Array> | undefined; + const name = node.name; + if (name.type === "JSXIdentifier" && name.name === "style") { if ( - attributes?.some((attribute) => { + node.attributes.some((attribute) => { if (attribute.type !== "JSXAttribute") return false; - const attributeName = attribute.name as { type?: string; name?: string } | undefined; - return attributeName?.type === "JSXIdentifier" && attributeName.name === "jsx"; + return attribute.name.type === "JSXIdentifier" && attribute.name.name === "jsx"; }) ) { found = true; diff --git a/packages/vinext/src/plugins/typeof-window.ts b/packages/vinext/src/plugins/typeof-window.ts index 89b6142eb0..4b636a5894 100644 --- a/packages/vinext/src/plugins/typeof-window.ts +++ b/packages/vinext/src/plugins/typeof-window.ts @@ -1,14 +1,11 @@ import path from "pathslash"; -import { parseAst } from "vite"; +import { parseAst, type ESTree } from "vite"; import MagicString from "magic-string"; import { booleanLiteralValue, collectBindingNames, forEachAstChild, - hasRange, - isAstRecord, isIdentifierNamed, - nodeArray, stringLiteralValue, } from "./ast-utils.js"; import { @@ -43,15 +40,13 @@ export type ConsumerEnvironmentReplacements = { pruneUnreachableImports?: boolean; }; -type AstNode = Parameters[0]; - type EnvironmentLike = { config: { consumer: "client" | "server"; }; }; -function createChildScope(node: AstNode, parent: AstScope): AstScope | null { +function createChildScope(node: ESTree.Node, parent: AstScope): AstScope | null { if ( node.type !== "Program" && node.type !== "BlockStatement" && @@ -92,15 +87,14 @@ export function getTypeofWindowReplacement(environment: EnvironmentLike): Window } function evaluateTypeofWindowComparison( - node: unknown, + node: ESTree.Node, replacement: WindowType, scope: AstScope, ): boolean | null { - if (!isAstRecord(node) || node.type !== "BinaryExpression") return null; + if (node.type !== "BinaryExpression") return null; if (!["==", "===", "!=", "!=="].includes(String(node.operator))) return null; - const left = isAstRecord(node.left) ? node.left : null; - const right = isAstRecord(node.right) ? node.right : null; + const { left, right } = node; const leftIsTypeofWindow = left?.type === "UnaryExpression" && left.operator === "typeof" && @@ -123,12 +117,9 @@ function evaluateTypeofWindowComparison( return node.operator === "==" || node.operator === "===" ? equal : !equal; } -function isProcessBrowserMember(node: unknown, scope: AstScope): boolean { - const candidate = - isAstRecord(node) && node.type === "ChainExpression" && isAstRecord(node.expression) - ? node.expression - : node; - if (!isAstRecord(candidate) || candidate.type !== "MemberExpression") { +function isProcessBrowserMember(node: ESTree.Node, scope: AstScope): boolean { + const candidate = node.type === "ChainExpression" ? node.expression : node; + if (candidate.type !== "MemberExpression") { return false; } return ( @@ -141,16 +132,16 @@ function isProcessBrowserMember(node: unknown, scope: AstScope): boolean { } function evaluateProcessBrowserCondition( - node: unknown, + node: ESTree.Node, replacement: boolean, scope: AstScope, ): boolean | null { if (isProcessBrowserMember(node, scope)) return replacement; - if (isAstRecord(node) && node.type === "UnaryExpression" && node.operator === "!") { + if (node.type === "UnaryExpression" && node.operator === "!") { const value = evaluateProcessBrowserCondition(node.argument, replacement, scope); return value === null ? null : !value; } - if (!isAstRecord(node) || node.type !== "BinaryExpression") return null; + if (node.type !== "BinaryExpression") return null; if (!["==", "===", "!=", "!=="].includes(String(node.operator))) return null; const leftIsProcessBrowser = isProcessBrowserMember(node.left, scope); @@ -168,15 +159,15 @@ function evaluateProcessBrowserCondition( type EvaluatedCondition = { value: boolean; - effects: AstNode[]; + effects: ESTree.Node[]; }; function evaluateConsumerCondition( - node: unknown, + node: ESTree.Node, replacements: ConsumerEnvironmentReplacements, scope: AstScope, ): EvaluatedCondition | null { - if (isAstRecord(node) && node.type === "LogicalExpression") { + if (node.type === "LogicalExpression") { const left = evaluateConsumerCondition(node.left, replacements, scope); if (node.operator === "&&") { if (left?.value === false) return left; @@ -188,8 +179,7 @@ function evaluateConsumerCondition( if ( replacements.pruneUnreachableImports && right?.value === false && - right.effects.length === 0 && - isAstRecord(node.left) + right.effects.length === 0 ) { return { value: false, effects: [node.left] }; } @@ -203,8 +193,7 @@ function evaluateConsumerCondition( if ( replacements.pruneUnreachableImports && right?.value === true && - right.effects.length === 0 && - isAstRecord(node.left) + right.effects.length === 0 ) { return { value: true, effects: [node.left] }; } @@ -256,7 +245,6 @@ export function replaceConsumerEnvironmentConditions( const output = new MagicString(code); let changed = false; - if (!isAstRecord(ast)) return null; function overwriteGap(start: number, end: number, content: string): void { if (start === end) { @@ -270,60 +258,57 @@ export function replaceConsumerEnvironmentConditions( collectDirectScopeBindings(ast, rootScope); collectVarScopeBindings(ast, rootScope); - function visit(node: AstNode, parentScope: AstScope): void { + function visit(node: ESTree.Node, parentScope: AstScope): void { if (isFunctionNode(node)) { const parameterScope = createAstScope(parentScope); collectBindingNames(node.id, parameterScope.bindings); - for (const parameter of nodeArray(node.params)) { + for (const parameter of node.params) { collectBindingNames(parameter, parameterScope.bindings); - if (isAstRecord(parameter)) visit(parameter, parameterScope); + visit(parameter, parameterScope); } - if (isAstRecord(node.body)) { - if (node.body.type === "BlockStatement") { - const bodyScope = createAstScope(parameterScope); - collectDirectScopeBindings(node.body, bodyScope); - collectVarScopeBindings(node.body, bodyScope); - visit(node.body, bodyScope); - } else { - visit(node.body, parameterScope); - } + if (!node.body) return; + if (node.body.type === "BlockStatement") { + const bodyScope = createAstScope(parameterScope); + collectDirectScopeBindings(node.body, bodyScope); + collectVarScopeBindings(node.body, bodyScope); + visit(node.body, bodyScope); + } else { + visit(node.body, parameterScope); } return; } if (node.type === "SwitchStatement") { - if (isAstRecord(node.discriminant)) visit(node.discriminant, parentScope); + visit(node.discriminant, parentScope); const switchScope = createAstScope(parentScope); collectSwitchScopeBindings(node, switchScope); - for (const switchCase of nodeArray(node.cases)) { - if (isAstRecord(switchCase)) visit(switchCase, switchScope); - } + for (const switchCase of node.cases) visit(switchCase, switchScope); return; } const scope = createChildScope(node, parentScope) ?? parentScope; - if (node.type === "IfStatement" && hasRange(node)) { + if (node.type === "IfStatement") { const result = evaluateConsumerCondition(node.test, replacements, scope); if (result !== null) { const selected = result.value ? node.consequent : node.alternate; if (result.effects.length > 0) { - const effects = result.effects.filter(hasRange); + const effects = result.effects; for (const effect of effects) visit(effect, scope); - if (isAstRecord(selected) && hasRange(selected)) visit(selected, scope); + if (selected) visit(selected, scope); overwriteGap(node.start, effects[0].start, "{ ("); for (let index = 1; index < effects.length; index++) { overwriteGap(effects[index - 1].end, effects[index].start, "); ("); } const lastEffect = effects.at(-1)!; - if (isAstRecord(selected) && hasRange(selected)) { + if (selected) { overwriteGap(lastEffect.end, selected.start, "); "); overwriteGap(selected.end, node.end, " }"); } else { overwriteGap(lastEffect.end, node.end, "); }"); } - } else if (isAstRecord(selected) && hasRange(selected)) { + } else if (selected) { output.remove(node.start, selected.start); output.remove(selected.end, node.end); visit(selected, scope); @@ -335,12 +320,12 @@ export function replaceConsumerEnvironmentConditions( } } - if (node.type === "ConditionalExpression" && hasRange(node)) { + if (node.type === "ConditionalExpression") { const result = evaluateConsumerCondition(node.test, replacements, scope); const selected = result?.value ? node.consequent : node.alternate; - if (result !== null && isAstRecord(selected) && hasRange(selected)) { + if (result !== null) { if (result.effects.length > 0) { - const effects = result.effects.filter(hasRange); + const effects = result.effects; for (const effect of effects) visit(effect, scope); visit(selected, scope); overwriteGap(node.start, effects[0].start, "(("); @@ -363,10 +348,10 @@ export function replaceConsumerEnvironmentConditions( } } - if (node.type === "LogicalExpression" && hasRange(node)) { + if (node.type === "LogicalExpression") { const result = evaluateConsumerCondition(node, replacements, scope); if (result !== null) { - const effects = result.effects.filter(hasRange); + const effects = result.effects; if (effects.length > 0) { for (const effect of effects) visit(effect, scope); overwriteGap(node.start, effects[0].start, "(("); @@ -387,19 +372,14 @@ export function replaceConsumerEnvironmentConditions( node.operator === "typeof" && isIdentifierNamed(node.argument, "window") && replacements.typeofWindow !== undefined && - !hasAstBinding(scope, "window") && - hasRange(node) + !hasAstBinding(scope, "window") ) { output.overwrite(node.start, node.end, JSON.stringify(replacements.typeofWindow)); changed = true; return; } - if ( - replacements.processBrowser !== undefined && - isProcessBrowserMember(node, scope) && - hasRange(node) - ) { + if (replacements.processBrowser !== undefined && isProcessBrowserMember(node, scope)) { output.overwrite(node.start, node.end, String(replacements.processBrowser)); changed = true; return; @@ -408,9 +388,7 @@ export function replaceConsumerEnvironmentConditions( forEachAstChild(node, (child) => visit(child, scope)); } - for (const node of ast.body) { - if (isAstRecord(node)) visit(node, rootScope); - } + for (const node of ast.body) visit(node, rootScope); if (!changed) return null; return magicStringTransformResult(output); diff --git a/packages/vinext/src/plugins/worker-image-imports.ts b/packages/vinext/src/plugins/worker-image-imports.ts index 6e71eefdfe..854e3ce01b 100644 --- a/packages/vinext/src/plugins/worker-image-imports.ts +++ b/packages/vinext/src/plugins/worker-image-imports.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import MagicString from "magic-string"; import path, { toSlash } from "pathslash"; -import { parseAst, type Plugin } from "vite"; +import { parseAst, type ESTree, type Plugin } from "vite"; import { appendDeploymentIdQuery } from "../utils/deployment-id.js"; import { NODE_MODULES_PATH_RE, stripViteModuleQuery } from "../utils/path.js"; import { staticStringValue, walkAst } from "./ast-utils.js"; @@ -15,16 +15,6 @@ const WORKER_IMAGE_DYNAMIC_IMPORT_RE = /import\(\s*["'][^"']+\.(?:png|jpe?g|gif|webp|avif|svg|ico|bmp|tiff?)["']\s*\)/; const WORKER_IMAGE_EXTENSION_RE = /\.(?:png|jpe?g|gif|webp|avif|svg|ico|bmp|tiff?)$/; -type AstNode = { - type?: string; - start?: number; - end?: number; - expressions?: unknown[]; - quasis?: Array<{ value?: { cooked?: unknown } }>; - source?: AstNode; - value?: unknown; -}; - function workerChunkSpecifier(hostFileName: string, targetFileName: string): string { const relative = path.relative(path.dirname(hostFileName), targetFileName); return relative.startsWith(".") ? relative : `./${relative}`; @@ -96,13 +86,10 @@ export function createWorkerImageImportsPlugin(options: { deploymentId?: string start: number; }> = []; - walkAst(ast, (value) => { - const node = value as AstNode; + walkAst(ast, (node) => { if ( node.type === "ImportExpression" && - typeof node.start === "number" && - typeof node.end === "number" && - node.source?.type === "Literal" && + node.source.type === "Literal" && typeof node.source.value === "string" && WORKER_IMAGE_EXTENSION_RE.test(node.source.value) ) { @@ -159,9 +146,8 @@ export function createWorkerImageImportsPlugin(options: { deploymentId?: string const output = new MagicString(code); let changed = false; - walkAst(ast, (value) => { - const node = value as AstNode; - const source = + walkAst(ast, (node) => { + const source: ESTree.Node | null = node.type === "ImportExpression" || node.type === "ImportDeclaration" || node.type === "ExportNamedDeclaration" || @@ -169,12 +155,7 @@ export function createWorkerImageImportsPlugin(options: { deploymentId?: string ? node.source : null; const specifier = staticStringValue(source); - if ( - specifier !== null && - chunkSpecifiers.has(specifier) && - typeof source?.start === "number" && - typeof source.end === "number" - ) { + if (specifier !== null && chunkSpecifiers.has(specifier) && source) { output.overwrite( source.start, source.end, diff --git a/tests/plugin-utils.test.ts b/tests/plugin-utils.test.ts index 12b2f572e8..239c08e6cc 100644 --- a/tests/plugin-utils.test.ts +++ b/tests/plugin-utils.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { toSlash } from "pathslash"; +import { parseAst, type ESTree } from "vite"; import { describe, expect, it } from "vite-plus/test"; import { booleanLiteralValue, @@ -27,6 +28,12 @@ import { } from "../packages/vinext/src/utils/path.js"; describe("plugin AST utilities", () => { + function parseExpression(source: string, lang: "js" | "ts" = "js"): ESTree.Expression { + const statement = parseAst(source, { lang }).body[0]; + if (statement?.type !== "ExpressionStatement") throw new Error("Expected an expression"); + return statement.expression; + } + it.each([ ["/app/page.js", "jsx"], ["/app/page.jsx?direct", "jsx"], @@ -42,28 +49,24 @@ describe("plugin AST utilities", () => { }); it("reads literal values and syntax-only expression wrappers", () => { - const literal = { type: "Literal", value: "value" }; - const template = { - type: "TemplateLiteral", - expressions: [], - quasis: [{ type: "TemplateElement", value: { cooked: "cooked", raw: "raw" } }], - }; - const wrapped = { type: "TSAsExpression", expression: literal }; + const literal = parseExpression('"value"'); + const template = parseExpression("`cooked`"); + const wrapped = parseExpression('"value" as string', "ts"); - expect(getAstName({ type: "Identifier", name: "binding" })).toBe("binding"); + expect(getAstName(parseExpression("binding"))).toBe("binding"); expect(stringLiteralValue(literal)).toBe("value"); expect(staticStringValue(template)).toBe("cooked"); - expect(booleanLiteralValue({ type: "Literal", value: false })).toBe(false); - expect(unwrapExpression(wrapped)).toBe(literal); - expect(staticStringValue({ ...template, expressions: [literal] })).toBeNull(); + expect(booleanLiteralValue(parseExpression("false"))).toBe(false); + expect(stringLiteralValue(unwrapExpression(wrapped))).toBe("value"); + expect(staticStringValue(parseExpression('`${"value"}`'))).toBeNull(); }); it("walks children without following parent cycles and supports pruning", () => { - const prunedChild = { type: "Literal", value: "hidden" }; - const pruned = { type: "CallExpression", arguments: [prunedChild] }; - const visible = { type: "Identifier", name: "visible" }; - const root = { type: "Program", body: [pruned, visible] } as Record; - root.parent = root; + const root = parseAst('pruned("hidden"); visible;'); + const firstStatement = root.body[0]; + if (firstStatement?.type !== "ExpressionStatement") throw new Error("Expected a call"); + const pruned = firstStatement.expression; + pruned.parent = root; const visited: string[] = []; walkAst(root, (node) => { @@ -71,7 +74,13 @@ describe("plugin AST utilities", () => { return node === pruned ? false : undefined; }); - expect(visited).toEqual(["Program", "CallExpression", "Identifier"]); + expect(visited).toEqual([ + "Program", + "ExpressionStatement", + "CallExpression", + "ExpressionStatement", + "Identifier", + ]); }); });