diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 799af2a96..3387ac7c8 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -175,6 +175,7 @@ import { import { stripServerExports } from "./plugins/strip-server-exports.js"; import { removeConsoleCalls } from "./plugins/remove-console.js"; import { createImportMetaUrlPlugin } from "./plugins/import-meta-url.js"; +import { createIgnoreDynamicRequestsPlugin } from "./plugins/ignore-dynamic-requests.js"; import { createRequireContextPlugin } from "./plugins/require-context.js"; import { createExtensionlessDynamicImportPlugin } from "./plugins/extensionless-dynamic-import.js"; import { createWasmModuleImportPlugin } from "./plugins/wasm-module-import.js"; @@ -1024,6 +1025,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ...(viteMajorVersion >= 8 ? [] : [tsconfigPaths()]), // React Fast Refresh + JSX transform for client components. reactPluginPromise, + // Apply the Next/Turbopack graph-construction boundary before import/CJS + // analyzers run: requests with no static path part must not become Vite + // module edges. If reached at runtime, they fail deterministically. + createIgnoreDynamicRequestsPlugin(), // Transform CJS require()/module.exports to ESM before other plugins // analyze imports (RSC directive scanning, shim resolution, etc.) commonjs(), diff --git a/packages/vinext/src/plugins/ast-utils.ts b/packages/vinext/src/plugins/ast-utils.ts index 901dbf7d9..b9ce8ee6a 100644 --- a/packages/vinext/src/plugins/ast-utils.ts +++ b/packages/vinext/src/plugins/ast-utils.ts @@ -12,8 +12,8 @@ export type AstRange = AstRecord & { 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; +export function getObjectProperty(value: unknown, key: string): unknown { + if ((typeof value !== "object" || value === null) && typeof value !== "function") return null; return Reflect.get(value, key); } @@ -21,7 +21,7 @@ export function isAstRecord(value: unknown): value is AstRecord { return typeof getObjectProperty(value, "type") === "string"; } -function toAstRecord(value: unknown): AstRecord | null { +export function toAstRecord(value: unknown): AstRecord | null { return isAstRecord(value) ? value : null; } @@ -29,6 +29,13 @@ export function nodeArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } +export function astArray(value: unknown): AstRecord[] { + return nodeArray(value).flatMap((entry) => { + const node = toAstRecord(entry); + return node ? [node] : []; + }); +} + export function hasRange(node: AstRecord | null): node is AstRange { return node !== null && typeof node.start === "number" && typeof node.end === "number"; } @@ -62,6 +69,26 @@ export function forEachAstChild(node: AstRecord, callback: (child: AstRecord) => } } +/** + * Expression wrappers that are transparent at runtime. Unwrapping them returns + * the underlying expression so callers can analyze the real value. + */ +const TRANSPARENT_EXPRESSION_TYPES = new Set([ + "TSAsExpression", + "TSTypeAssertion", + "TSNonNullExpression", + "TSInstantiationExpression", + "TSSatisfiesExpression", + "ParenthesizedExpression", + "ChainExpression", +]); + +export function unwrapTransparentExpression(node: AstRecord | null): AstRecord | null { + if (!node) return null; + if (!TRANSPARENT_EXPRESSION_TYPES.has(node.type)) return node; + return unwrapTransparentExpression(toAstRecord(getObjectProperty(node, "expression"))); +} + export function collectBindingNames(pattern: unknown, target: Set): void { const node = toAstRecord(pattern); if (!node) return; @@ -76,6 +103,9 @@ export function collectBindingNames(pattern: unknown, target: Set): void case "AssignmentPattern": collectBindingNames(node.left, target); return; + case "TSParameterProperty": + collectBindingNames(node.parameter, target); + return; case "ArrayPattern": for (const element of nodeArray(node.elements)) collectBindingNames(element, target); return; diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts new file mode 100644 index 000000000..0d726b43e --- /dev/null +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -0,0 +1,821 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import MagicString from "magic-string"; +import type { Plugin } from "vite"; +import { parseAst } from "vite"; +import { + type AstRecord, + astArray, + collectBindingNames, + forEachAstChild, + getAstName, + getObjectProperty, + hasRange, + isIdentifierNamed, + toAstRecord, + unwrapTransparentExpression, +} from "./ast-utils.js"; +import { cleanModuleId, isDependencyId, parserLangForId } from "./transform-utils.js"; + +const DYNAMIC_REQUEST_ERROR_MESSAGE = "Cannot find module as expression is too dynamic"; +const VINEXT_SOURCE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +type TransformResult = { + code: string; + map: ReturnType; +}; + +type RequestClassification = + | { kind: "static" } + | { kind: "partly-static" } + | { kind: "very-dynamic" }; + +function hasGraphEligibleStaticPart(classification: RequestClassification): boolean { + return classification.kind !== "very-dynamic"; +} + +function singleArgument(node: AstRecord): AstRecord | null { + const args = astArray(node.arguments); + if (args.length !== 1) return null; + const arg = args[0] ?? null; + if (arg?.type === "SpreadElement") return null; + return arg; +} + +function concatenatedStringHasSignificantStaticPart(value: string): boolean { + const normalized = value.replaceAll("\\", "/"); + return normalized !== "" && normalized !== "/"; +} + +function constantStringHasSignificantStaticPart(value: string): boolean { + return value.replaceAll("\\", "/") !== "/"; +} + +function templateElementHasSignificantStaticPart(node: AstRecord): boolean { + const value = getObjectProperty(node, "value"); + const cooked = getObjectProperty(value, "cooked"); + if (typeof cooked === "string") return concatenatedStringHasSignificantStaticPart(cooked); + + const raw = getObjectProperty(value, "raw"); + return typeof raw === "string" && concatenatedStringHasSignificantStaticPart(raw); +} + +function stringConcatenationHasStaticPart(node: AstRecord | null): boolean { + const unwrapped = unwrapTransparentExpression(node); + if (!unwrapped) return false; + + if (unwrapped.type === "Literal" || unwrapped.type === "StringLiteral") { + return ( + typeof unwrapped.value === "string" && + concatenatedStringHasSignificantStaticPart(unwrapped.value) + ); + } + + if (unwrapped.type === "TemplateLiteral") { + return astArray(unwrapped.quasis).some(templateElementHasSignificantStaticPart); + } + + if (unwrapped.type === "BinaryExpression" && unwrapped.operator === "+") { + return ( + stringConcatenationHasStaticPart(toAstRecord(unwrapped.left)) || + stringConcatenationHasStaticPart(toAstRecord(unwrapped.right)) + ); + } + + return false; +} + +function staticTruthiness( + node: AstRecord | null, + isBound: (name: string) => boolean, +): boolean | null { + const unwrapped = unwrapTransparentExpression(node); + if (!unwrapped) return null; + + if (unwrapped.type === "Literal" || unwrapped.type === "StringLiteral") { + return Boolean(unwrapped.value); + } + + if ( + unwrapped.type === "ArrayExpression" || + unwrapped.type === "ObjectExpression" || + unwrapped.type === "FunctionExpression" || + unwrapped.type === "ArrowFunctionExpression" || + unwrapped.type === "ClassExpression" + ) { + return true; + } + + if (isIdentifierNamed(unwrapped, "undefined") && !isBound("undefined")) { + return false; + } + + return null; +} + +function staticNullishness( + node: AstRecord | null, + isBound: (name: string) => boolean, +): boolean | null { + const unwrapped = unwrapTransparentExpression(node); + if (!unwrapped) return null; + + if (unwrapped.type === "Literal" || unwrapped.type === "StringLiteral") { + return unwrapped.value === null; + } + + if (isIdentifierNamed(unwrapped, "undefined") && !isBound("undefined")) { + return true; + } + + if ( + unwrapped.type === "ArrayExpression" || + unwrapped.type === "ObjectExpression" || + unwrapped.type === "FunctionExpression" || + unwrapped.type === "ArrowFunctionExpression" || + unwrapped.type === "ClassExpression" || + unwrapped.type === "TemplateLiteral" + ) { + return false; + } + + return null; +} + +function isSyntacticallySideEffectFree(node: AstRecord | null): boolean { + const unwrapped = unwrapTransparentExpression(node); + if (!unwrapped) return false; + + if ( + unwrapped.type === "Identifier" || + unwrapped.type === "Literal" || + unwrapped.type === "StringLiteral" || + unwrapped.type === "ThisExpression" || + unwrapped.type === "FunctionExpression" || + unwrapped.type === "ArrowFunctionExpression" + ) { + return true; + } + + if (unwrapped.type === "TemplateLiteral") { + return astArray(unwrapped.expressions).every(isSyntacticallySideEffectFree); + } + + if (unwrapped.type === "ArrayExpression") { + return astArray(unwrapped.elements).every((element) => { + if (element.type === "SpreadElement") { + return isSyntacticallySideEffectFree(toAstRecord(element.argument)); + } + return isSyntacticallySideEffectFree(element); + }); + } + + if (unwrapped.type === "ObjectExpression") { + return astArray(unwrapped.properties).every((property) => { + if (property.type === "SpreadElement") { + return isSyntacticallySideEffectFree(toAstRecord(property.argument)); + } + if (property.computed && !isSyntacticallySideEffectFree(toAstRecord(property.key))) { + return false; + } + if (property.type === "Property" || property.type === "ObjectProperty") { + return isSyntacticallySideEffectFree(toAstRecord(property.value)); + } + return true; + }); + } + + if (unwrapped.type === "ClassExpression") { + return ( + !unwrapped.superClass && + astArray(unwrapped.decorators).length === 0 && + astArray(toAstRecord(unwrapped.body)?.body).every((element) => { + if (astArray(element.decorators).length > 0) return false; + if (element.computed && !isSyntacticallySideEffectFree(toAstRecord(element.key))) { + return false; + } + if (element.type === "StaticBlock") return false; + if (element.static && element.value) { + return isSyntacticallySideEffectFree(toAstRecord(element.value)); + } + return true; + }) + ); + } + + return false; +} + +function classifyRequestExpression( + node: AstRecord | null, + isBound: (name: string) => boolean, +): RequestClassification { + const unwrapped = unwrapTransparentExpression(node); + if (!unwrapped) return { kind: "very-dynamic" }; + + if (unwrapped.type === "Literal" || unwrapped.type === "StringLiteral") { + return typeof unwrapped.value !== "string" || + constantStringHasSignificantStaticPart(unwrapped.value) + ? { kind: "static" } + : { kind: "very-dynamic" }; + } + + if (isIdentifierNamed(unwrapped, "undefined")) { + return isBound("undefined") ? { kind: "very-dynamic" } : { kind: "static" }; + } + + if (unwrapped.type === "TemplateLiteral") { + return astArray(unwrapped.quasis).some(templateElementHasSignificantStaticPart) + ? { kind: "partly-static" } + : { kind: "very-dynamic" }; + } + + if (unwrapped.type === "BinaryExpression") { + return unwrapped.operator === "+" && stringConcatenationHasStaticPart(unwrapped) + ? { kind: "partly-static" } + : { kind: "very-dynamic" }; + } + + if (unwrapped.type === "ConditionalExpression") { + const truthiness = staticTruthiness(toAstRecord(unwrapped.test), isBound); + if (truthiness !== null) { + return classifyRequestExpression( + toAstRecord(truthiness ? unwrapped.consequent : unwrapped.alternate), + isBound, + ); + } + return { kind: "very-dynamic" }; + } + + if (unwrapped.type === "LogicalExpression") { + const left = toAstRecord(unwrapped.left); + const truthiness = staticTruthiness(left, isBound); + if (unwrapped.operator === "&&" && truthiness !== null) { + return classifyRequestExpression(truthiness ? toAstRecord(unwrapped.right) : left, isBound); + } + if (unwrapped.operator === "||" && truthiness !== null) { + return classifyRequestExpression(truthiness ? left : toAstRecord(unwrapped.right), isBound); + } + if (unwrapped.operator === "??") { + const nullishness = staticNullishness(left, isBound); + if (nullishness !== null) { + return classifyRequestExpression( + nullishness ? toAstRecord(unwrapped.right) : left, + isBound, + ); + } + } + + return hasGraphEligibleStaticPart( + classifyRequestExpression(toAstRecord(unwrapped.left), isBound), + ) || + hasGraphEligibleStaticPart(classifyRequestExpression(toAstRecord(unwrapped.right), isBound)) + ? { kind: "partly-static" } + : { kind: "very-dynamic" }; + } + + if (unwrapped.type === "SequenceExpression") { + const expressions = astArray(unwrapped.expressions); + if (expressions.length === 0) return { kind: "very-dynamic" }; + if (!expressions.slice(0, -1).every(isSyntacticallySideEffectFree)) { + return { kind: "very-dynamic" }; + } + return classifyRequestExpression(expressions.at(-1) ?? null, isBound); + } + + return { kind: "very-dynamic" }; +} + +type ScopeType = "module" | "function" | "block" | "static-block"; +const BLOCK_SCOPED_VARIABLE_KINDS = new Set(["let", "const", "using", "await using"]); + +/** + * Block-introducing and control-flow node types that contain + * statements, but are not themselves function/method scopes. The + * pre-pass recurses through these when collecting hoisted `var` + * declarations for the enclosing function/module/static-block scope. + */ +const SCOPE_RECURSE_INTO = new Set([ + "BlockStatement", + "IfStatement", + "ForStatement", + "ForInStatement", + "ForOfStatement", + "WhileStatement", + "DoWhileStatement", + "WithStatement", + "TryStatement", + "CatchClause", + "LabeledStatement", + "SwitchStatement", + "SwitchCase", +]); + +function isFunctionLike(node: AstRecord): boolean { + return ( + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression" + ); +} + +function isExportWrapper(node: AstRecord): boolean { + return node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration"; +} + +function isHoistingScope(scopeType: ScopeType): boolean { + return scopeType === "module" || scopeType === "function" || scopeType === "static-block"; +} + +function walkAstWithBindings( + value: unknown, + visit: (node: AstRecord, isBound: (name: string) => boolean) => void, +): void { + const scopeStack: Set[] = [new Set()]; + + function isBound(name: string): boolean { + for (const scope of scopeStack) { + if (scope.has(name)) return true; + } + return false; + } + + /** + * Recursively collect `var` declarations from anywhere inside the + * current node, crossing into nested blocks and control flow. `var` + * is the only kind that hoists to the enclosing function/module scope + * from inside a nested block — lexical variables, `class`, and function + * declarations remain block-scoped. + */ + function collectVarDeclarationsFromNestedBlocks(node: AstRecord, targetScope: Set): void { + if (!node) return; + if (isFunctionLike(node)) return; + + if (isExportWrapper(node)) { + const declaration = toAstRecord(node.declaration); + if (declaration) { + collectVarDeclarationsFromNestedBlocks(declaration, targetScope); + } + return; + } + + if (node.type === "VariableDeclaration" && node.kind === "var") { + for (const decl of astArray(node.declarations)) { + collectBindingNames(decl.id, targetScope); + } + return; + } + + if (SCOPE_RECURSE_INTO.has(node.type)) { + forEachAstChild(node, (child) => collectVarDeclarationsFromNestedBlocks(child, targetScope)); + } + } + + /** + * Collect all bindings that exist in the current scope *before* any + * expression is visited. This ensures TDZ and hoisted declarations are + * both visible for the entire scope. + * + * Scope types: + * - "module": everything (function, variables, class, import) + * declared at the top level of the module, plus `var` + * hoisted from nested blocks. + * - "function": everything (function, variables, class) + * declared at the top level of the function body, + * plus `var` hoisted from nested blocks. + * - "static-block": like "function", but scoped to a class static + * block. `var` inside a static block is scoped to + * the static block itself, not the enclosing + * function/module, so it must be collected here and + * must NOT leak to the function-scope pre-pass. + * - "block": block-scoped declarations (lexical variables, class, + * function) at the top level of the current block + * only. `var` does NOT belong to a block scope — it + * is hoisted to the enclosing function/static-block + * scope and collected by that pre-pass. + */ + function collectDeclarationsForScope( + node: AstRecord, + scopeType: ScopeType, + targetScope: Set, + ): void { + if (!node) return; + + // TS `declare` declarations are type-only and have no runtime + // binding. OXC sets `declare: true` on `declare const`, + // `declare let`, `declare class`, `declare function`, etc. + if (node.declare === true) return; + + if (isExportWrapper(node)) { + const declaration = toAstRecord(node.declaration); + if (declaration) { + collectDeclarationsForScope(declaration, scopeType, targetScope); + } + return; + } + + if (node.type === "ImportDeclaration" && scopeType === "module") { + if (node.importKind === "type") return; + + for (const spec of astArray(node.specifiers)) { + if (spec.importKind === "type") continue; + + const localName = getAstName(spec.local); + if (localName) targetScope.add(localName); + } + return; + } + + if (node.type === "ClassDeclaration" && node.id) { + collectBindingNames(node.id, targetScope); + return; + } + + if (node.type === "FunctionDeclaration" && node.id) { + collectBindingNames(node.id, targetScope); + return; + } + + if ( + node.type === "TSEnumDeclaration" || + node.type === "TSModuleDeclaration" || + (node.type === "TSImportEqualsDeclaration" && node.importKind !== "type") + ) { + collectBindingNames(node.id, targetScope); + return; + } + + if (node.type === "VariableDeclaration") { + const isBlockScoped = + scopeType === "block" && BLOCK_SCOPED_VARIABLE_KINDS.has(String(node.kind)); + if (isHoistingScope(scopeType) || isBlockScoped) { + for (const decl of astArray(node.declarations)) { + collectBindingNames(decl.id, targetScope); + } + } + return; + } + + if (isHoistingScope(scopeType) && SCOPE_RECURSE_INTO.has(node.type)) { + collectVarDeclarationsFromNestedBlocks(node, targetScope); + } + } + + function walkBody(body: AstRecord[], scopeType: ScopeType): void { + const currentScope = scopeStack[scopeStack.length - 1]; + for (const node of body) { + collectDeclarationsForScope(node, scopeType, currentScope); + } + for (const node of body) { + walkNode(node); + } + } + + function walkNode(node: AstRecord): void { + if (!node) return; + + visit(node, isBound); + + if (isFunctionLike(node)) { + scopeStack.push(new Set()); + + // Named function expressions bind their name only in their own scope + if (node.type === "FunctionExpression" && node.id) { + collectBindingNames(node.id, scopeStack[scopeStack.length - 1]); + } + + for (const param of astArray(node.params)) { + collectBindingNames(param, scopeStack[scopeStack.length - 1]); + } + + // Walk parameter default RHS expressions with the parameter bindings + // already in scope. A default like `function f(require = require(id))` + // must NOT rewrite the RHS `require(id)` because the parameter binding + // named `require` is the resolved binding for that call. A default + // like `function load(x = require(id))` rewrites normally because + // `require` is not bound by the parameter list. Binding collection + // does not walk default-value expressions, so they need an explicit pass. + for (const param of astArray(node.params)) { + walkNode(param); + } + + if (node.body) { + const bodyRec = toAstRecord(node.body); + if (bodyRec?.type === "BlockStatement") { + walkBody(astArray(bodyRec.body), "function"); + } else if (bodyRec) { + walkNode(bodyRec); + } + } + + scopeStack.pop(); + return; + } + + if (node.type === "BlockStatement") { + scopeStack.push(new Set()); + walkBody(astArray(node.body), "block"); + scopeStack.pop(); + return; + } + + if (node.type === "CatchClause") { + scopeStack.push(new Set()); + if (node.param) { + collectBindingNames(node.param, scopeStack[scopeStack.length - 1]); + } + const bodyRec = toAstRecord(node.body); + if (bodyRec?.type === "BlockStatement") { + walkBody(astArray(bodyRec.body), "block"); + } else if (bodyRec) { + walkNode(bodyRec); + } + scopeStack.pop(); + return; + } + + if (node.type === "TSModuleDeclaration") { + const body = toAstRecord(node.body); + if (body?.type === "TSModuleBlock") { + scopeStack.push(new Set()); + collectBindingNames(node.id, scopeStack[scopeStack.length - 1]); + walkBody(astArray(body.body), "module"); + scopeStack.pop(); + } else if (body) { + walkNode(body); + } + return; + } + + // `for` / `for-in` / `for-of` with a lexical variable header introduce a + // per-iteration lexical scope that covers init/test/update/body (or + // left/right/body). `var` headers remain function-scoped and are + // already collected by the enclosing function-scope pre-pass. + if (node.type === "ForStatement") { + const init = toAstRecord(node.init); + const hasLexicalHeader = + init?.type === "VariableDeclaration" && BLOCK_SCOPED_VARIABLE_KINDS.has(String(init.kind)); + + if (hasLexicalHeader) { + scopeStack.push(new Set()); + for (const decl of astArray(init.declarations)) { + collectBindingNames(decl.id, scopeStack[scopeStack.length - 1]); + } + } + + if (init) walkNode(init); + const test = toAstRecord(node.test); + if (test) walkNode(test); + const update = toAstRecord(node.update); + if (update) walkNode(update); + const body = toAstRecord(node.body); + if (body) walkNode(body); + + if (hasLexicalHeader) { + scopeStack.pop(); + } + return; + } + + if (node.type === "ForInStatement" || node.type === "ForOfStatement") { + const left = toAstRecord(node.left); + const hasLexicalHeader = + left?.type === "VariableDeclaration" && BLOCK_SCOPED_VARIABLE_KINDS.has(String(left.kind)); + + if (hasLexicalHeader) { + scopeStack.push(new Set()); + for (const decl of astArray(left.declarations)) { + collectBindingNames(decl.id, scopeStack[scopeStack.length - 1]); + } + } + + if (left) walkNode(left); + const right = toAstRecord(node.right); + if (right) walkNode(right); + const body = toAstRecord(node.body); + if (body) walkNode(body); + + if (hasLexicalHeader) { + scopeStack.pop(); + } + return; + } + + // `switch` cases share the lexical scope of the `SwitchStatement` + // itself unless the user adds explicit braces around a case body. + // Model this with a single shared switch scope that pre-collects + // lexical variables, `class`, and `function` from every case consequent. + if (node.type === "SwitchStatement") { + const discriminant = toAstRecord(node.discriminant); + if (discriminant) walkNode(discriminant); + + scopeStack.push(new Set()); + const switchScope = scopeStack[scopeStack.length - 1]; + + for (const caseNode of astArray(node.cases)) { + for (const stmt of astArray(caseNode.consequent)) { + if ( + stmt.type === "VariableDeclaration" && + BLOCK_SCOPED_VARIABLE_KINDS.has(String(stmt.kind)) + ) { + for (const decl of astArray(stmt.declarations)) { + collectBindingNames(decl.id, switchScope); + } + } else if (stmt.type === "ClassDeclaration" && stmt.id) { + collectBindingNames(stmt.id, switchScope); + } else if (stmt.type === "FunctionDeclaration" && stmt.id) { + collectBindingNames(stmt.id, switchScope); + } + } + } + + for (const caseNode of astArray(node.cases)) { + for (const stmt of astArray(caseNode.consequent)) { + walkNode(stmt); + } + } + + scopeStack.pop(); + return; + } + + // `SwitchCase` is handled by its parent `SwitchStatement`. If the + // walker ever reaches one in isolation (parser edge case), walk the + // consequent without pushing a new scope to avoid double-scoping. + if (node.type === "SwitchCase") { + for (const stmt of astArray(node.consequent)) { + walkNode(stmt); + } + return; + } + + // Class static blocks (`static { ... }`) introduce their own lexical + // scope with `var`-as-static-block semantics: a `var` declared inside + // a static block is scoped to that static block, not the enclosing + // class/module/function. Use the "static-block" scope type so the + // pre-pass collects variables, `class`, and `function` at the + // top level and hoists `var` from nested control-flow blocks, without + // leaking those bindings to the enclosing function/module scope. + if (node.type === "StaticBlock") { + scopeStack.push(new Set()); + walkBody(astArray(node.body), "static-block"); + scopeStack.pop(); + return; + } + + // Named class expressions (`const C = class require { ... }`) bind + // their name in a scope that wraps the class body. Static blocks + // inside see the class name (per ES2022), so this scope sits + // between the class body's static blocks and the enclosing scope. + // Methods do not see the class name, but they create their own + // function scopes when walked, so the extra class scope is harmless + // for them. Decorators are evaluated in the enclosing scope, while the + // class name is already bound (in TDZ) during `superClass` evaluation. + // An unnamed class expression does not introduce a new binding. + if (node.type === "ClassExpression") { + for (const dec of astArray(node.decorators)) walkNode(dec); + if (node.id) { + scopeStack.push(new Set()); + collectBindingNames(node.id, scopeStack[scopeStack.length - 1]); + } + const superClass = toAstRecord(node.superClass); + if (superClass) walkNode(superClass); + const body = toAstRecord(node.body); + if (body) walkNode(body); + if (node.id) scopeStack.pop(); + return; + } + + // Generic recursion for non-scope nodes + forEachAstChild(node, walkNode); + } + + const root = toAstRecord(value); + if (root?.type === "Program") { + walkBody(astArray(root.body), "module"); + } else if (Array.isArray(value)) { + walkBody(astArray(value), "block"); + } +} + +function isVeryDynamicRequireCall(node: AstRecord, isBound: (name: string) => boolean): boolean { + if (node.type !== "CallExpression") return false; + if (!isIdentifierNamed(unwrapTransparentExpression(toAstRecord(node.callee)), "require")) { + return false; + } + if (isBound("require")) return false; + + const request = singleArgument(node); + if (!request) return false; + + return classifyRequestExpression(request, isBound).kind === "very-dynamic"; +} + +function isVeryDynamicImportExpression( + node: AstRecord, + isBound: (name: string) => boolean, +): boolean { + if (node.type !== "ImportExpression") return false; + return classifyRequestExpression(toAstRecord(node.source), isBound).kind === "very-dynamic"; +} + +function dynamicRequireFailureExpression(): string { + return `(() => { + const e = new Error("${DYNAMIC_REQUEST_ERROR_MESSAGE}"); + e.code = "MODULE_NOT_FOUND"; + throw e; +})()`; +} + +function dynamicImportFailureExpression(): string { + return `Promise.resolve().then(() => { + const e = new Error("${DYNAMIC_REQUEST_ERROR_MESSAGE}"); + e.code = "MODULE_NOT_FOUND"; + throw e; +})`; +} + +export function isTransformableModuleId(id: string): boolean { + if (isDependencyId(id)) return false; + const cleanId = cleanModuleId(id); + if (path.isAbsolute(cleanId)) { + const resolvedId = path.resolve(cleanId); + if ( + resolvedId === VINEXT_SOURCE_ROOT || + resolvedId.startsWith(`${VINEXT_SOURCE_ROOT}${path.sep}`) + ) { + return false; + } + } + return parserLangForId(id) !== null; +} + +export function mayContainVeryDynamicRequestTarget(code: string): boolean { + return ( + /\brequire\b/.test(code) || + /\bimport\b(?:\s|(?:\/\*[\s\S]*?\*\/)|(?:\/\/[^\r\n]*(?:\r?\n|$)))*\(/.test(code) + ); +} + +export function ignoreVeryDynamicRequests(code: string, id: string): TransformResult | null { + if (!mayContainVeryDynamicRequestTarget(code)) return null; + const source = cleanModuleId(id); + const lang = parserLangForId(id); + if (!lang) return null; + + let ast: ReturnType; + try { + ast = parseAst(code, { lang }, source); + } catch { + return null; + } + + const output = new MagicString(code); + const rewrittenRanges: { start: number; end: number }[] = []; + let changed = false; + + function isInsideRewrittenRange(node: AstRecord): boolean { + if (!hasRange(node)) return false; + return rewrittenRanges.some((range) => node.start >= range.start && node.end <= range.end); + } + + function overwriteNode(node: AstRecord, replacement: string): void { + if (!hasRange(node)) return; + output.overwrite(node.start, node.end, replacement); + rewrittenRanges.push({ start: node.start, end: node.end }); + changed = true; + } + + walkAstWithBindings(ast, (node, isBound) => { + if (isInsideRewrittenRange(node)) return; + + if (isVeryDynamicRequireCall(node, isBound)) { + overwriteNode(node, dynamicRequireFailureExpression()); + return; + } + + if (isVeryDynamicImportExpression(node, isBound)) { + overwriteNode(node, dynamicImportFailureExpression()); + } + }); + + if (!changed) return null; + + return { + code: output.toString(), + map: output.generateMap({ hires: true, source }), + }; +} + +export function createIgnoreDynamicRequestsPlugin(): Plugin { + return { + name: "vinext:ignore-dynamic-requests", + enforce: "pre", + // Keep dev/build parity: vite-plugin-commonjs can analyze require(dynamic) + // in both modes, so this guard must run before it in both modes too. + transform(code, id) { + if (!isTransformableModuleId(id)) return null; + return ignoreVeryDynamicRequests(code, id); + }, + }; +} diff --git a/packages/vinext/src/plugins/import-meta-url.ts b/packages/vinext/src/plugins/import-meta-url.ts index 4227ef7b7..0a8669ff9 100644 --- a/packages/vinext/src/plugins/import-meta-url.ts +++ b/packages/vinext/src/plugins/import-meta-url.ts @@ -26,6 +26,7 @@ import { type AstRange, type AstRecord, } from "./ast-utils.js"; +import { cleanModuleId, TRANSFORMABLE_SCRIPT_EXTENSIONS } from "./transform-utils.js"; type ImportMetaUrlEnvironment = "client" | "server"; @@ -41,17 +42,6 @@ type RootPaths = { excludedRelativePrefixes: string[]; }; -const TRANSFORMABLE_SCRIPT_EXTENSIONS = new Set([ - ".cjs", - ".cts", - ".js", - ".jsx", - ".mjs", - ".mts", - ".ts", - ".tsx", -]); - export function createImportMetaUrlPlugin(options: { getRoot: () => string | undefined }): Plugin { let rootPaths: RootPaths | undefined; let outputDirs: string[] = []; @@ -173,10 +163,6 @@ function rewriteCanonicalSourceIdentity( }; } -function cleanModuleId(id: string): string { - return id.split("?", 1)[0]; -} - function createRootPaths(root: string, options: { outputDirs?: string[] } = {}): RootPaths { const canonicalRoot = canonicalizePath(root); const normalizedRoot = normalizePath(canonicalRoot); diff --git a/packages/vinext/src/plugins/require-context.ts b/packages/vinext/src/plugins/require-context.ts index 42331b697..6e5de83d3 100644 --- a/packages/vinext/src/plugins/require-context.ts +++ b/packages/vinext/src/plugins/require-context.ts @@ -26,20 +26,12 @@ import { hasRange, isAstRecord, nodeArray, + toAstRecord, type AstRange, type AstRecord, + unwrapTransparentExpression, } from "./ast-utils.js"; - -const TRANSFORMABLE_EXTENSIONS = new Set([ - ".js", - ".jsx", - ".ts", - ".tsx", - ".mjs", - ".cjs", - ".mts", - ".cts", -]); +import { parserLangForId } from "./transform-utils.js"; type ParsedCall = { range: AstRange; @@ -57,7 +49,7 @@ export function createRequireContextPlugin(): Plugin { enforce: "pre", transform(code, id) { if (!mayContainRequireContext(code)) return null; - const lang = langForId(id); + const lang = parserLangForId(id); if (!lang) return null; let ast: unknown; @@ -89,27 +81,6 @@ function mayContainRequireContext(code: string): boolean { return code.includes("require") && code.includes(".context"); } -function langForId(id: string): "js" | "jsx" | "ts" | "tsx" | null { - const clean = id.split("?", 1)[0]; - const dot = clean.lastIndexOf("."); - if (dot < 0) return null; - const ext = clean.slice(dot).toLowerCase(); - if (!TRANSFORMABLE_EXTENSIONS.has(ext)) return null; - switch (ext) { - case ".ts": - case ".cts": - case ".mts": - return "ts"; - case ".tsx": - return "tsx"; - case ".jsx": - return "jsx"; - default: - // .js / .jsx / .mjs / .cjs — parse as jsx so JSX in .js still works. - return "jsx"; - } -} - function collectRequireContextCalls(ast: unknown): ParsedCall[] { const calls: ParsedCall[] = []; @@ -195,27 +166,8 @@ function parseRequireContextCall(node: AstRecord): ParsedCall | null { // `require`, `(require)`, `(require as any)`, `(require as unknown as Foo)`, … function isRequireExpression(value: unknown): boolean { - let node = value; - // Unwrap TS assertion / non-null / parenthesized wrappers around `require`. - while (isAstRecord(node)) { - if (node.type === "Identifier") { - return node.name === "require"; - } - if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") { - node = node.expression; - continue; - } - if (node.type === "TSNonNullExpression") { - node = node.expression; - continue; - } - if (node.type === "ParenthesizedExpression") { - node = node.expression; - continue; - } - return false; - } - return false; + const node = unwrapTransparentExpression(toAstRecord(value)); + return node?.type === "Identifier" && node.name === "require"; } function isPropertyNamed(value: unknown, name: string): boolean { diff --git a/packages/vinext/src/plugins/transform-utils.ts b/packages/vinext/src/plugins/transform-utils.ts new file mode 100644 index 000000000..294c3e004 --- /dev/null +++ b/packages/vinext/src/plugins/transform-utils.ts @@ -0,0 +1,48 @@ +import path from "node:path"; + +/** + * Script extensions that vinext transform plugins handle. + */ +export const TRANSFORMABLE_SCRIPT_EXTENSIONS = new Set([ + ".cjs", + ".cts", + ".js", + ".jsx", + ".mjs", + ".mts", + ".ts", + ".tsx", +]); + +/** + * Strip query/hash parameters and the leading null byte that Vite sometimes + * prefixes on virtual module IDs. + */ +export function cleanModuleId(id: string): string { + const cleanId = id.split(/[?#]/, 1)[0] ?? id; + return cleanId.charCodeAt(0) === 0 ? cleanId.slice(1) : cleanId; +} + +/** + * Returns true when the module id points into `node_modules`. + */ +export function isDependencyId(id: string): boolean { + const cleanId = cleanModuleId(id).replaceAll("\\", "/"); + return cleanId.includes("/node_modules/"); +} + +/** + * Map a module id to the parser language Vite/Rolldown should use. + * Returns `null` for non-script extensions. + */ +export function parserLangForId(id: string): "js" | "jsx" | "ts" | "tsx" | null { + const cleanId = cleanModuleId(id); + const extension = path.extname(cleanId).toLowerCase(); + if (!TRANSFORMABLE_SCRIPT_EXTENSIONS.has(extension)) return null; + if (extension === ".ts" || extension === ".cts" || extension === ".mts") return "ts"; + if (extension === ".tsx") return "tsx"; + // Next.js allows JSX in plain .js files; parse them as JSX so the + // pre-transform can analyse files that haven't been through the JSX-in-JS + // plugin yet. + return "jsx"; +} diff --git a/packages/vinext/src/server/pages-get-initial-props.ts b/packages/vinext/src/server/pages-get-initial-props.ts index 3e4b30c51..7b4314bc4 100644 --- a/packages/vinext/src/server/pages-get-initial-props.ts +++ b/packages/vinext/src/server/pages-get-initial-props.ts @@ -1,3 +1,5 @@ +import { getObjectProperty } from "../plugins/ast-utils.js"; + type PagesGetInitialPropsContext = { req?: unknown; res?: unknown; @@ -12,19 +14,10 @@ type PagesGetInitialPropsContext = { type PagesGetInitialProps = (context: PagesGetInitialPropsContext) => unknown; -function isObjectLike(value: unknown): value is object { - return (typeof value === "object" && value !== null) || typeof value === "function"; -} - function isPagesGetInitialProps(value: unknown): value is PagesGetInitialProps { return typeof value === "function"; } -function getObjectProperty(target: unknown, property: string): unknown { - if (!isObjectLike(target)) return undefined; - return Reflect.get(target, property); -} - function getDisplayName(component: unknown): string { const displayName = getObjectProperty(component, "displayName"); if (typeof displayName === "string" && displayName.length > 0) return displayName; diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 650162fce..83bd92327 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -29,6 +29,11 @@ import { collectAssetTags } from "../packages/vinext/src/server/pages-asset-tags import { computeClientRuntimeMetadata } from "../packages/vinext/src/utils/client-runtime-metadata.js"; import { manifestFileWithBase } from "../packages/vinext/src/utils/manifest-paths.js"; import { asyncHooksStubPlugin as _asyncHooksStubPlugin } from "../packages/vinext/src/plugins/async-hooks-stub.js"; +import { + ignoreVeryDynamicRequests as _ignoreVeryDynamicRequests, + isTransformableModuleId as _isTransformableModuleId, + mayContainVeryDynamicRequestTarget as _mayContainVeryDynamicRequestTarget, +} from "../packages/vinext/src/plugins/ignore-dynamic-requests.js"; // Create a clientManualChunks instance with a test shims directory. // The exact path doesn't matter for the node_modules-focused tests; @@ -2774,6 +2779,913 @@ export const getStaticPaths = () => [ }); }); +// ─── App Router build compatibility ────────────────────────────────────────── + +describe("App Router build compatibility", () => { + // Ported from Next.js: test/e2e/app-dir/dynamic-requests/dynamic-requests.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/dynamic-requests/dynamic-requests.test.ts + it("builds page and route modules with guarded very dynamic import and require calls", async () => { + const vinext = (await import("../packages/vinext/src/index.js")).default; + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-dynamic-requests-build-")); + const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules"); + await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction"); + + const writeFile = async (filePath: string, content: string) => { + const absPath = path.join(tmpDir, filePath); + await fsp.mkdir(path.dirname(absPath), { recursive: true }); + await fsp.writeFile(absPath, content); + }; + + try { + await writeFile( + "package.json", + JSON.stringify({ name: "vinext-dynamic-requests-build", private: true, type: "module" }), + ); + await writeFile( + "tsconfig.json", + JSON.stringify( + { + compilerOptions: { + target: "ES2022", + module: "ESNext", + moduleResolution: "bundler", + jsx: "react-jsx", + strict: true, + skipLibCheck: true, + types: ["vite/client", "@vitejs/plugin-rsc/types"], + }, + include: ["app", "*.ts", "*.tsx"], + }, + null, + 2, + ), + ); + await writeFile( + "app/layout.tsx", + `import type { ReactNode } from "react"; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} +`, + ); + + const guardedDynamicRequest = `function dynamic() { + const dynamic = Math.random() + ""; + require(dynamic); + import(dynamic); +} +`; + + await writeFile( + "app/page.tsx", + `export default async function Page() { + if (Math.random() < 0) dynamic(); + return

Hello World

; +} + +${guardedDynamicRequest}`, + ); + await writeFile( + "app/hello/route.tsx", + `export function GET() { + if (Math.random() < 0) dynamic(); + return new Response("Hello World"); +} + +${guardedDynamicRequest}`, + ); + + const rscOutDir = path.join(tmpDir, "dist", "server"); + const ssrOutDir = path.join(tmpDir, "dist", "server", "ssr"); + const clientOutDir = path.join(tmpDir, "dist", "client"); + const builder = await createBuilder({ + root: tmpDir, + configFile: false, + plugins: [vinext({ appDir: tmpDir, rscOutDir, ssrOutDir, clientOutDir })], + logLevel: "silent", + }); + await builder.buildApp(); + + await expect(fsp.access(path.join(tmpDir, "dist", "server", "index.js"))).resolves.toBe( + undefined, + ); + await expect( + fsp.access(path.join(tmpDir, "dist", "server", "ssr", "index.js")), + ).resolves.toBe(undefined); + await expect(fsp.access(path.join(tmpDir, "dist", "client"))).resolves.toBe(undefined); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }, 60_000); + + it("builds .js files with JSX and guarded very dynamic requests", async () => { + const vinext = (await import("../packages/vinext/src/index.js")).default; + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-dynamic-requests-jsx-")); + const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules"); + await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction"); + + const writeFile = async (filePath: string, content: string) => { + const absPath = path.join(tmpDir, filePath); + await fsp.mkdir(path.dirname(absPath), { recursive: true }); + await fsp.writeFile(absPath, content); + }; + + try { + await writeFile( + "package.json", + JSON.stringify({ name: "vinext-dynamic-requests-jsx", private: true, type: "module" }), + ); + await writeFile( + "tsconfig.json", + JSON.stringify( + { + compilerOptions: { + target: "ES2022", + module: "ESNext", + moduleResolution: "bundler", + jsx: "react-jsx", + strict: true, + skipLibCheck: true, + types: ["vite/client", "@vitejs/plugin-rsc/types"], + }, + include: ["app", "*.ts", "*.tsx"], + }, + null, + 2, + ), + ); + await writeFile( + "app/layout.tsx", + `import type { ReactNode } from "react"; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} +`, + ); + + await writeFile( + "app/page.js", + `export default function Page() { + if (Math.random() < 0) { + const id = Math.random() + ""; + require(id); + import(id); + } + + return

Hello World

; +} +`, + ); + + const rscOutDir = path.join(tmpDir, "dist", "server"); + const ssrOutDir = path.join(tmpDir, "dist", "server", "ssr"); + const clientOutDir = path.join(tmpDir, "dist", "client"); + const builder = await createBuilder({ + root: tmpDir, + configFile: false, + plugins: [vinext({ appDir: tmpDir, rscOutDir, ssrOutDir, clientOutDir })], + logLevel: "silent", + }); + await builder.buildApp(); + + await expect(fsp.access(path.join(tmpDir, "dist", "server", "index.js"))).resolves.toBe( + undefined, + ); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } + }, 60_000); +}); + +// ─── ignoreVeryDynamicRequests ──────────────────────────────────────────────── + +describe("ignoreVeryDynamicRequests", () => { + function expectDynamicRequireFailure(code: string): void { + expect(code).toContain(`new Error("Cannot find module as expression is too dynamic")`); + expect(code).toContain(`e.code = "MODULE_NOT_FOUND"`); + expect(code).toContain("throw e;"); + } + + function expectDynamicImportFailure(code: string): void { + expect(code).toContain("Promise.resolve().then(() => {"); + expectDynamicRequireFailure(code); + } + + it("skips static import declarations in the fast path", () => { + const code = ` +import value from "./value"; +import { other as renamed } from "./other"; + +export const result = value + renamed; +`; + expect(_mayContainVeryDynamicRequestTarget(code)).toBe(false); + expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).toBeNull(); + }); + + it("rejects non-call import text without regex backtracking", () => { + expect(_mayContainVeryDynamicRequestTarget(`import${" ".repeat(1_000)}value`)).toBe(false); + expect(_mayContainVeryDynamicRequestTarget("important(value)")).toBe(false); + }); + + it("skips vinext internal runtime modules in the plugin guard", () => { + expect( + _isTransformableModuleId(path.join(process.cwd(), "packages/vinext/src/shims/router.ts")), + ).toBe(false); + expect(_isTransformableModuleId("/app/page.ts")).toBe(true); + }); + + it("rewrites unbound require(dynamic) to a Next-like runtime failure expression", () => { + const code = `require(id);`; + expect(_mayContainVeryDynamicRequestTarget(code)).toBe(true); + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + expect(result!.code).not.toContain("__vinext_ignored_dynamic_require__"); + }); + + it("rewrites comment-separated very dynamic call forms", () => { + for (const code of ["require/* comment */(id);", "import// comment\n(id);"]) { + expect(_mayContainVeryDynamicRequestTarget(code)).toBe(true); + expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).not.toBeNull(); + } + }); + + it("rewrites wrapped very dynamic require call forms", () => { + for (const code of ["(require)(id);", "(require as any)(id);"]) { + expect(_mayContainVeryDynamicRequestTarget(code)).toBe(true); + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + } + }); + + it("leaves require() untouched", () => { + const result = _ignoreVeryDynamicRequests(`require();`, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("leaves require(dynamic, extra) untouched", () => { + const result = _ignoreVeryDynamicRequests(`require(id, extra);`, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("leaves require(...args) untouched", () => { + const result = _ignoreVeryDynamicRequests(`require(...args);`, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("uses the cleaned module id as the source map source", () => { + const result = _ignoreVeryDynamicRequests(`require(id);`, "/app/page.ts?raw#hash"); + expect(result).not.toBeNull(); + expect(result!.map.sources).toEqual(["/app/page.ts"]); + }); + + it("does not rewrite local const require = ...; require(dynamic)", () => { + const code = ` +const require = (id: string) => ({ id }); + +export default function Page() { + const result = require(Math.random() + ""); + return

{result.id}

; +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.tsx"); + expect(result).toBeNull(); + }); + + it("does not rewrite require(dynamic) when require is a function parameter", () => { + const code = ` +function factory(require) { + return require(dynamic); +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/helper.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require(dynamic) when require is imported", () => { + const code = ` +import { require } from "./custom-require"; +require(id); +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require(dynamic) inside a function named require", () => { + const code = ` +function require(id) { + require(id); +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/helper.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require(dynamic) inside a catch binding named require", () => { + const code = ` +try { + require(id); +} catch (require) { + require(id); +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + // The try-block require is rewritten; catch-block require stays untouched. + expectDynamicRequireFailure(result!.code); + expect(result!.code).toContain("} catch (require) {\n require(id);"); + }); + + it("does not rewrite require before a hoisted function declaration named require", () => { + const code = ` +export default function Page() { + return require(id); + + function require(id: string) { + return { id }; + } +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.tsx"); + expect(result).toBeNull(); + }); + + it("does not let block-scoped require shadow an outer unbound require", () => { + const code = ` +if (flag) { + const require = (id: string) => ({ id }); + require(id); +} + +require(id); +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + }); + + it("does not rewrite require before a later const binding named require", () => { + const code = ` +export default function Page() { + return require(id); + + const require = (id: string) => ({ id }); +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.tsx"); + expect(result).toBeNull(); + }); + + it("does not rewrite require before an exported function declaration named require", () => { + const code = ` +require(id); + +export function require(id: string) { + return { id }; +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require before an exported const binding named require", () => { + const code = ` +require(id); + +export const require = (id: string) => ({ id }); +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require captured from a for-of lexical binding", () => { + const code = ` +for (const require of loaders) { + require(id); +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require captured from a for initializer lexical binding", () => { + const code = ` +for (const require = makeRequire(); enabled; update()) { + require(id); +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require shadowed by a switch-wide lexical binding", () => { + const code = ` +switch (kind) { + case "load": + require(id); + break; + + case "custom": + const require = (id: string) => ({ id }); + break; +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("rewrites unbound require(dynamic) in a function parameter default", () => { + const code = ` +function load(x = require(id)) { + return x; +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + }); + + it("rewrites unbound import(dynamic) in a function parameter default to a rejected promise", () => { + const code = ` +function load(x = import(id)) { + return x; +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicImportFailure(result!.code); + }); + + it("does not rewrite a parameter default that resolves to a parameter named require", () => { + const code = ` +function load(require = require(id)) { + return require; +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require shadowed by a class static block lexical binding", () => { + const code = ` +class Loader { + static { + require(id); + const require = (id: string) => ({ id }); + } +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require shadowed by a class static block var binding", () => { + const code = ` +class Loader { + static { + require(id); + + var require = (id: string) => ({ id }); + } +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require shadowed by a catch-body var binding", () => { + const code = ` +try { + throw new Error("x"); +} catch (e) { + var require = (id: string) => ({ id }); + require(id); +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require after a catch-body var binding", () => { + const code = ` +try { + throw new Error("x"); +} catch (e) { + var require = (id: string) => ({ id }); +} + +require(id); +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require shadowed by a labelled-block var binding", () => { + const code = ` +label: { + var require = (id: string) => ({ id }); + require(id); +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require after a labelled-block var binding", () => { + const code = ` +label: { + var require = (id: string) => ({ id }); +} + +require(id); +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("does not leak class static block var require outside the static block", () => { + const code = ` +class Loader { + static { + var require = (id: string) => ({ id }); + require(id); + } +} + +require(id); +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + }); + + it("does not rewrite require shadowed by a named class expression binding", () => { + const code = ` +const C = class require { + static { + require(id); + } +}; +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require in a named class expression with extends or method names", () => { + const code = ` +const C = class require extends Base { + require(id: string) { + return { id }; + } + + static { + require(id); + } +}; +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("does not rewrite require shadowed by a named class expression in extends", () => { + const code = `const Loader = class require extends require(id) {};`; + expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).toBeNull(); + }); + + it("rewrites require in a switch discriminant before case bindings enter scope", () => { + const code = `switch (require(id)) { case 0: let require; }`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + }); + + it("rewrites require after `declare const require` (type-only, no runtime binding)", () => { + const code = `declare const require: unknown; +require(id); +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + }); + + it("rewrites require after `declare function require` (type-only, no runtime binding)", () => { + const code = `declare function require(id: string): unknown; +require(id); +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + }); + + it("rewrites require after `declare class require` (type-only, no runtime binding)", () => { + const code = `declare class require { + static { + require(id); + } +} + +require(id); +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + }); + + it("rewrites require after `import type` renamed to require", () => { + const code = `import type { RequireFn as require } from "./types"; +require(id); +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + }); + + it("rewrites require after a type-only import specifier renamed to require", () => { + const code = `import { type RequireFn as require, value } from "./mod"; +require(id); +value(); +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + }); + + it("leaves require calls shadowed by runtime TypeScript declarations untouched", () => { + for (const declaration of [ + "enum require { A }", + "namespace require { export const A = 1 }", + 'import require = require("./loader")', + ]) { + const result = _ignoreVeryDynamicRequests(`${declaration}; require(id);`, "/app/page.ts"); + expect(result).toBeNull(); + } + }); + + it("leaves require calls shadowed inside TypeScript namespace scopes untouched", () => { + for (const code of [ + "namespace N { export const require = load; require(id); }", + "namespace N { export import require = loader; require(id); }", + "namespace require { require(id); }", + ]) { + expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).toBeNull(); + } + }); + + it("leaves require calls shadowed by constructor parameter properties untouched", () => { + const code = `class Loader { + constructor(private require = load) { + require(id); + } +}`; + expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).toBeNull(); + }); + + it("leaves require calls shadowed by resource-management declarations untouched", () => { + for (const code of [ + "{ using require = resource; require(id); }", + "async function run() { await using require = resource; require(id); }", + "for (using require of resources) require(id);", + "switch (value) { case 0: using require = resource; break; case 1: require(id); }", + ]) { + expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).toBeNull(); + } + }); + + it("rewrites require after declare-only TypeScript declarations", () => { + for (const declaration of [ + "declare enum require { A }", + "declare namespace require { const A: number }", + ]) { + const result = _ignoreVeryDynamicRequests(`${declaration}; require(id);`, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + } + }); + + it("leaves require('./' + name) untouched for existing analysis", () => { + const code = `require('./' + name);`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("leaves import('./' + name) untouched for existing analysis", () => { + const code = `import('./' + name);`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it('leaves require("") untouched as a static literal request', () => { + const code = `require("");`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it('leaves import("") untouched as a static literal request', () => { + const code = `import("");`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("leaves non-string constant requests untouched for existing analysis", () => { + for (const request of ["42", "true", "false", "null", "undefined", "/pattern/i"]) { + expect(_ignoreVeryDynamicRequests(`require(${request});`, "/app/page.ts")).toBeNull(); + expect(_ignoreVeryDynamicRequests(`import(${request});`, "/app/page.ts")).toBeNull(); + } + }); + + it("rewrites locally shadowed undefined requests as very dynamic", () => { + for (const code of [ + "function load(undefined) { require(undefined); import(undefined); }", + "{ const undefined = request; require(undefined); import(undefined); }", + ]) { + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + expectDynamicImportFailure(result!.code); + } + }); + + it("rewrites slash-only request patterns as very dynamic", () => { + for (const request of [ + '"/"', + '"\\\\"', + "`\\/`", + "`\\\\`", + "`${left}/${right}`", + "`${left}\\/`", + "`${left}\\\\`", + "`${left}/${right}/`", + ]) { + const requireResult = _ignoreVeryDynamicRequests(`require(${request});`, "/app/page.ts"); + expect(requireResult).not.toBeNull(); + expectDynamicRequireFailure(requireResult!.code); + + const importResult = _ignoreVeryDynamicRequests(`import(${request});`, "/app/page.ts"); + expect(importResult).not.toBeNull(); + expectDynamicImportFailure(importResult!.code); + } + }); + + it("does not treat non-concatenating binary expressions as static paths", () => { + for (const request of ['value === "./module"', 'value in { "./module": true }']) { + const requireResult = _ignoreVeryDynamicRequests(`require(${request});`, "/app/page.ts"); + expect(requireResult).not.toBeNull(); + expectDynamicRequireFailure(requireResult!.code); + + const importResult = _ignoreVeryDynamicRequests(`import(${request});`, "/app/page.ts"); + expect(importResult).not.toBeNull(); + expectDynamicImportFailure(importResult!.code); + } + }); + + it("folds statically decidable conditional request branches", () => { + for (const code of [ + 'require(true ? id : "./unused");', + 'import(false ? "./unused" : id);', + 'require([] ? id : "./unused");', + 'import({} ? id : "./unused");', + "require(true && id);", + "import(false || id);", + "require(undefined ?? id);", + ]) { + expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).not.toBeNull(); + } + + for (const code of [ + 'require(false ? id : "./module");', + 'import(true ? "./module" : id);', + "require(false && id);", + "import(true || id);", + 'require("./module" ?? id);', + ]) { + expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).toBeNull(); + } + }); + + it("treats unresolved conditional request expressions as very dynamic", () => { + for (const code of [ + 'require(flag ? id : "./module");', + 'require(flag ? "./module" : id);', + 'import(flag ? id : "./module");', + 'import(flag ? "./module" : id);', + ]) { + expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).not.toBeNull(); + } + }); + + it("uses the final value of side-effect-free sequence requests", () => { + for (const code of [ + 'require((0, "./module"));', + 'import((value, "./module"));', + 'require(({}, "./module"));', + 'import(([], "./module"));', + 'require((function () {}, "./module"));', + 'import((class {}, "./module"));', + ]) { + expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).toBeNull(); + } + + for (const code of [ + 'require((sideEffect(), "./module"));', + 'import((value = 1, "./module"));', + 'require(({ [sideEffect()]: true }, "./module"));', + 'import((class extends sideEffect() {}, "./module"));', + ]) { + expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).not.toBeNull(); + } + }); + + it("leaves require(('./' + name) as string) untouched because it has a static part", () => { + const code = `require(('./' + name) as string);`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("leaves import((`./${name}`) as string) untouched because it has a static part", () => { + const code = "import((`./${name}`) as string);"; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("leaves require(('./' + name) satisfies string) untouched because it has a static part", () => { + const code = `require(("./" + name) satisfies string);`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("leaves import((`./${name}`) satisfies string) untouched because it has a static part", () => { + const code = "import((`./${name}`) satisfies string);"; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).toBeNull(); + }); + + it("correctly classifies require(path!) as very dynamic after unwrapping", () => { + const code = `require(path!);`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + }); + + it("rewrites very dynamic import to a rejected promise", () => { + const code = `import(id);`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicImportFailure(result!.code); + expect(result!.code).not.toContain("@vite-ignore"); + }); + + it("rewrites very dynamic import through TS wrappers to a rejected promise", () => { + const code = `import((id) as string);`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicImportFailure(result!.code); + }); + + it("rewrites the outer require when a very dynamic request is nested inside it", () => { + const code = `require(import(id));`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + expect(result!.code).not.toContain("Promise.resolve().then(() => {"); + }); + + it("rewrites the outer import when a very dynamic request is nested inside it", () => { + const code = `import(require(id));`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expectDynamicImportFailure(result!.code); + expect(result!.code).not.toContain("})()"); + }); + + it("parses .js files with JSX without parse errors", () => { + const code = ` +export default function Page() { + if (Math.random() < 0) { + const id = Math.random() + ""; + require(id); + import(id); + } + return

Hello World

; +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.js"); + expect(result).not.toBeNull(); + expectDynamicRequireFailure(result!.code); + expectDynamicImportFailure(result!.code); + }); +}); + // ─── getClientTreeshakeConfigForVite ────────────────────────────────────────── describe("getClientTreeshakeConfigForVite", () => {