From 63e3d4cbf38b8383a4e8e2d2d055e2e05270239e Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:33:23 +1000 Subject: [PATCH 01/32] fix(app-router): ignore very dynamic requests during build analysis App Router production builds currently fail when a page or route module contains guarded require(dynamic) calls. That differs from Next.js, where requests with no static request part are ignored during graph analysis and only fail if executed at runtime. The violated invariant was that bundler analysis should not turn unreachable, very dynamic requests into build-time failures. vite-plugin-commonjs tried to expand require(dynamic) as a static glob before the branch could remain runtime-only code. Add a pre-transform that preserves directives, removes only very dynamic require calls from CJS graph analysis, and marks matching dynamic import calls with @vite-ignore. The regression builds both an App Router page and route module ported from Next.js dynamic-requests coverage. --- packages/vinext/src/index.ts | 5 + .../src/plugins/ignore-dynamic-requests.ts | 230 ++++++++++++++++++ tests/build-optimization.test.ts | 105 ++++++++ 3 files changed, 340 insertions(+) create mode 100644 packages/vinext/src/plugins/ignore-dynamic-requests.ts diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 65f6a1530..777770fb4 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -149,6 +149,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 { hasMdxFiles } from "./utils/mdx-scan.js"; import { scanPublicFileRoutes } from "./utils/public-routes.js"; import { getViteMajorVersion } from "./utils/vite-version.js"; @@ -982,6 +983,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ...(viteMajorVersion >= 8 ? [] : [tsconfigPaths()]), // React Fast Refresh + JSX transform for client components. reactPluginPromise, + // Next.js/Turbopack ignores "very dynamic" requests with no static path + // part during graph analysis. Preserve that behaviour before the CJS + // plugin tries to expand require(dynamic) as a static glob. + 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/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts new file mode 100644 index 000000000..9c5b32abf --- /dev/null +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -0,0 +1,230 @@ +import path from "node:path"; +import MagicString from "magic-string"; +import type { Plugin } from "vite"; +import { parseAst } from "vite"; + +const MODULE_EXTENSIONS = new Set([".cjs", ".mjs", ".js", ".cts", ".mts", ".ts", ".jsx", ".tsx"]); +const DYNAMIC_REQUIRE_HELPER_BASE = "__vinext_ignored_dynamic_require__"; + +type AstRecord = { + type: string; + start?: number; + end?: number; + [key: string]: unknown; +}; + +type TransformResult = { + code: string; + map: ReturnType; +}; + +type ParserLang = "js" | "jsx" | "ts" | "tsx"; + +function getObjectProperty(value: unknown, key: string): unknown { + if (typeof value !== "object" || value === null) return null; + return Reflect.get(value, key); +} + +function isAstRecord(value: unknown): value is AstRecord { + return typeof getObjectProperty(value, "type") === "string"; +} + +function toAstRecord(value: unknown): AstRecord | null { + return isAstRecord(value) ? value : null; +} + +function astArray(value: unknown): AstRecord[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + const node = toAstRecord(entry); + return node ? [node] : []; + }); +} + +function hasRange(node: AstRecord | null): node is AstRecord & { start: number; end: number } { + return node !== null && typeof node.start === "number" && typeof node.end === "number"; +} + +function walkAst(value: unknown, visit: (node: AstRecord) => void): void { + if (Array.isArray(value)) { + for (const item of value) { + walkAst(item, visit); + } + return; + } + + const node = toAstRecord(value); + if (!node) return; + + visit(node); + + for (const [key, child] of Object.entries(node)) { + if (key === "parent") continue; + walkAst(child, visit); + } +} + +function isIdentifierNamed(node: AstRecord | null, name: string): boolean { + return node?.type === "Identifier" && node.name === name; +} + +function firstArgument(node: AstRecord): AstRecord | null { + return astArray(node.arguments)[0] ?? null; +} + +function templateElementHasStaticPart(node: AstRecord): boolean { + const raw = getObjectProperty(node.value, "raw"); + const cooked = getObjectProperty(node.value, "cooked"); + return ( + (typeof raw === "string" && raw.length > 0) || (typeof cooked === "string" && cooked.length > 0) + ); +} + +function requestHasStaticPart(node: AstRecord | null): boolean { + if (!node) return false; + + if (node.type === "Literal" || node.type === "StringLiteral") { + return typeof node.value === "string" && node.value.length > 0; + } + + if (node.type === "TemplateLiteral") { + return astArray(node.quasis).some(templateElementHasStaticPart); + } + + if ( + node.type === "BinaryExpression" || + node.type === "LogicalExpression" || + node.type === "ConditionalExpression" + ) { + return ( + requestHasStaticPart(toAstRecord(node.left)) || + requestHasStaticPart(toAstRecord(node.right)) || + requestHasStaticPart(toAstRecord(node.consequent)) || + requestHasStaticPart(toAstRecord(node.alternate)) + ); + } + + return false; +} + +function isVeryDynamicRequireCall(node: AstRecord): boolean { + if (node.type !== "CallExpression") return false; + if (!isIdentifierNamed(toAstRecord(node.callee), "require")) return false; + return !requestHasStaticPart(firstArgument(node)); +} + +function isVeryDynamicImportExpression(node: AstRecord): boolean { + if (node.type !== "ImportExpression") return false; + return !requestHasStaticPart(toAstRecord(node.source)); +} + +function directiveInsertionPoint(body: unknown): number { + let insertionPoint = 0; + + for (const node of astArray(body)) { + if (node.type !== "ExpressionStatement") break; + const expression = toAstRecord(node.expression); + if ( + expression?.type !== "Literal" || + typeof expression.value !== "string" || + typeof node.end !== "number" + ) { + break; + } + insertionPoint = node.end; + } + + return insertionPoint; +} + +function uniqueHelperName(code: string): string { + let helperName = DYNAMIC_REQUIRE_HELPER_BASE; + let suffix = 0; + + while (code.includes(helperName)) { + suffix += 1; + helperName = `${DYNAMIC_REQUIRE_HELPER_BASE}${suffix}`; + } + + return helperName; +} + +function cleanModuleId(id: string): string { + return id.split(/[?#]/, 1)[0] ?? id; +} + +function parserLangForId(id: string): ParserLang { + const extension = path.extname(cleanModuleId(id)); + if (extension === ".jsx") return "jsx"; + if (extension === ".ts" || extension === ".tsx" || extension === ".cts" || extension === ".mts") { + return extension === ".tsx" ? "tsx" : "ts"; + } + return "js"; +} + +function isTransformableModuleId(id: string): boolean { + const cleanId = cleanModuleId(id); + if (cleanId.includes("/node_modules/")) return false; + return MODULE_EXTENSIONS.has(path.extname(cleanId)); +} + +function ignoreVeryDynamicRequests(code: string, id: string): TransformResult | null { + if (!/\b(?:import|require)\s*\(/.test(code)) return null; + + let ast: ReturnType; + try { + ast = parseAst(code, { lang: parserLangForId(id) }, cleanModuleId(id)); + } catch { + return null; + } + + const output = new MagicString(code); + const helperName = uniqueHelperName(code); + let rewroteRequire = false; + let changed = false; + + walkAst(ast.body, (node) => { + if (isVeryDynamicRequireCall(node)) { + const callee = toAstRecord(node.callee); + if (!hasRange(callee)) return; + output.overwrite(callee.start, callee.end, helperName); + rewroteRequire = true; + changed = true; + return; + } + + if (isVeryDynamicImportExpression(node)) { + const source = toAstRecord(node.source); + if (!hasRange(source)) return; + if (code.slice(node.start ?? 0, source.start).includes("@vite-ignore")) return; + output.appendLeft(source.start, "/* @vite-ignore */ "); + changed = true; + } + }); + + if (!changed) return null; + + if (rewroteRequire) { + const insertionPoint = directiveInsertionPoint(ast.body); + output.appendRight( + insertionPoint, + `\nfunction ${helperName}(id) {\n throw new Error("Cannot find module " + String(id));\n}\n`, + ); + } + + return { + code: output.toString(), + map: output.generateMap({ hires: true, source: id }), + }; +} + +export function createIgnoreDynamicRequestsPlugin(): Plugin { + return { + name: "vinext:ignore-dynamic-requests", + enforce: "pre", + transform(code, id) { + if (!isTransformableModuleId(id)) return null; + return ignoreVeryDynamicRequests(code, id); + }, + }; +} diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index b953f7b2e..96dd2e76e 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -2137,6 +2137,111 @@ 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); +}); + // ─── getClientTreeshakeConfigForVite ────────────────────────────────────────── describe("getClientTreeshakeConfigForVite", () => { From 403d3ec983f221c9f4b234966bfcc207e3b21697 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:36:54 +1000 Subject: [PATCH 02/32] fix(plugins/ignore-dynamic-requests): tighten semantics for dynamic request rewrite - Parse .js/.mjs/.cjs as JSX so App Router files with JSX are not silently skipped before the JSX-in-JS transform runs. - Track lexical bindings and only rewrite unbound/CommonJS-style require(...). Skips rewriting when require is declared as a local variable, function, parameter, import specifier, or catch binding. - Unwrap TypeScript-transparent expression nodes (TSAsExpression, TSTypeAssertion, TSNonNullExpression, TSInstantiationExpression, ParenthesizedExpression, ChainExpression) before classifying static parts, ensuring expressions like require(('./' + name) as string) keep flowing through existing analysis. - Add focused unit and integration tests for the three blocking cases. --- .../src/plugins/ignore-dynamic-requests.ts | 194 +++++++++++++--- tests/build-optimization.test.ts | 214 ++++++++++++++++++ 2 files changed, 373 insertions(+), 35 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 9c5b32abf..019ff1f59 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -45,29 +45,18 @@ function hasRange(node: AstRecord | null): node is AstRecord & { start: number; return node !== null && typeof node.start === "number" && typeof node.end === "number"; } -function walkAst(value: unknown, visit: (node: AstRecord) => void): void { - if (Array.isArray(value)) { - for (const item of value) { - walkAst(item, visit); - } - return; - } - - const node = toAstRecord(value); - if (!node) return; - - visit(node); - - for (const [key, child] of Object.entries(node)) { - if (key === "parent") continue; - walkAst(child, visit); - } -} - function isIdentifierNamed(node: AstRecord | null, name: string): boolean { return node?.type === "Identifier" && node.name === name; } +function getIdentifierName(value: unknown): string | null { + const rec = toAstRecord(value); + if (rec?.type === "Identifier" && typeof rec.name === "string") { + return rec.name; + } + return null; +} + function firstArgument(node: AstRecord): AstRecord | null { return astArray(node.arguments)[0] ?? null; } @@ -80,36 +69,165 @@ function templateElementHasStaticPart(node: AstRecord): boolean { ); } +function unwrapTransparentExpression(node: AstRecord | null): AstRecord | null { + if (!node) return null; + switch (node.type) { + case "TSAsExpression": + case "TSTypeAssertion": + case "TSNonNullExpression": + case "TSInstantiationExpression": + case "ParenthesizedExpression": + case "ChainExpression": + return unwrapTransparentExpression(toAstRecord(node.expression)); + default: + return node; + } +} + function requestHasStaticPart(node: AstRecord | null): boolean { - if (!node) return false; + const unwrapped = unwrapTransparentExpression(node); + if (!unwrapped) return false; - if (node.type === "Literal" || node.type === "StringLiteral") { - return typeof node.value === "string" && node.value.length > 0; + if (unwrapped.type === "Literal" || unwrapped.type === "StringLiteral") { + return typeof unwrapped.value === "string" && unwrapped.value.length > 0; } - if (node.type === "TemplateLiteral") { - return astArray(node.quasis).some(templateElementHasStaticPart); + if (unwrapped.type === "TemplateLiteral") { + return astArray(unwrapped.quasis).some(templateElementHasStaticPart); } if ( - node.type === "BinaryExpression" || - node.type === "LogicalExpression" || - node.type === "ConditionalExpression" + unwrapped.type === "BinaryExpression" || + unwrapped.type === "LogicalExpression" || + unwrapped.type === "ConditionalExpression" ) { return ( - requestHasStaticPart(toAstRecord(node.left)) || - requestHasStaticPart(toAstRecord(node.right)) || - requestHasStaticPart(toAstRecord(node.consequent)) || - requestHasStaticPart(toAstRecord(node.alternate)) + requestHasStaticPart(toAstRecord(unwrapped.left)) || + requestHasStaticPart(toAstRecord(unwrapped.right)) || + requestHasStaticPart(toAstRecord(unwrapped.consequent)) || + requestHasStaticPart(toAstRecord(unwrapped.alternate)) ); } return false; } -function isVeryDynamicRequireCall(node: AstRecord): boolean { +function addBindings(node: unknown, set: Set): void { + const rec = toAstRecord(node); + if (!rec) return; + + if (rec.type === "Identifier" && typeof rec.name === "string") { + set.add(rec.name); + } else if (rec.type === "ObjectPattern") { + for (const prop of astArray(rec.properties)) { + addBindings(prop, set); + } + } else if (rec.type === "ArrayPattern") { + for (const element of astArray(rec.elements)) { + addBindings(element, set); + } + } else if (rec.type === "RestElement") { + addBindings(rec.argument, set); + } else if (rec.type === "Property") { + addBindings(rec.value, set); + } else if (rec.type === "AssignmentPattern") { + addBindings(rec.left, set); + } +} + +function walkAstWithBindings( + value: unknown, + visit: (node: AstRecord, isRequireBound: () => boolean) => void, +): void { + const scopeStack: Set[] = [new Set()]; + + function isRequireBound(): boolean { + for (const scope of scopeStack) { + if (scope.has("require")) return true; + } + return false; + } + + function walk(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) walk(item); + return; + } + + const node = toAstRecord(value); + if (!node) return; + + let pushed = false; + + if ( + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression" + ) { + const functionIdName = getIdentifierName(node.id); + if (node.type === "FunctionDeclaration" && functionIdName !== null) { + if (scopeStack.length > 0) { + scopeStack[scopeStack.length - 1].add(functionIdName); + } + } + + scopeStack.push(new Set()); + pushed = true; + + for (const param of astArray(node.params)) { + addBindings(param, scopeStack[scopeStack.length - 1]); + } + + if ( + (node.type === "FunctionExpression" || node.type === "FunctionDeclaration") && + functionIdName !== null + ) { + scopeStack[scopeStack.length - 1].add(functionIdName); + } + } + + if (node.type === "CatchClause") { + scopeStack.push(new Set()); + pushed = true; + if (node.param) { + addBindings(node.param, scopeStack[scopeStack.length - 1]); + } + } + + if (node.type === "VariableDeclarator") { + addBindings(node.id, scopeStack[scopeStack.length - 1]); + } + + if ( + node.type === "ImportSpecifier" || + node.type === "ImportDefaultSpecifier" || + node.type === "ImportNamespaceSpecifier" + ) { + const localName = getIdentifierName(node.local); + if (localName !== null) { + scopeStack[scopeStack.length - 1].add(localName); + } + } + + visit(node, isRequireBound); + + for (const [key, child] of Object.entries(node)) { + if (key === "parent") continue; + walk(child); + } + + if (pushed) { + scopeStack.pop(); + } + } + + walk(value); +} + +function isVeryDynamicRequireCall(node: AstRecord, isRequireBound: () => boolean): boolean { if (node.type !== "CallExpression") return false; if (!isIdentifierNamed(toAstRecord(node.callee), "require")) return false; + if (isRequireBound()) return false; return !requestHasStaticPart(firstArgument(node)); } @@ -159,6 +277,12 @@ function parserLangForId(id: string): ParserLang { if (extension === ".ts" || extension === ".tsx" || extension === ".cts" || extension === ".mts") { return extension === ".tsx" ? "tsx" : "ts"; } + // 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. + if (extension === ".js" || extension === ".mjs" || extension === ".cjs") { + return "jsx"; + } return "js"; } @@ -168,7 +292,7 @@ function isTransformableModuleId(id: string): boolean { return MODULE_EXTENSIONS.has(path.extname(cleanId)); } -function ignoreVeryDynamicRequests(code: string, id: string): TransformResult | null { +export function ignoreVeryDynamicRequests(code: string, id: string): TransformResult | null { if (!/\b(?:import|require)\s*\(/.test(code)) return null; let ast: ReturnType; @@ -183,8 +307,8 @@ function ignoreVeryDynamicRequests(code: string, id: string): TransformResult | let rewroteRequire = false; let changed = false; - walkAst(ast.body, (node) => { - if (isVeryDynamicRequireCall(node)) { + walkAstWithBindings(ast.body, (node, isRequireBound) => { + if (isVeryDynamicRequireCall(node, isRequireBound)) { const callee = toAstRecord(node.callee); if (!hasRange(callee)) return; output.overwrite(callee.start, callee.end, helperName); diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 96dd2e76e..39a7b6aa4 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -19,6 +19,7 @@ import { } from "../packages/vinext/src/build/client-build-config.js"; import { computeLazyChunks } from "../packages/vinext/src/utils/lazy-chunks.js"; import { asyncHooksStubPlugin as _asyncHooksStubPlugin } from "../packages/vinext/src/plugins/async-hooks-stub.js"; +import { ignoreVeryDynamicRequests as _ignoreVeryDynamicRequests } 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; @@ -2240,6 +2241,219 @@ ${guardedDynamicRequest}`, 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", () => { + it("rewrites unbound require(dynamic) to runtime helper", () => { + const code = `require(id);`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + }); + + 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(); + // Only the first require(id) in the try block should be rewritten + expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + expect(result!.code).not.toContain("__vinext_ignored_dynamic_require__(id)"); + }); + + 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(('./' + 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("correctly classifies require(path!) as very dynamic after unwrapping", () => { + const code = `require(path!);`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + }); + + it("adds @vite-ignore to very dynamic import", () => { + const code = `import(id);`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expect(result!.code).toContain("/* @vite-ignore */"); + }); + + it("adds @vite-ignore to very dynamic import through TS wrappers", () => { + const code = `import((id) as string);`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expect(result!.code).toContain("/* @vite-ignore */"); + }); + + 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(); + expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + expect(result!.code).toContain("/* @vite-ignore */"); + }); }); // ─── getClientTreeshakeConfigForVite ────────────────────────────────────────── From 9d8e542e0fb62ff6e674e60205407f80922f3971 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:46:49 +1000 Subject: [PATCH 03/32] chore: trigger ci From 13b6589efcd9106ba04f78d84f101d1c70046e39 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:48:13 +1000 Subject: [PATCH 04/32] test(build-optimization): fix catch-binding assertion for helper name collision --- tests/build-optimization.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 39a7b6aa4..9505a5fbb 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -2388,9 +2388,9 @@ try { `; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); expect(result).not.toBeNull(); - // Only the first require(id) in the try block should be rewritten + // The try-block require is rewritten to the helper; catch-block require stays untouched expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); - expect(result!.code).not.toContain("__vinext_ignored_dynamic_require__(id)"); + expect(result!.code).toContain("} catch (require) {\n require(id);"); }); it("leaves require('./' + name) untouched for existing analysis", () => { From 7006b697bfb495731c3ba5abbe36b81614d3a0c1 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:21:17 +1000 Subject: [PATCH 05/32] fix(scope-analysis): two-phase hoisted declaration collection for require shadowing Rewrites walkAstWithBindings as a proper two-phase scope pass: 1. Collect hoisted declarations (FunctionDeclaration, var, imports) before visiting any expressions in a scope body. 2. Walk statements and transform expressions with the full binding context already in place. This fixes two JavaScript-correctness issues: - Function declarations are hoisted, so local require is now visible throughout its enclosing scope. - Block-scoped let/const/class bindings push their own scope frames (BlockStatement, CatchClause, SwitchCase), so they never leak out and poison the parent scope. Adds tests for hoisted function declaration named require and block-scoped require not leaking to outer scope. Loosens the fast-path regex to handle comments between the callee and the paren. --- .../src/plugins/ignore-dynamic-requests.ts | 187 ++++++++++++++---- tests/build-optimization.test.ts | 28 +++ 2 files changed, 173 insertions(+), 42 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 019ff1f59..02fe7b007 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -148,80 +148,183 @@ function walkAstWithBindings( return false; } - function walk(value: unknown): void { - if (Array.isArray(value)) { - for (const item of value) walk(item); - return; + // Scan a subtree for hoisted declarations (function declarations, var + // declarations, import specifiers) and add them to the target scope. + // Does NOT recurse into nested functions because their declarations are + // hoisted to themselves, not the outer scope. + function collectHoistedDeclarations(body: AstRecord[], targetScope: Set): void { + for (const node of body) { + collectHoistedDeclarationsFromNode(node, targetScope); } + } - const node = toAstRecord(value); + function collectHoistedDeclarationsFromNode(node: AstRecord, targetScope: Set): void { if (!node) return; - let pushed = false; + if (node.type === "FunctionDeclaration") { + addBindings(node.id, targetScope); + return; + } - if ( - node.type === "FunctionDeclaration" || - node.type === "FunctionExpression" || - node.type === "ArrowFunctionExpression" - ) { - const functionIdName = getIdentifierName(node.id); - if (node.type === "FunctionDeclaration" && functionIdName !== null) { - if (scopeStack.length > 0) { - scopeStack[scopeStack.length - 1].add(functionIdName); - } + if (node.type === "VariableDeclaration" && node.kind === "var") { + for (const decl of astArray(node.declarations)) { + addBindings(toAstRecord(decl.id), targetScope); } + return; + } - scopeStack.push(new Set()); - pushed = true; - - for (const param of astArray(node.params)) { - addBindings(param, scopeStack[scopeStack.length - 1]); + if (node.type === "ImportDeclaration") { + for (const spec of astArray(node.specifiers)) { + const localName = getIdentifierName(toAstRecord(spec.local)); + if (localName) targetScope.add(localName); } + return; + } - if ( - (node.type === "FunctionExpression" || node.type === "FunctionDeclaration") && - functionIdName !== null - ) { - scopeStack[scopeStack.length - 1].add(functionIdName); + // Recurse into non-function children + for (const [key, child] of Object.entries(node)) { + if (key === "parent") continue; + const childRec = toAstRecord(child); + if (childRec) { + if ( + childRec.type === "FunctionDeclaration" || + childRec.type === "FunctionExpression" || + childRec.type === "ArrowFunctionExpression" + ) { + continue; + } + collectHoistedDeclarationsFromNode(childRec, targetScope); + } else if (Array.isArray(child)) { + for (const item of child) { + const itemRec = toAstRecord(item); + if (itemRec) { + if ( + itemRec.type === "FunctionDeclaration" || + itemRec.type === "FunctionExpression" || + itemRec.type === "ArrowFunctionExpression" + ) { + continue; + } + collectHoistedDeclarationsFromNode(itemRec, targetScope); + } + } } } + } - if (node.type === "CatchClause") { - scopeStack.push(new Set()); - pushed = true; - if (node.param) { - addBindings(node.param, scopeStack[scopeStack.length - 1]); - } + function walkBody(body: AstRecord[], isFunctionBody: boolean): void { + if (isFunctionBody) { + collectHoistedDeclarations(body, scopeStack[scopeStack.length - 1]); + } + for (const node of body) { + walkNode(node); } + } + function walkNode(node: AstRecord): void { + if (!node) return; + + // let / const / class declarations are block-scoped and NOT hoisted, + // so add them to the current scope as we encounter them. if (node.type === "VariableDeclarator") { addBindings(node.id, scopeStack[scopeStack.length - 1]); } + if (node.type === "ClassDeclaration") { + addBindings(node.id, scopeStack[scopeStack.length - 1]); + } + if ( node.type === "ImportSpecifier" || node.type === "ImportDefaultSpecifier" || node.type === "ImportNamespaceSpecifier" ) { const localName = getIdentifierName(node.local); - if (localName !== null) { - scopeStack[scopeStack.length - 1].add(localName); - } + if (localName) scopeStack[scopeStack.length - 1].add(localName); } visit(node, isRequireBound); - for (const [key, child] of Object.entries(node)) { - if (key === "parent") continue; - walk(child); + const isFunctionLike = + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression"; + + if (isFunctionLike) { + scopeStack.push(new Set()); + + // Named function expressions bind their name only in their own scope + if (node.type === "FunctionExpression") { + addBindings(node.id, scopeStack[scopeStack.length - 1]); + } + + for (const param of astArray(node.params)) { + addBindings(param, scopeStack[scopeStack.length - 1]); + } + + if (node.body) { + const bodyRec = toAstRecord(node.body); + if (bodyRec?.type === "BlockStatement") { + walkBody(astArray(bodyRec.body), true); + } else if (bodyRec) { + walkNode(bodyRec); + } + } + + scopeStack.pop(); + return; + } + + if (node.type === "BlockStatement") { + scopeStack.push(new Set()); + walkBody(astArray(node.body), false); + scopeStack.pop(); + return; } - if (pushed) { + if (node.type === "CatchClause") { + scopeStack.push(new Set()); + if (node.param) { + addBindings(node.param, scopeStack[scopeStack.length - 1]); + } + const bodyRec = toAstRecord(node.body); + if (bodyRec) { + walkNode(bodyRec); + } scopeStack.pop(); + return; + } + + if (node.type === "SwitchCase") { + scopeStack.push(new Set()); + for (const stmt of astArray(node.consequent)) { + walkNode(stmt); + } + scopeStack.pop(); + return; + } + + // Generic recursion for non-scope nodes + for (const [key, child] of Object.entries(node)) { + if (key === "parent" || key === "start" || key === "end") continue; + const childRec = toAstRecord(child); + if (childRec) { + walkNode(childRec); + } else if (Array.isArray(child)) { + for (const item of child) { + const itemRec = toAstRecord(item); + if (itemRec) walkNode(itemRec); + } + } } } - walk(value); + const root = toAstRecord(value); + if (root?.type === "Program") { + walkBody(astArray(root.body), true); + } else if (Array.isArray(value)) { + walkBody(value as AstRecord[], false); + } } function isVeryDynamicRequireCall(node: AstRecord, isRequireBound: () => boolean): boolean { @@ -293,7 +396,7 @@ function isTransformableModuleId(id: string): boolean { } export function ignoreVeryDynamicRequests(code: string, id: string): TransformResult | null { - if (!/\b(?:import|require)\s*\(/.test(code)) return null; + if (!/\b(?:import|require)\b/.test(code)) return null; let ast: ReturnType; try { @@ -307,7 +410,7 @@ export function ignoreVeryDynamicRequests(code: string, id: string): TransformRe let rewroteRequire = false; let changed = false; - walkAstWithBindings(ast.body, (node, isRequireBound) => { + walkAstWithBindings(ast, (node, isRequireBound) => { if (isVeryDynamicRequireCall(node, isRequireBound)) { const callee = toAstRecord(node.callee); if (!hasRange(callee)) return; diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 9505a5fbb..300a572da 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -2393,6 +2393,34 @@ try { 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(); + expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + }); + it("leaves require('./' + name) untouched for existing analysis", () => { const code = `require('./' + name);`; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); From 4810c73566cf0e494628e521e669299d31551fa6 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:37:23 +1000 Subject: [PATCH 06/32] fix(scope-analysis): correct block-scoped let/const shadowing for TDZ and exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous two-phase pre-collection passed 'block' scope type when descending into nested blocks from module/function scope. This caused two bugs: 1. TDZ false negative: a 'const require' at the top level of a function body was not collected, so a 'require(id)' before the declaration was incorrectly rewritten even though the function-scope const shadows the global. Fix: for function scope, collect all var kinds (let, const, var) at the top level of the function body — the function body itself is the enclosing block, so let/const there ARE function-scoped. 2. Block shadow leak: a 'const require' inside an 'if' or other nested block was being promoted into the enclosing module/function scope via the block-scope descent, so a 'require(id)' outside the block was incorrectly skipped. Fix: when descending from module/function scope into nested blocks, only collect 'var' (the only kind that hoists out of a block into the enclosing function/module scope). The new logic splits the two concerns: - collectDeclarationsForScope handles the top level of a scope and delegates to collectVarDeclarationsFromNestedBlocks when descending into blocks from module/function scope. - collectVarDeclarationsFromNestedBlocks walks into blocks looking ONLY for 'var' declarations (stopping at nested function boundaries). Also adds three regression tests: - const binding in function body shadows global require (TDZ) - exported function declaration named require - exported const binding named require --- .../src/plugins/ignore-dynamic-requests.ts | 206 ++++++++++++------ tests/build-optimization.test.ts | 34 +++ 2 files changed, 171 insertions(+), 69 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 02fe7b007..97b797503 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -148,21 +148,29 @@ function walkAstWithBindings( return false; } - // Scan a subtree for hoisted declarations (function declarations, var - // declarations, import specifiers) and add them to the target scope. - // Does NOT recurse into nested functions because their declarations are - // hoisted to themselves, not the outer scope. - function collectHoistedDeclarations(body: AstRecord[], targetScope: Set): void { - for (const node of body) { - collectHoistedDeclarationsFromNode(node, targetScope); - } - } - - function collectHoistedDeclarationsFromNode(node: AstRecord, targetScope: Set): void { + /** + * 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 — `let`, `const`, `class`, and function + * declarations remain block-scoped. + */ + function collectVarDeclarationsFromNestedBlocks(node: AstRecord, targetScope: Set): void { if (!node) return; - if (node.type === "FunctionDeclaration") { - addBindings(node.id, targetScope); + if ( + node.type === "FunctionDeclaration" || + node.type === "FunctionExpression" || + node.type === "ArrowFunctionExpression" + ) { + return; + } + + if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") { + const declaration = toAstRecord(node.declaration); + if (declaration) { + collectVarDeclarationsFromNestedBlocks(declaration, targetScope); + } return; } @@ -173,7 +181,72 @@ function walkAstWithBindings( return; } - if (node.type === "ImportDeclaration") { + const recurseInto = new Set([ + "BlockStatement", + "IfStatement", + "ForStatement", + "ForInStatement", + "ForOfStatement", + "WhileStatement", + "DoWhileStatement", + "WithStatement", + "TryStatement", + "SwitchStatement", + "SwitchCase", + ]); + + if (recurseInto.has(node.type)) { + for (const [key, child] of Object.entries(node)) { + if (key === "parent") continue; + const childRec = toAstRecord(child); + if (childRec) { + collectVarDeclarationsFromNestedBlocks(childRec, targetScope); + } else if (Array.isArray(child)) { + for (const item of child) { + const itemRec = toAstRecord(item); + if (itemRec) { + collectVarDeclarationsFromNestedBlocks(itemRec, 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, var, let, const, class, import) + * declared at the top level of the module, plus `var` + * hoisted from nested blocks. + * - "function": everything (function, var, let, const, class) + * declared at the top level of the function body, plus + * `var` hoisted from nested blocks. + * - "block": block-scoped declarations (let, const, 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 scope and collected by the + * function-level pre-pass. + */ + function collectDeclarationsForScope( + node: AstRecord, + scopeType: "module" | "function" | "block", + targetScope: Set, + ): void { + if (!node) return; + + if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") { + const declaration = toAstRecord(node.declaration); + if (declaration) { + collectDeclarationsForScope(declaration, scopeType, targetScope); + } + return; + } + + if (node.type === "ImportDeclaration" && scopeType === "module") { for (const spec of astArray(node.specifiers)) { const localName = getIdentifierName(toAstRecord(spec.local)); if (localName) targetScope.add(localName); @@ -181,40 +254,54 @@ function walkAstWithBindings( return; } - // Recurse into non-function children - for (const [key, child] of Object.entries(node)) { - if (key === "parent") continue; - const childRec = toAstRecord(child); - if (childRec) { - if ( - childRec.type === "FunctionDeclaration" || - childRec.type === "FunctionExpression" || - childRec.type === "ArrowFunctionExpression" - ) { - continue; + if (node.type === "ClassDeclaration" && node.id) { + addBindings(node.id, targetScope); + return; + } + + if (node.type === "FunctionDeclaration" && node.id) { + addBindings(node.id, targetScope); + return; + } + + if (node.type === "VariableDeclaration") { + if (scopeType === "module" || scopeType === "function") { + for (const decl of astArray(node.declarations)) { + addBindings(toAstRecord(decl.id), targetScope); } - collectHoistedDeclarationsFromNode(childRec, targetScope); - } else if (Array.isArray(child)) { - for (const item of child) { - const itemRec = toAstRecord(item); - if (itemRec) { - if ( - itemRec.type === "FunctionDeclaration" || - itemRec.type === "FunctionExpression" || - itemRec.type === "ArrowFunctionExpression" - ) { - continue; - } - collectHoistedDeclarationsFromNode(itemRec, targetScope); - } + } else if (scopeType === "block" && (node.kind === "let" || node.kind === "const")) { + for (const decl of astArray(node.declarations)) { + addBindings(toAstRecord(decl.id), targetScope); } } + return; + } + + if (scopeType === "module" || scopeType === "function") { + const recurseInto = new Set([ + "BlockStatement", + "IfStatement", + "ForStatement", + "ForInStatement", + "ForOfStatement", + "WhileStatement", + "DoWhileStatement", + "WithStatement", + "TryStatement", + "SwitchStatement", + "SwitchCase", + ]); + + if (recurseInto.has(node.type)) { + collectVarDeclarationsFromNestedBlocks(node, targetScope); + } } } - function walkBody(body: AstRecord[], isFunctionBody: boolean): void { - if (isFunctionBody) { - collectHoistedDeclarations(body, scopeStack[scopeStack.length - 1]); + function walkBody(body: AstRecord[], scopeType: "module" | "function" | "block"): void { + const currentScope = scopeStack[scopeStack.length - 1]; + for (const node of body) { + collectDeclarationsForScope(node, scopeType, currentScope); } for (const node of body) { walkNode(node); @@ -224,25 +311,6 @@ function walkAstWithBindings( function walkNode(node: AstRecord): void { if (!node) return; - // let / const / class declarations are block-scoped and NOT hoisted, - // so add them to the current scope as we encounter them. - if (node.type === "VariableDeclarator") { - addBindings(node.id, scopeStack[scopeStack.length - 1]); - } - - if (node.type === "ClassDeclaration") { - addBindings(node.id, scopeStack[scopeStack.length - 1]); - } - - if ( - node.type === "ImportSpecifier" || - node.type === "ImportDefaultSpecifier" || - node.type === "ImportNamespaceSpecifier" - ) { - const localName = getIdentifierName(node.local); - if (localName) scopeStack[scopeStack.length - 1].add(localName); - } - visit(node, isRequireBound); const isFunctionLike = @@ -265,7 +333,7 @@ function walkAstWithBindings( if (node.body) { const bodyRec = toAstRecord(node.body); if (bodyRec?.type === "BlockStatement") { - walkBody(astArray(bodyRec.body), true); + walkBody(astArray(bodyRec.body), "function"); } else if (bodyRec) { walkNode(bodyRec); } @@ -277,7 +345,7 @@ function walkAstWithBindings( if (node.type === "BlockStatement") { scopeStack.push(new Set()); - walkBody(astArray(node.body), false); + walkBody(astArray(node.body), "block"); scopeStack.pop(); return; } @@ -288,7 +356,9 @@ function walkAstWithBindings( addBindings(node.param, scopeStack[scopeStack.length - 1]); } const bodyRec = toAstRecord(node.body); - if (bodyRec) { + if (bodyRec?.type === "BlockStatement") { + walkBody(astArray(bodyRec.body), "block"); + } else if (bodyRec) { walkNode(bodyRec); } scopeStack.pop(); @@ -297,9 +367,7 @@ function walkAstWithBindings( if (node.type === "SwitchCase") { scopeStack.push(new Set()); - for (const stmt of astArray(node.consequent)) { - walkNode(stmt); - } + walkBody(astArray(node.consequent), "block"); scopeStack.pop(); return; } @@ -321,9 +389,9 @@ function walkAstWithBindings( const root = toAstRecord(value); if (root?.type === "Program") { - walkBody(astArray(root.body), true); + walkBody(astArray(root.body), "module"); } else if (Array.isArray(value)) { - walkBody(value as AstRecord[], false); + walkBody(value as AstRecord[], "block"); } } diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 300a572da..88a807b05 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -2421,6 +2421,40 @@ require(id); expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); }); + 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("leaves require('./' + name) untouched for existing analysis", () => { const code = `require('./' + name);`; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); From 7d4b4a282f686cf92eb1db67fcbff407d6412966 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:53:13 +1000 Subject: [PATCH 07/32] fix(scope-analysis): model for-loop lexical header and switch-shared lexical scope Two scope-analysis gaps surfaced by review: 1. ForStatement / ForInStatement / ForOfStatement with a let/const header introduced a per-iteration lexical scope that covered init/test/update/body (or left/right/body) but was not modeled. A 'const require' in a 'for (...)' header was therefore invisible inside the loop body, so a 'require(id)' in the body was incorrectly rewritten even though it resolved to the loop binding. Fix: when the header is a VariableDeclaration with kind 'let' or 'const', push a new scope, pre-collect those bindings (including destructuring patterns via addBindings), and walk init/test/update/ body under that scope. 'var' headers remain function-scoped and are already collected by the enclosing function-scope pre-pass, so no extra scope is pushed for them. 2. SwitchStatement cases share the lexical scope of the SwitchStatement itself unless the user wraps a case body in explicit braces. The walker previously created a fresh scope per SwitchCase, so a 'const require' in a later case did not shadow a 'require(id)' in an earlier case (the earlier case saw an empty scope and the call was rewritten). Fix: SwitchStatement now pushes one shared scope, pre-collects 'let'/'const'/'class'/'function' from every case consequent at its top level, and walks each case's consequent under that same shared scope. The standalone SwitchCase handler is retained as a defensive fallback that walks the consequent without pushing a scope, so a stray SwitchCase (parser edge case) cannot double-scope. Adds the three suggested regression tests: - for-of lexical binding shadows global require - for-initializer lexical binding shadows global require - switch-wide lexical binding shadows require in a prior case --- .../src/plugins/ignore-dynamic-requests.ts | 99 ++++++++++++++++++- tests/build-optimization.test.ts | 36 +++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 97b797503..5c11b6442 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -365,13 +365,108 @@ function walkAstWithBindings( return; } - if (node.type === "SwitchCase") { + // `for` / `for-in` / `for-of` with a `let`/`const` 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" && (init.kind === "let" || init.kind === "const"); + + if (hasLexicalHeader) { + scopeStack.push(new Set()); + for (const decl of astArray(init.declarations)) { + addBindings(toAstRecord(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" && (left.kind === "let" || left.kind === "const"); + + if (hasLexicalHeader) { + scopeStack.push(new Set()); + for (const decl of astArray(left.declarations)) { + addBindings(toAstRecord(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 + // `let`/`const`/`class`/`function` from every case consequent. + if (node.type === "SwitchStatement") { scopeStack.push(new Set()); - walkBody(astArray(node.consequent), "block"); + const switchScope = scopeStack[scopeStack.length - 1]; + + for (const caseNode of astArray(node.cases)) { + for (const stmt of astArray(caseNode.consequent)) { + if ( + stmt.type === "VariableDeclaration" && + (stmt.kind === "let" || stmt.kind === "const") + ) { + for (const decl of astArray(stmt.declarations)) { + addBindings(toAstRecord(decl.id), switchScope); + } + } else if (stmt.type === "ClassDeclaration" && stmt.id) { + addBindings(stmt.id, switchScope); + } else if (stmt.type === "FunctionDeclaration" && stmt.id) { + addBindings(stmt.id, switchScope); + } + } + } + + const discriminant = toAstRecord(node.discriminant); + if (discriminant) walkNode(discriminant); + + 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; + } + // Generic recursion for non-scope nodes for (const [key, child] of Object.entries(node)) { if (key === "parent" || key === "start" || key === "end") continue; diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 88a807b05..6381c52d4 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -2455,6 +2455,42 @@ export const require = (id: string) => ({ id }); 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("leaves require('./' + name) untouched for existing analysis", () => { const code = `require('./' + name);`; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); From 4b893bf85c20169a0de6190f3b03a09d9661d9c1 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:04:31 +1000 Subject: [PATCH 08/32] fix(scope-analysis): walk function parameter defaults and model class static block scope Two traversal gaps surfaced by review: 1. Function parameter default RHS expressions were never walked. 'addBindings' intentionally records binding names but does not traverse default-value expressions, so an executable 'require(dynamic)' or 'import(dynamic)' hidden in a parameter default was invisible to the pre-transform. That left the original build-analysis failure mode alive in code like: function load(x = require(id)) { return x; } Fix: after collecting parameter bindings into the function scope, walk each parameter node. Because the bindings are already in scope, a default like 'function f(require = require(id))' is correctly skipped (the parameter binding named 'require' is the resolved binding for the RHS call), while 'function load(x = require(id))' is rewritten normally. 2. Class static blocks ('static { ... }') introduce their own lexical scope. The generic walker previously treated them as ordinary object recursion, so a 'const require' inside a static block did not shadow an earlier 'require(id)' in the same block (the earlier call was rewritten instead of resolving to the local binding via TDZ). Fix: handle 'StaticBlock' as a block scope. Push a new scope, pre-collect 'let'/'const'/'class'/'function' from the static block body, walk the body, and pop. 'var' declarations in static blocks still hoist to the enclosing function scope and are collected by the function-scope pre-pass. Adds the suggested regression tests: - unbound require(dynamic) in a function parameter default is rewritten - unbound import(dynamic) in a function parameter default gets @vite-ignore - parameter default that resolves to a parameter named require is NOT rewritten - require shadowed by a class static block lexical binding is NOT rewritten --- .../src/plugins/ignore-dynamic-requests.ts | 27 +++++++++++ tests/build-optimization.test.ts | 45 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 5c11b6442..e0eaccbf5 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -330,6 +330,18 @@ function walkAstWithBindings( addBindings(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. `addBindings` only + // records binding names, so default-value expressions need to be + // walked explicitly. + for (const param of astArray(node.params)) { + walkNode(param); + } + if (node.body) { const bodyRec = toAstRecord(node.body); if (bodyRec?.type === "BlockStatement") { @@ -461,12 +473,27 @@ function walkAstWithBindings( // 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") { + // 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. for (const stmt of astArray(node.consequent)) { walkNode(stmt); } return; } + // Class static blocks (`static { ... }`) introduce their own lexical + // scope. `let`/`const`/`class`/`function` declared inside are scoped + // to the block and must not leak into the enclosing class or module + // scope. `var` declarations still hoist to the enclosing function + // scope and are collected by the function-scope pre-pass. + if (node.type === "StaticBlock") { + scopeStack.push(new Set()); + walkBody(astArray(node.body), "block"); + scopeStack.pop(); + return; + } + // Generic recursion for non-scope nodes for (const [key, child] of Object.entries(node)) { if (key === "parent" || key === "start" || key === "end") continue; diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 6381c52d4..e70908d60 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -2491,6 +2491,51 @@ switch (kind) { 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(); + expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + }); + + it("adds @vite-ignore to unbound import(dynamic) in a function parameter default", () => { + const code = ` +function load(x = import(id)) { + return x; +} +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expect(result!.code).toContain("/* @vite-ignore */"); + }); + + 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("leaves require('./' + name) untouched for existing analysis", () => { const code = `require('./' + name);`; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); From 5e460b0cce436ad1412d432731e430937d74f382 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:11:34 +1000 Subject: [PATCH 09/32] fix(scope-analysis): add distinct static-block scope type for var semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class static blocks ('static { ... }') have var-as-static-block semantics: a 'var' declared inside a static block is scoped to the static block itself, not hoisted to the enclosing function/module. This differs from ordinary 'var', which is function-scoped. The previous 'StaticBlock' handling reused the 'block' scope type, which only pre-collects 'let'/'const' (not 'var'). That meant a 'var require' inside a static block was invisible to the static block scope, so an earlier 'require(id)' in the same static block was incorrectly rewritten even though it resolves to the local 'var require' binding (TDZ/undefined at runtime, not global CommonJS require). Fix: introduce a distinct 'static-block' scope type. It behaves like 'function' for collection purposes — collects 'var'/'let'/ 'const'/'class'/'function' at the top level and hoists 'var' from nested control-flow blocks — but the name makes the intent clear and keeps the two cases from being conflated. The function/module scope pre-pass is unaffected: 'var' inside a static block stays in the static block scope and never leaks to the enclosing function/module scope, because the function-scope pre-pass already does not recurse into class bodies or static blocks. Adds the suggested regression tests: - 'var require' inside a static block shadows an earlier 'require(id)' in the same static block - 'var require' inside a static block does NOT leak to an outer 'require(id)' at the module level --- .../src/plugins/ignore-dynamic-requests.ts | 50 +++++++++++-------- tests/build-optimization.test.ts | 30 +++++++++++ 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index e0eaccbf5..51507be6a 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -219,21 +219,26 @@ function walkAstWithBindings( * both visible for the entire scope. * * Scope types: - * - "module": everything (function, var, let, const, class, import) - * declared at the top level of the module, plus `var` - * hoisted from nested blocks. - * - "function": everything (function, var, let, const, class) - * declared at the top level of the function body, plus - * `var` hoisted from nested blocks. - * - "block": block-scoped declarations (let, const, 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 scope and collected by the - * function-level pre-pass. + * - "module": everything (function, var, let, const, class, import) + * declared at the top level of the module, plus `var` + * hoisted from nested blocks. + * - "function": everything (function, var, let, const, 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 (let, const, 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: "module" | "function" | "block", + scopeType: "module" | "function" | "block" | "static-block", targetScope: Set, ): void { if (!node) return; @@ -265,7 +270,7 @@ function walkAstWithBindings( } if (node.type === "VariableDeclaration") { - if (scopeType === "module" || scopeType === "function") { + if (scopeType === "module" || scopeType === "function" || scopeType === "static-block") { for (const decl of astArray(node.declarations)) { addBindings(toAstRecord(decl.id), targetScope); } @@ -277,7 +282,7 @@ function walkAstWithBindings( return; } - if (scopeType === "module" || scopeType === "function") { + if (scopeType === "module" || scopeType === "function" || scopeType === "static-block") { const recurseInto = new Set([ "BlockStatement", "IfStatement", @@ -298,7 +303,10 @@ function walkAstWithBindings( } } - function walkBody(body: AstRecord[], scopeType: "module" | "function" | "block"): void { + function walkBody( + body: AstRecord[], + scopeType: "module" | "function" | "block" | "static-block", + ): void { const currentScope = scopeStack[scopeStack.length - 1]; for (const node of body) { collectDeclarationsForScope(node, scopeType, currentScope); @@ -483,13 +491,15 @@ function walkAstWithBindings( } // Class static blocks (`static { ... }`) introduce their own lexical - // scope. `let`/`const`/`class`/`function` declared inside are scoped - // to the block and must not leak into the enclosing class or module - // scope. `var` declarations still hoist to the enclosing function - // scope and are collected by the function-scope pre-pass. + // 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 `var`/`let`/`const`/`class`/`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), "block"); + walkBody(astArray(node.body), "static-block"); scopeStack.pop(); return; } diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index e70908d60..ca9d66148 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -2536,6 +2536,36 @@ class Loader { 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 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(); + expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + }); + it("leaves require('./' + name) untouched for existing analysis", () => { const code = `require('./' + name);`; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); From ada1fc38b34211c0449fd15d9673e4a7f876c713 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:34:31 +1000 Subject: [PATCH 10/32] fix(scope-analysis): model named class expressions and TS declare, dedup walker Two correctness follow-ups and a verbosity cleanup for the binding-aware walker. ## Fixes ### Named class expressions A named class expression (e.g. `const C = class require { static { require(id) } }`) binds its name in the class's own scope, and static blocks inside see that name (per ES2022). The walker was falling through to the generic recursion for ClassExpression, so a `require` static block binding was not detected. Add an explicit ClassExpression handler that: * Walks `superClass` and decorators in the enclosing scope (they are evaluated before the class is bound). * Pushes a new scope, binds the class name, walks the body, and pops the scope. Methods don't see the class name, but they create their own function scopes when walked, so the extra class scope is harmless for them. * For unnamed ClassExpression, just walks the body in the enclosing scope (the generic recursion path). ### TS `declare` declarations A `declare const require: unknown` (or `declare function`, ``declare class`, etc.) is type-only and has no runtime binding. OXC reports it with `declare: true` on the declaration node. The walker was treating it as a runtime binding and skipping the rewrite, leaving the call for the CJS plugin. Add an early `node.declare === true` return in `collectDeclarationsForScope` so type-only declarations never shadow the global `require`. ## Refactor * Hoist `SCOPE_RECURSE_INTO` (the block-introducing node set) to module level. It was duplicated in `collectVarDeclarationsFromNestedBlocks` and `collectDeclarationsForScope`. * Add `forEachChild` helper for the generic child-iteration pattern. Was duplicated in `collectVarDeclarationsFromNestedBlocks` and the fallback branch of `walkNode`. * Add `isFunctionLike`, `isExportWrapper`, `isHoistingScope` helpers. The 3-clause OR for function-like and the export-wrapper type check were duplicated in 2-3 places each. * Add `ScopeType` type alias. Replaces 4-fold union repetition in function signatures. Net effect: ~50 lines of duplication removed, ~60 lines of named helpers (with comments) added. The walker is shorter overall and the intent of each scope-introducing case is clearer. ## Tests * Named class expression shadows an earlier `require(id)` in the same static block. * Named class expression with `extends` and a method named `require` still shadows correctly. * `declare const require: unknown; require(id)` rewrites the call to the runtime helper (type-only declare does not shadow the global require). * Same for `declare function require` and `declare class require`. All 33 `ignoreVeryDynamicRequests` tests pass (was 28). --- .../src/plugins/ignore-dynamic-requests.ts | 190 ++++++++++-------- tests/build-optimization.test.ts | 60 ++++++ 2 files changed, 165 insertions(+), 85 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 51507be6a..35c8334b3 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -135,6 +135,67 @@ function addBindings(node: unknown, set: Set): void { } } +type ScopeType = "module" | "function" | "block" | "static-block"; + +/** + * 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", + "SwitchStatement", + "SwitchCase", +]); + +const SKIP_CHILD_KEYS = new Set(["parent", "loc", "start", "end"]); + +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"; +} + +/** + * Iterate the AST children of `node` in property-declaration order. + * Skips non-AST bookkeeping fields like `parent`/`loc`/`start`/`end` + * and recurses into arrays. Used for the generic fallback when a + * node is not a recognized scope introducer. + */ +function forEachChild(node: AstRecord, callback: (child: AstRecord) => void): void { + for (const [key, value] of Object.entries(node)) { + if (SKIP_CHILD_KEYS.has(key)) continue; + const rec = toAstRecord(value); + if (rec) { + callback(rec); + } else if (Array.isArray(value)) { + for (const item of value) { + const itemRec = toAstRecord(item); + if (itemRec) callback(itemRec); + } + } + } +} + function walkAstWithBindings( value: unknown, visit: (node: AstRecord, isRequireBound: () => boolean) => void, @@ -157,16 +218,9 @@ function walkAstWithBindings( */ function collectVarDeclarationsFromNestedBlocks(node: AstRecord, targetScope: Set): void { if (!node) return; + if (isFunctionLike(node)) return; - if ( - node.type === "FunctionDeclaration" || - node.type === "FunctionExpression" || - node.type === "ArrowFunctionExpression" - ) { - return; - } - - if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") { + if (isExportWrapper(node)) { const declaration = toAstRecord(node.declaration); if (declaration) { collectVarDeclarationsFromNestedBlocks(declaration, targetScope); @@ -181,35 +235,8 @@ function walkAstWithBindings( return; } - const recurseInto = new Set([ - "BlockStatement", - "IfStatement", - "ForStatement", - "ForInStatement", - "ForOfStatement", - "WhileStatement", - "DoWhileStatement", - "WithStatement", - "TryStatement", - "SwitchStatement", - "SwitchCase", - ]); - - if (recurseInto.has(node.type)) { - for (const [key, child] of Object.entries(node)) { - if (key === "parent") continue; - const childRec = toAstRecord(child); - if (childRec) { - collectVarDeclarationsFromNestedBlocks(childRec, targetScope); - } else if (Array.isArray(child)) { - for (const item of child) { - const itemRec = toAstRecord(item); - if (itemRec) { - collectVarDeclarationsFromNestedBlocks(itemRec, targetScope); - } - } - } - } + if (SCOPE_RECURSE_INTO.has(node.type)) { + forEachChild(node, (child) => collectVarDeclarationsFromNestedBlocks(child, targetScope)); } } @@ -238,12 +265,17 @@ function walkAstWithBindings( */ function collectDeclarationsForScope( node: AstRecord, - scopeType: "module" | "function" | "block" | "static-block", + scopeType: ScopeType, targetScope: Set, ): void { if (!node) return; - if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") { + // 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); @@ -270,11 +302,9 @@ function walkAstWithBindings( } if (node.type === "VariableDeclaration") { - if (scopeType === "module" || scopeType === "function" || scopeType === "static-block") { - for (const decl of astArray(node.declarations)) { - addBindings(toAstRecord(decl.id), targetScope); - } - } else if (scopeType === "block" && (node.kind === "let" || node.kind === "const")) { + const isBlockLetConst = + scopeType === "block" && (node.kind === "let" || node.kind === "const"); + if (isHoistingScope(scopeType) || isBlockLetConst) { for (const decl of astArray(node.declarations)) { addBindings(toAstRecord(decl.id), targetScope); } @@ -282,31 +312,12 @@ function walkAstWithBindings( return; } - if (scopeType === "module" || scopeType === "function" || scopeType === "static-block") { - const recurseInto = new Set([ - "BlockStatement", - "IfStatement", - "ForStatement", - "ForInStatement", - "ForOfStatement", - "WhileStatement", - "DoWhileStatement", - "WithStatement", - "TryStatement", - "SwitchStatement", - "SwitchCase", - ]); - - if (recurseInto.has(node.type)) { - collectVarDeclarationsFromNestedBlocks(node, targetScope); - } + if (isHoistingScope(scopeType) && SCOPE_RECURSE_INTO.has(node.type)) { + collectVarDeclarationsFromNestedBlocks(node, targetScope); } } - function walkBody( - body: AstRecord[], - scopeType: "module" | "function" | "block" | "static-block", - ): void { + function walkBody(body: AstRecord[], scopeType: ScopeType): void { const currentScope = scopeStack[scopeStack.length - 1]; for (const node of body) { collectDeclarationsForScope(node, scopeType, currentScope); @@ -321,16 +332,11 @@ function walkAstWithBindings( visit(node, isRequireBound); - const isFunctionLike = - node.type === "FunctionDeclaration" || - node.type === "FunctionExpression" || - node.type === "ArrowFunctionExpression"; - - if (isFunctionLike) { + if (isFunctionLike(node)) { scopeStack.push(new Set()); // Named function expressions bind their name only in their own scope - if (node.type === "FunctionExpression") { + if (node.type === "FunctionExpression" && node.id) { addBindings(node.id, scopeStack[scopeStack.length - 1]); } @@ -504,19 +510,33 @@ function walkAstWithBindings( return; } - // Generic recursion for non-scope nodes - for (const [key, child] of Object.entries(node)) { - if (key === "parent" || key === "start" || key === "end") continue; - const childRec = toAstRecord(child); - if (childRec) { - walkNode(childRec); - } else if (Array.isArray(child)) { - for (const item of child) { - const itemRec = toAstRecord(item); - if (itemRec) walkNode(itemRec); - } + // 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. `superClass` and decorators are evaluated in the + // enclosing scope, so they are walked before the class scope is + // pushed. An unnamed class expression does not introduce a new + // binding, so we just walk its body in the enclosing scope. + if (node.type === "ClassExpression") { + const superClass = toAstRecord(node.superClass); + if (superClass) walkNode(superClass); + for (const dec of astArray(node.decorators)) walkNode(dec); + + if (node.id) { + scopeStack.push(new Set()); + addBindings(node.id, scopeStack[scopeStack.length - 1]); } + const body = toAstRecord(node.body); + if (body) walkNode(body); + if (node.id) scopeStack.pop(); + return; } + + // Generic recursion for non-scope nodes + forEachChild(node, walkNode); } const root = toAstRecord(value); diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index ca9d66148..c99793b2a 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -2559,6 +2559,66 @@ class Loader { } } +require(id); +`; + const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); + expect(result).not.toBeNull(); + expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + }); + + 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("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(); + expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + }); + + 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(); + expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + }); + + 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"); From 5b3e9406f587fddc78fd283eb2b08f15d7aceebd Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:25:37 +1000 Subject: [PATCH 11/32] fix(build): handle remaining dynamic request AST edges --- .../src/plugins/ignore-dynamic-requests.ts | 136 +++++------------- tests/build-optimization.test.ts | 58 ++++++++ 2 files changed, 95 insertions(+), 99 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 35c8334b3..581def1e6 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -2,17 +2,20 @@ import path from "node:path"; import MagicString from "magic-string"; import type { Plugin } from "vite"; import { parseAst } from "vite"; +import { + type AstRecord, + collectBindingNames, + forEachAstChild, + getAstName, + hasRange, + isAstRecord, + isIdentifierNamed, + nodeArray, +} from "./ast-utils.js"; const MODULE_EXTENSIONS = new Set([".cjs", ".mjs", ".js", ".cts", ".mts", ".ts", ".jsx", ".tsx"]); const DYNAMIC_REQUIRE_HELPER_BASE = "__vinext_ignored_dynamic_require__"; -type AstRecord = { - type: string; - start?: number; - end?: number; - [key: string]: unknown; -}; - type TransformResult = { code: string; map: ReturnType; @@ -25,38 +28,17 @@ function getObjectProperty(value: unknown, key: string): unknown { return Reflect.get(value, key); } -function isAstRecord(value: unknown): value is AstRecord { - return typeof getObjectProperty(value, "type") === "string"; -} - function toAstRecord(value: unknown): AstRecord | null { return isAstRecord(value) ? value : null; } function astArray(value: unknown): AstRecord[] { - if (!Array.isArray(value)) return []; - return value.flatMap((entry) => { + return nodeArray(value).flatMap((entry) => { const node = toAstRecord(entry); return node ? [node] : []; }); } -function hasRange(node: AstRecord | null): node is AstRecord & { start: number; end: number } { - return node !== null && typeof node.start === "number" && typeof node.end === "number"; -} - -function isIdentifierNamed(node: AstRecord | null, name: string): boolean { - return node?.type === "Identifier" && node.name === name; -} - -function getIdentifierName(value: unknown): string | null { - const rec = toAstRecord(value); - if (rec?.type === "Identifier" && typeof rec.name === "string") { - return rec.name; - } - return null; -} - function firstArgument(node: AstRecord): AstRecord | null { return astArray(node.arguments)[0] ?? null; } @@ -76,6 +58,7 @@ function unwrapTransparentExpression(node: AstRecord | null): AstRecord | null { case "TSTypeAssertion": case "TSNonNullExpression": case "TSInstantiationExpression": + case "TSSatisfiesExpression": case "ParenthesizedExpression": case "ChainExpression": return unwrapTransparentExpression(toAstRecord(node.expression)); @@ -112,29 +95,6 @@ function requestHasStaticPart(node: AstRecord | null): boolean { return false; } -function addBindings(node: unknown, set: Set): void { - const rec = toAstRecord(node); - if (!rec) return; - - if (rec.type === "Identifier" && typeof rec.name === "string") { - set.add(rec.name); - } else if (rec.type === "ObjectPattern") { - for (const prop of astArray(rec.properties)) { - addBindings(prop, set); - } - } else if (rec.type === "ArrayPattern") { - for (const element of astArray(rec.elements)) { - addBindings(element, set); - } - } else if (rec.type === "RestElement") { - addBindings(rec.argument, set); - } else if (rec.type === "Property") { - addBindings(rec.value, set); - } else if (rec.type === "AssignmentPattern") { - addBindings(rec.left, set); - } -} - type ScopeType = "module" | "function" | "block" | "static-block"; /** @@ -153,12 +113,11 @@ const SCOPE_RECURSE_INTO = new Set([ "DoWhileStatement", "WithStatement", "TryStatement", + "CatchClause", "SwitchStatement", "SwitchCase", ]); -const SKIP_CHILD_KEYS = new Set(["parent", "loc", "start", "end"]); - function isFunctionLike(node: AstRecord): boolean { return ( node.type === "FunctionDeclaration" || @@ -175,27 +134,6 @@ function isHoistingScope(scopeType: ScopeType): boolean { return scopeType === "module" || scopeType === "function" || scopeType === "static-block"; } -/** - * Iterate the AST children of `node` in property-declaration order. - * Skips non-AST bookkeeping fields like `parent`/`loc`/`start`/`end` - * and recurses into arrays. Used for the generic fallback when a - * node is not a recognized scope introducer. - */ -function forEachChild(node: AstRecord, callback: (child: AstRecord) => void): void { - for (const [key, value] of Object.entries(node)) { - if (SKIP_CHILD_KEYS.has(key)) continue; - const rec = toAstRecord(value); - if (rec) { - callback(rec); - } else if (Array.isArray(value)) { - for (const item of value) { - const itemRec = toAstRecord(item); - if (itemRec) callback(itemRec); - } - } - } -} - function walkAstWithBindings( value: unknown, visit: (node: AstRecord, isRequireBound: () => boolean) => void, @@ -230,13 +168,13 @@ function walkAstWithBindings( if (node.type === "VariableDeclaration" && node.kind === "var") { for (const decl of astArray(node.declarations)) { - addBindings(toAstRecord(decl.id), targetScope); + collectBindingNames(decl.id, targetScope); } return; } if (SCOPE_RECURSE_INTO.has(node.type)) { - forEachChild(node, (child) => collectVarDeclarationsFromNestedBlocks(child, targetScope)); + forEachAstChild(node, (child) => collectVarDeclarationsFromNestedBlocks(child, targetScope)); } } @@ -284,20 +222,24 @@ function walkAstWithBindings( } if (node.type === "ImportDeclaration" && scopeType === "module") { + if (node.importKind === "type") return; + for (const spec of astArray(node.specifiers)) { - const localName = getIdentifierName(toAstRecord(spec.local)); + if (spec.importKind === "type") continue; + + const localName = getAstName(spec.local); if (localName) targetScope.add(localName); } return; } if (node.type === "ClassDeclaration" && node.id) { - addBindings(node.id, targetScope); + collectBindingNames(node.id, targetScope); return; } if (node.type === "FunctionDeclaration" && node.id) { - addBindings(node.id, targetScope); + collectBindingNames(node.id, targetScope); return; } @@ -306,7 +248,7 @@ function walkAstWithBindings( scopeType === "block" && (node.kind === "let" || node.kind === "const"); if (isHoistingScope(scopeType) || isBlockLetConst) { for (const decl of astArray(node.declarations)) { - addBindings(toAstRecord(decl.id), targetScope); + collectBindingNames(decl.id, targetScope); } } return; @@ -337,11 +279,11 @@ function walkAstWithBindings( // Named function expressions bind their name only in their own scope if (node.type === "FunctionExpression" && node.id) { - addBindings(node.id, scopeStack[scopeStack.length - 1]); + collectBindingNames(node.id, scopeStack[scopeStack.length - 1]); } for (const param of astArray(node.params)) { - addBindings(param, scopeStack[scopeStack.length - 1]); + collectBindingNames(param, scopeStack[scopeStack.length - 1]); } // Walk parameter default RHS expressions with the parameter bindings @@ -349,9 +291,8 @@ function walkAstWithBindings( // 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. `addBindings` only - // records binding names, so default-value expressions need to be - // walked explicitly. + // `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); } @@ -379,7 +320,7 @@ function walkAstWithBindings( if (node.type === "CatchClause") { scopeStack.push(new Set()); if (node.param) { - addBindings(node.param, scopeStack[scopeStack.length - 1]); + collectBindingNames(node.param, scopeStack[scopeStack.length - 1]); } const bodyRec = toAstRecord(node.body); if (bodyRec?.type === "BlockStatement") { @@ -403,7 +344,7 @@ function walkAstWithBindings( if (hasLexicalHeader) { scopeStack.push(new Set()); for (const decl of astArray(init.declarations)) { - addBindings(toAstRecord(decl.id), scopeStack[scopeStack.length - 1]); + collectBindingNames(decl.id, scopeStack[scopeStack.length - 1]); } } @@ -429,7 +370,7 @@ function walkAstWithBindings( if (hasLexicalHeader) { scopeStack.push(new Set()); for (const decl of astArray(left.declarations)) { - addBindings(toAstRecord(decl.id), scopeStack[scopeStack.length - 1]); + collectBindingNames(decl.id, scopeStack[scopeStack.length - 1]); } } @@ -460,12 +401,12 @@ function walkAstWithBindings( (stmt.kind === "let" || stmt.kind === "const") ) { for (const decl of astArray(stmt.declarations)) { - addBindings(toAstRecord(decl.id), switchScope); + collectBindingNames(decl.id, switchScope); } } else if (stmt.type === "ClassDeclaration" && stmt.id) { - addBindings(stmt.id, switchScope); + collectBindingNames(stmt.id, switchScope); } else if (stmt.type === "FunctionDeclaration" && stmt.id) { - addBindings(stmt.id, switchScope); + collectBindingNames(stmt.id, switchScope); } } } @@ -487,9 +428,6 @@ function walkAstWithBindings( // 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") { - // 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. for (const stmt of astArray(node.consequent)) { walkNode(stmt); } @@ -527,7 +465,7 @@ function walkAstWithBindings( if (node.id) { scopeStack.push(new Set()); - addBindings(node.id, scopeStack[scopeStack.length - 1]); + collectBindingNames(node.id, scopeStack[scopeStack.length - 1]); } const body = toAstRecord(node.body); if (body) walkNode(body); @@ -536,20 +474,20 @@ function walkAstWithBindings( } // Generic recursion for non-scope nodes - forEachChild(node, walkNode); + forEachAstChild(node, walkNode); } const root = toAstRecord(value); if (root?.type === "Program") { walkBody(astArray(root.body), "module"); } else if (Array.isArray(value)) { - walkBody(value as AstRecord[], "block"); + walkBody(astArray(value), "block"); } } function isVeryDynamicRequireCall(node: AstRecord, isRequireBound: () => boolean): boolean { if (node.type !== "CallExpression") return false; - if (!isIdentifierNamed(toAstRecord(node.callee), "require")) return false; + if (!isIdentifierNamed(node.callee, "require")) return false; if (isRequireBound()) return false; return !requestHasStaticPart(firstArgument(node)); } diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index c99793b2a..b2e38a1d4 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -2550,6 +2550,33 @@ class Loader { 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 leak class static block var require outside the static block", () => { const code = ` class Loader { @@ -2626,6 +2653,25 @@ require(id); expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); }); + 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(); + expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + }); + + 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(); + expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + }); + it("leaves require('./' + name) untouched for existing analysis", () => { const code = `require('./' + name);`; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); @@ -2650,6 +2696,18 @@ require(id); 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"); From 41e776f412565cc7e7f5c8abf6d6c651e58e518a Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:08:58 +1000 Subject: [PATCH 12/32] fix(build): preserve labelled var require and empty literals --- .../src/plugins/ignore-dynamic-requests.ts | 3 +- tests/build-optimization.test.ts | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 581def1e6..ca5814707 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -72,7 +72,7 @@ function requestHasStaticPart(node: AstRecord | null): boolean { if (!unwrapped) return false; if (unwrapped.type === "Literal" || unwrapped.type === "StringLiteral") { - return typeof unwrapped.value === "string" && unwrapped.value.length > 0; + return typeof unwrapped.value === "string"; } if (unwrapped.type === "TemplateLiteral") { @@ -114,6 +114,7 @@ const SCOPE_RECURSE_INTO = new Set([ "WithStatement", "TryStatement", "CatchClause", + "LabeledStatement", "SwitchStatement", "SwitchCase", ]); diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index b2e38a1d4..63116d3c4 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -2571,6 +2571,29 @@ try { 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"); @@ -2684,6 +2707,18 @@ value(); 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 require(('./' + name) as string) untouched because it has a static part", () => { const code = `require(('./' + name) as string);`; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); From 8d4e93e346d7d45b055cae009fd23e24d9120185 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:16:45 +1000 Subject: [PATCH 13/32] chore: retrigger CI From eb71e0548e3980b1d1424643b5ae4d7d01223452 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 7 Jun 2026 00:24:00 +1000 Subject: [PATCH 14/32] fix(build): narrow dynamic request precheck --- .../src/plugins/ignore-dynamic-requests.ts | 6 +++++- tests/build-optimization.test.ts | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index ca5814707..85a066e23 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -554,8 +554,12 @@ function isTransformableModuleId(id: string): boolean { return MODULE_EXTENSIONS.has(path.extname(cleanId)); } +export function mayContainVeryDynamicRequestTarget(code: string): boolean { + return /\b(?:require\s*\(|import\s*\()/.test(code); +} + export function ignoreVeryDynamicRequests(code: string, id: string): TransformResult | null { - if (!/\b(?:import|require)\b/.test(code)) return null; + if (!mayContainVeryDynamicRequestTarget(code)) return null; let ast: ReturnType; try { diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 63116d3c4..9fade9d45 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -19,7 +19,10 @@ import { } from "../packages/vinext/src/build/client-build-config.js"; import { computeLazyChunks } from "../packages/vinext/src/utils/lazy-chunks.js"; import { asyncHooksStubPlugin as _asyncHooksStubPlugin } from "../packages/vinext/src/plugins/async-hooks-stub.js"; -import { ignoreVeryDynamicRequests as _ignoreVeryDynamicRequests } from "../packages/vinext/src/plugins/ignore-dynamic-requests.js"; +import { + ignoreVeryDynamicRequests as _ignoreVeryDynamicRequests, + 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; @@ -2329,8 +2332,20 @@ export default function RootLayout({ children }: { children: ReactNode }) { // ─── ignoreVeryDynamicRequests ──────────────────────────────────────────────── describe("ignoreVeryDynamicRequests", () => { + 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("rewrites unbound require(dynamic) to runtime helper", () => { const code = `require(id);`; + expect(_mayContainVeryDynamicRequestTarget(code)).toBe(true); const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); expect(result).not.toBeNull(); expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); From dafd2e1b0d8c8d99d19c54d27269603f35d5d79e Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 7 Jun 2026 00:57:35 +1000 Subject: [PATCH 15/32] fix(build): emit Next-like dynamic request failures --- .../src/plugins/ignore-dynamic-requests.ts | 78 +++++++------------ tests/build-optimization.test.ts | 71 ++++++++++++----- 2 files changed, 80 insertions(+), 69 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 85a066e23..79d48a8a6 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -14,7 +14,7 @@ import { } from "./ast-utils.js"; const MODULE_EXTENSIONS = new Set([".cjs", ".mjs", ".js", ".cts", ".mts", ".ts", ".jsx", ".tsx"]); -const DYNAMIC_REQUIRE_HELPER_BASE = "__vinext_ignored_dynamic_require__"; +const DYNAMIC_REQUEST_ERROR_MESSAGE = "Cannot find module as expression is too dynamic"; type TransformResult = { code: string; @@ -498,35 +498,20 @@ function isVeryDynamicImportExpression(node: AstRecord): boolean { return !requestHasStaticPart(toAstRecord(node.source)); } -function directiveInsertionPoint(body: unknown): number { - let insertionPoint = 0; - - for (const node of astArray(body)) { - if (node.type !== "ExpressionStatement") break; - const expression = toAstRecord(node.expression); - if ( - expression?.type !== "Literal" || - typeof expression.value !== "string" || - typeof node.end !== "number" - ) { - break; - } - insertionPoint = node.end; - } - - return insertionPoint; +function dynamicRequireFailureExpression(): string { + return `(() => { + const e = new Error("${DYNAMIC_REQUEST_ERROR_MESSAGE}"); + e.code = "MODULE_NOT_FOUND"; + throw e; +})()`; } -function uniqueHelperName(code: string): string { - let helperName = DYNAMIC_REQUIRE_HELPER_BASE; - let suffix = 0; - - while (code.includes(helperName)) { - suffix += 1; - helperName = `${DYNAMIC_REQUIRE_HELPER_BASE}${suffix}`; - } - - return helperName; +function dynamicImportFailureExpression(): string { + return `Promise.resolve().then(() => { + const e = new Error("${DYNAMIC_REQUEST_ERROR_MESSAGE}"); + e.code = "MODULE_NOT_FOUND"; + throw e; +})`; } function cleanModuleId(id: string): string { @@ -569,39 +554,36 @@ export function ignoreVeryDynamicRequests(code: string, id: string): TransformRe } const output = new MagicString(code); - const helperName = uniqueHelperName(code); - let rewroteRequire = false; + 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, isRequireBound) => { + if (isInsideRewrittenRange(node)) return; + if (isVeryDynamicRequireCall(node, isRequireBound)) { - const callee = toAstRecord(node.callee); - if (!hasRange(callee)) return; - output.overwrite(callee.start, callee.end, helperName); - rewroteRequire = true; - changed = true; + overwriteNode(node, dynamicRequireFailureExpression()); return; } if (isVeryDynamicImportExpression(node)) { - const source = toAstRecord(node.source); - if (!hasRange(source)) return; - if (code.slice(node.start ?? 0, source.start).includes("@vite-ignore")) return; - output.appendLeft(source.start, "/* @vite-ignore */ "); - changed = true; + overwriteNode(node, dynamicImportFailureExpression()); } }); if (!changed) return null; - if (rewroteRequire) { - const insertionPoint = directiveInsertionPoint(ast.body); - output.appendRight( - insertionPoint, - `\nfunction ${helperName}(id) {\n throw new Error("Cannot find module " + String(id));\n}\n`, - ); - } - return { code: output.toString(), map: output.generateMap({ hires: true, source: id }), diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 9fade9d45..38eae0a23 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -2332,6 +2332,17 @@ export default function RootLayout({ children }: { children: ReactNode }) { // ─── 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"; @@ -2343,12 +2354,13 @@ export const result = value + renamed; expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).toBeNull(); }); - it("rewrites unbound require(dynamic) to runtime helper", () => { + 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(); - expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + expectDynamicRequireFailure(result!.code); + expect(result!.code).not.toContain("__vinext_ignored_dynamic_require__"); }); it("does not rewrite local const require = ...; require(dynamic)", () => { @@ -2403,8 +2415,8 @@ try { `; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); expect(result).not.toBeNull(); - // The try-block require is rewritten to the helper; catch-block require stays untouched - expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + // The try-block require is rewritten; catch-block require stays untouched. + expectDynamicRequireFailure(result!.code); expect(result!.code).toContain("} catch (require) {\n require(id);"); }); @@ -2433,7 +2445,7 @@ require(id); `; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); expect(result).not.toBeNull(); - expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + expectDynamicRequireFailure(result!.code); }); it("does not rewrite require before a later const binding named require", () => { @@ -2514,10 +2526,10 @@ function load(x = require(id)) { `; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); expect(result).not.toBeNull(); - expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + expectDynamicRequireFailure(result!.code); }); - it("adds @vite-ignore to unbound import(dynamic) in a function parameter default", () => { + it("rewrites unbound import(dynamic) in a function parameter default to a rejected promise", () => { const code = ` function load(x = import(id)) { return x; @@ -2525,7 +2537,7 @@ function load(x = import(id)) { `; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); expect(result).not.toBeNull(); - expect(result!.code).toContain("/* @vite-ignore */"); + expectDynamicImportFailure(result!.code); }); it("does not rewrite a parameter default that resolves to a parameter named require", () => { @@ -2628,7 +2640,7 @@ require(id); `; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); expect(result).not.toBeNull(); - expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + expectDynamicRequireFailure(result!.code); }); it("does not rewrite require shadowed by a named class expression binding", () => { @@ -2665,7 +2677,7 @@ require(id); `; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); expect(result).not.toBeNull(); - expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + expectDynamicRequireFailure(result!.code); }); it("rewrites require after `declare function require` (type-only, no runtime binding)", () => { @@ -2674,7 +2686,7 @@ require(id); `; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); expect(result).not.toBeNull(); - expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + expectDynamicRequireFailure(result!.code); }); it("rewrites require after `declare class require` (type-only, no runtime binding)", () => { @@ -2688,7 +2700,7 @@ require(id); `; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); expect(result).not.toBeNull(); - expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + expectDynamicRequireFailure(result!.code); }); it("rewrites require after `import type` renamed to require", () => { @@ -2697,7 +2709,7 @@ require(id); `; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); expect(result).not.toBeNull(); - expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + expectDynamicRequireFailure(result!.code); }); it("rewrites require after a type-only import specifier renamed to require", () => { @@ -2707,7 +2719,7 @@ value(); `; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); expect(result).not.toBeNull(); - expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + expectDynamicRequireFailure(result!.code); }); it("leaves require('./' + name) untouched for existing analysis", () => { @@ -2762,21 +2774,38 @@ value(); const code = `require(path!);`; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); expect(result).not.toBeNull(); - expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); + expectDynamicRequireFailure(result!.code); }); - it("adds @vite-ignore to very dynamic import", () => { + it("rewrites very dynamic import to a rejected promise", () => { const code = `import(id);`; const result = _ignoreVeryDynamicRequests(code, "/app/page.ts"); expect(result).not.toBeNull(); - expect(result!.code).toContain("/* @vite-ignore */"); + expectDynamicImportFailure(result!.code); + expect(result!.code).not.toContain("@vite-ignore"); }); - it("adds @vite-ignore to very dynamic import through TS wrappers", () => { + 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(); - expect(result!.code).toContain("/* @vite-ignore */"); + 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", () => { @@ -2792,8 +2821,8 @@ export default function Page() { `; const result = _ignoreVeryDynamicRequests(code, "/app/page.js"); expect(result).not.toBeNull(); - expect(result!.code).toContain("__vinext_ignored_dynamic_require__"); - expect(result!.code).toContain("/* @vite-ignore */"); + expectDynamicRequireFailure(result!.code); + expectDynamicImportFailure(result!.code); }); }); From c5193e176c259412b1f90209517e721493e317f0 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:03:06 +1000 Subject: [PATCH 16/32] fix(build): require one dynamic request argument --- .../vinext/src/plugins/ignore-dynamic-requests.ts | 11 ++++++++--- tests/build-optimization.test.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 79d48a8a6..821b1610f 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -39,8 +39,9 @@ function astArray(value: unknown): AstRecord[] { }); } -function firstArgument(node: AstRecord): AstRecord | null { - return astArray(node.arguments)[0] ?? null; +function singleArgument(node: AstRecord): AstRecord | null { + const args = astArray(node.arguments); + return args.length === 1 ? (args[0] ?? null) : null; } function templateElementHasStaticPart(node: AstRecord): boolean { @@ -490,7 +491,11 @@ function isVeryDynamicRequireCall(node: AstRecord, isRequireBound: () => boolean if (node.type !== "CallExpression") return false; if (!isIdentifierNamed(node.callee, "require")) return false; if (isRequireBound()) return false; - return !requestHasStaticPart(firstArgument(node)); + + const request = singleArgument(node); + if (!request) return false; + + return !requestHasStaticPart(request); } function isVeryDynamicImportExpression(node: AstRecord): boolean { diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 38eae0a23..071f27d1a 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -2363,6 +2363,16 @@ export const result = value + renamed; expect(result!.code).not.toContain("__vinext_ignored_dynamic_require__"); }); + 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("does not rewrite local const require = ...; require(dynamic)", () => { const code = ` const require = (id: string) => ({ id }); From eebfb4bb82c00c02a6acfbeccaeb9b7781cd5e37 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:09:17 +1000 Subject: [PATCH 17/32] fix(build): skip vinext internals in dynamic request guard --- .../src/plugins/ignore-dynamic-requests.ts | 16 ++++++++++++++-- tests/build-optimization.test.ts | 8 ++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 821b1610f..9d7aab8a7 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { fileURLToPath } from "node:url"; import MagicString from "magic-string"; import type { Plugin } from "vite"; import { parseAst } from "vite"; @@ -15,6 +16,7 @@ import { const MODULE_EXTENSIONS = new Set([".cjs", ".mjs", ".js", ".cts", ".mts", ".ts", ".jsx", ".tsx"]); 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; @@ -520,7 +522,8 @@ function dynamicImportFailureExpression(): string { } function cleanModuleId(id: string): string { - return id.split(/[?#]/, 1)[0] ?? id; + const cleanId = id.split(/[?#]/, 1)[0] ?? id; + return cleanId.charCodeAt(0) === 0 ? cleanId.slice(1) : cleanId; } function parserLangForId(id: string): ParserLang { @@ -538,9 +541,18 @@ function parserLangForId(id: string): ParserLang { return "js"; } -function isTransformableModuleId(id: string): boolean { +export function isTransformableModuleId(id: string): boolean { const cleanId = cleanModuleId(id); if (cleanId.includes("/node_modules/")) return false; + if (path.isAbsolute(cleanId)) { + const resolvedId = path.resolve(cleanId); + if ( + resolvedId === VINEXT_SOURCE_ROOT || + resolvedId.startsWith(`${VINEXT_SOURCE_ROOT}${path.sep}`) + ) { + return false; + } + } return MODULE_EXTENSIONS.has(path.extname(cleanId)); } diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 071f27d1a..a5dd4bba8 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -21,6 +21,7 @@ import { computeLazyChunks } from "../packages/vinext/src/utils/lazy-chunks.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"; @@ -2354,6 +2355,13 @@ export const result = value + renamed; expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).toBeNull(); }); + 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); From a3f506f9e375782e8887910193018bfa84cc9886 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:46:11 +1000 Subject: [PATCH 18/32] fix(build): skip spread require dynamic guard --- .../vinext/src/plugins/ignore-dynamic-requests.ts | 12 +++++++++--- tests/build-optimization.test.ts | 11 +++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 9d7aab8a7..3f95303c9 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -43,7 +43,10 @@ function astArray(value: unknown): AstRecord[] { function singleArgument(node: AstRecord): AstRecord | null { const args = astArray(node.arguments); - return args.length === 1 ? (args[0] ?? null) : null; + if (args.length !== 1) return null; + const arg = args[0] ?? null; + if (arg?.type === "SpreadElement") return null; + return arg; } function templateElementHasStaticPart(node: AstRecord): boolean { @@ -562,10 +565,11 @@ export function mayContainVeryDynamicRequestTarget(code: string): boolean { export function ignoreVeryDynamicRequests(code: string, id: string): TransformResult | null { if (!mayContainVeryDynamicRequestTarget(code)) return null; + const source = cleanModuleId(id); let ast: ReturnType; try { - ast = parseAst(code, { lang: parserLangForId(id) }, cleanModuleId(id)); + ast = parseAst(code, { lang: parserLangForId(id) }, source); } catch { return null; } @@ -603,7 +607,7 @@ export function ignoreVeryDynamicRequests(code: string, id: string): TransformRe return { code: output.toString(), - map: output.generateMap({ hires: true, source: id }), + map: output.generateMap({ hires: true, source }), }; } @@ -611,6 +615,8 @@ 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/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index a5dd4bba8..5dc4f56b1 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -2381,6 +2381,17 @@ export const result = value + renamed; 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 }); From 3688a702bb12cd0ab6a031aaaeda8ed4762b7c5b Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:10:33 +1000 Subject: [PATCH 19/32] refactor(plugins): dedup AST and module-ID helpers across transform plugins --- packages/vinext/src/build/report.ts | 13 +--- packages/vinext/src/check.ts | 10 +-- packages/vinext/src/plugins/ast-utils.ts | 47 ++++++++++- .../src/plugins/ignore-dynamic-requests.ts | 78 +++---------------- .../vinext/src/plugins/import-meta-url.ts | 16 +--- .../vinext/src/plugins/require-context.ts | 60 ++------------ .../vinext/src/plugins/transform-utils.ts | 48 ++++++++++++ .../src/server/pages-get-initial-props.ts | 11 +-- 8 files changed, 118 insertions(+), 165 deletions(-) create mode 100644 packages/vinext/src/plugins/transform-utils.ts diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 418f0a910..0f4217a4f 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -23,6 +23,7 @@ import fs from "node:fs"; import path from "node:path"; import { parseSync } from "vite"; import type { ESTree } from "vite"; +import { unwrapTransparentExpression, type AstRecord } from "../plugins/ast-utils.js"; import type { Route } from "../routing/pages-router.js"; import type { AppRoute } from "../routing/app-router.js"; import type { LayoutBuildClassification } from "./layout-classification-types.js"; @@ -149,17 +150,7 @@ function hasNamedExportInProgram(program: Program, name: string): boolean { } function unwrapStaticExpression(expression: Expression): Expression { - let current = expression; - while ( - current.type === "ParenthesizedExpression" || - current.type === "TSAsExpression" || - current.type === "TSSatisfiesExpression" || - current.type === "TSTypeAssertion" || - current.type === "TSNonNullExpression" - ) { - current = current.expression; - } - return current; + return unwrapTransparentExpression(expression as AstRecord) as Expression; } function findExportedConstInitializer(code: string, name: string): Expression | null { diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index ba7a72b05..c960ee5c1 100644 --- a/packages/vinext/src/check.ts +++ b/packages/vinext/src/check.ts @@ -9,6 +9,7 @@ import { detectPackageManager } from "./utils/project.js"; import { parseAst, type ESTree } from "vite"; import fs from "node:fs"; import path from "node:path"; +import { unwrapTransparentExpression, type AstRecord } from "./plugins/ast-utils.js"; // ── Support status definitions ───────────────────────────────────────────── @@ -755,12 +756,9 @@ function collectConfigKeys(source: string): ConfigKeys { collectReturnArgs(body, returns); return returns.flatMap((arg) => resolveObjects(arg, depth + 1)); } - if ( - node.type === "TSAsExpression" || - node.type === "TSSatisfiesExpression" || - node.type === "ParenthesizedExpression" - ) { - return resolveObjects(node.expression, depth + 1); + const unwrapped = unwrapTransparentExpression(node as AstRecord) as ESTree.Expression | null; + if (unwrapped && unwrapped !== node) { + return resolveObjects(unwrapped, depth + 1); } return []; } diff --git a/packages/vinext/src/plugins/ast-utils.ts b/packages/vinext/src/plugins/ast-utils.ts index 901dbf7d9..4e3b1e56c 100644 --- a/packages/vinext/src/plugins/ast-utils.ts +++ b/packages/vinext/src/plugins/ast-utils.ts @@ -12,7 +12,7 @@ export type AstRange = AstRecord & { const SKIP_CHILD_KEYS = new Set(["type", "parent", "loc", "start", "end"]); -function getObjectProperty(value: unknown, key: string): unknown { +export function getObjectProperty(value: unknown, key: string): unknown { if (typeof value !== "object" || value === null) 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,42 @@ 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"))); +} + +function getTemplateElementValue(node: AstRecord): { raw: string; cooked: string } | null { + if (node.type !== "TemplateElement") return null; + const value = getObjectProperty(node, "value"); + if (typeof value !== "object" || value === null) return null; + const raw = getObjectProperty(value, "raw"); + const cooked = getObjectProperty(value, "cooked"); + if (typeof raw !== "string" || typeof cooked !== "string") return null; + return { raw, cooked }; +} + +export function templateElementHasStaticPart(node: AstRecord): boolean { + const value = getTemplateElementValue(node); + if (!value) return false; + return value.raw.length > 0 || value.cooked.length > 0; +} + export function collectBindingNames(pattern: unknown, target: Set): void { const node = toAstRecord(pattern); if (!node) return; diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 3f95303c9..040358809 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -5,16 +5,18 @@ import type { Plugin } from "vite"; import { parseAst } from "vite"; import { type AstRecord, + astArray, collectBindingNames, forEachAstChild, getAstName, hasRange, - isAstRecord, isIdentifierNamed, - nodeArray, + templateElementHasStaticPart, + toAstRecord, + unwrapTransparentExpression, } from "./ast-utils.js"; +import { cleanModuleId, isDependencyId, parserLangForId } from "./transform-utils.js"; -const MODULE_EXTENSIONS = new Set([".cjs", ".mjs", ".js", ".cts", ".mts", ".ts", ".jsx", ".tsx"]); 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)), ".."); @@ -23,24 +25,6 @@ type TransformResult = { map: ReturnType; }; -type ParserLang = "js" | "jsx" | "ts" | "tsx"; - -function getObjectProperty(value: unknown, key: string): unknown { - if (typeof value !== "object" || value === null) return null; - return Reflect.get(value, key); -} - -function toAstRecord(value: unknown): AstRecord | null { - return isAstRecord(value) ? value : null; -} - -function astArray(value: unknown): AstRecord[] { - return nodeArray(value).flatMap((entry) => { - const node = toAstRecord(entry); - return node ? [node] : []; - }); -} - function singleArgument(node: AstRecord): AstRecord | null { const args = astArray(node.arguments); if (args.length !== 1) return null; @@ -49,30 +33,6 @@ function singleArgument(node: AstRecord): AstRecord | null { return arg; } -function templateElementHasStaticPart(node: AstRecord): boolean { - const raw = getObjectProperty(node.value, "raw"); - const cooked = getObjectProperty(node.value, "cooked"); - return ( - (typeof raw === "string" && raw.length > 0) || (typeof cooked === "string" && cooked.length > 0) - ); -} - -function unwrapTransparentExpression(node: AstRecord | null): AstRecord | null { - if (!node) return null; - switch (node.type) { - case "TSAsExpression": - case "TSTypeAssertion": - case "TSNonNullExpression": - case "TSInstantiationExpression": - case "TSSatisfiesExpression": - case "ParenthesizedExpression": - case "ChainExpression": - return unwrapTransparentExpression(toAstRecord(node.expression)); - default: - return node; - } -} - function requestHasStaticPart(node: AstRecord | null): boolean { const unwrapped = unwrapTransparentExpression(node); if (!unwrapped) return false; @@ -524,29 +484,9 @@ function dynamicImportFailureExpression(): string { })`; } -function cleanModuleId(id: string): string { - const cleanId = id.split(/[?#]/, 1)[0] ?? id; - return cleanId.charCodeAt(0) === 0 ? cleanId.slice(1) : cleanId; -} - -function parserLangForId(id: string): ParserLang { - const extension = path.extname(cleanModuleId(id)); - if (extension === ".jsx") return "jsx"; - if (extension === ".ts" || extension === ".tsx" || extension === ".cts" || extension === ".mts") { - return extension === ".tsx" ? "tsx" : "ts"; - } - // 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. - if (extension === ".js" || extension === ".mjs" || extension === ".cjs") { - return "jsx"; - } - return "js"; -} - export function isTransformableModuleId(id: string): boolean { + if (isDependencyId(id)) return false; const cleanId = cleanModuleId(id); - if (cleanId.includes("/node_modules/")) return false; if (path.isAbsolute(cleanId)) { const resolvedId = path.resolve(cleanId); if ( @@ -556,7 +496,7 @@ export function isTransformableModuleId(id: string): boolean { return false; } } - return MODULE_EXTENSIONS.has(path.extname(cleanId)); + return parserLangForId(id) !== null; } export function mayContainVeryDynamicRequestTarget(code: string): boolean { @@ -566,10 +506,12 @@ export function mayContainVeryDynamicRequestTarget(code: string): boolean { 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: parserLangForId(id) }, source); + ast = parseAst(code, { lang }, source); } catch { return null; } 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 32cfbf32f..67e104afc 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; From 225e5d4b3c7c41a3feec2e9efae796c4d1dae080 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:19:32 +1000 Subject: [PATCH 20/32] fix(plugins): getObjectProperty handles functions; keep report/check unwrappers typed --- packages/vinext/src/build/report.ts | 13 +++++++++++-- packages/vinext/src/check.ts | 10 ++++++---- packages/vinext/src/plugins/ast-utils.ts | 2 +- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 0f4217a4f..418f0a910 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -23,7 +23,6 @@ import fs from "node:fs"; import path from "node:path"; import { parseSync } from "vite"; import type { ESTree } from "vite"; -import { unwrapTransparentExpression, type AstRecord } from "../plugins/ast-utils.js"; import type { Route } from "../routing/pages-router.js"; import type { AppRoute } from "../routing/app-router.js"; import type { LayoutBuildClassification } from "./layout-classification-types.js"; @@ -150,7 +149,17 @@ function hasNamedExportInProgram(program: Program, name: string): boolean { } function unwrapStaticExpression(expression: Expression): Expression { - return unwrapTransparentExpression(expression as AstRecord) as Expression; + let current = expression; + while ( + current.type === "ParenthesizedExpression" || + current.type === "TSAsExpression" || + current.type === "TSSatisfiesExpression" || + current.type === "TSTypeAssertion" || + current.type === "TSNonNullExpression" + ) { + current = current.expression; + } + return current; } function findExportedConstInitializer(code: string, name: string): Expression | null { diff --git a/packages/vinext/src/check.ts b/packages/vinext/src/check.ts index c960ee5c1..ba7a72b05 100644 --- a/packages/vinext/src/check.ts +++ b/packages/vinext/src/check.ts @@ -9,7 +9,6 @@ import { detectPackageManager } from "./utils/project.js"; import { parseAst, type ESTree } from "vite"; import fs from "node:fs"; import path from "node:path"; -import { unwrapTransparentExpression, type AstRecord } from "./plugins/ast-utils.js"; // ── Support status definitions ───────────────────────────────────────────── @@ -756,9 +755,12 @@ function collectConfigKeys(source: string): ConfigKeys { collectReturnArgs(body, returns); return returns.flatMap((arg) => resolveObjects(arg, depth + 1)); } - const unwrapped = unwrapTransparentExpression(node as AstRecord) as ESTree.Expression | null; - if (unwrapped && unwrapped !== node) { - return resolveObjects(unwrapped, depth + 1); + if ( + node.type === "TSAsExpression" || + node.type === "TSSatisfiesExpression" || + node.type === "ParenthesizedExpression" + ) { + return resolveObjects(node.expression, depth + 1); } return []; } diff --git a/packages/vinext/src/plugins/ast-utils.ts b/packages/vinext/src/plugins/ast-utils.ts index 4e3b1e56c..8efca242d 100644 --- a/packages/vinext/src/plugins/ast-utils.ts +++ b/packages/vinext/src/plugins/ast-utils.ts @@ -13,7 +13,7 @@ export type AstRange = AstRecord & { const SKIP_CHILD_KEYS = new Set(["type", "parent", "loc", "start", "end"]); export function getObjectProperty(value: unknown, key: string): unknown { - if (typeof value !== "object" || value === null) return null; + if ((typeof value !== "object" || value === null) && typeof value !== "function") return null; return Reflect.get(value, key); } From b9be15a47fd252c8f5bfc99fbf3f16aff8f4952f Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 03:47:15 +0100 Subject: [PATCH 21/32] fix(build): align very dynamic request classification --- .../src/plugins/ignore-dynamic-requests.ts | 54 ++++++++++++++++--- tests/build-optimization.test.ts | 53 ++++++++++++++++++ 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 040358809..ab9cfbe5e 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -9,6 +9,7 @@ import { collectBindingNames, forEachAstChild, getAstName, + getObjectProperty, hasRange, isIdentifierNamed, templateElementHasStaticPart, @@ -33,23 +34,53 @@ function singleArgument(node: AstRecord): AstRecord | null { return arg; } -function requestHasStaticPart(node: AstRecord | null): boolean { +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"; + return typeof unwrapped.value === "string" && unwrapped.value !== "" && unwrapped.value !== "/"; } if (unwrapped.type === "TemplateLiteral") { return astArray(unwrapped.quasis).some(templateElementHasStaticPart); } - if ( - unwrapped.type === "BinaryExpression" || - unwrapped.type === "LogicalExpression" || - unwrapped.type === "ConditionalExpression" - ) { + if (unwrapped.type === "BinaryExpression" && unwrapped.operator === "+") { + return ( + stringConcatenationHasStaticPart(toAstRecord(unwrapped.left)) || + stringConcatenationHasStaticPart(toAstRecord(unwrapped.right)) + ); + } + + return false; +} + +function requestHasStaticPart(node: AstRecord | null): boolean { + const unwrapped = unwrapTransparentExpression(node); + if (!unwrapped) return false; + + if (unwrapped.type === "Literal" || unwrapped.type === "StringLiteral") { + return typeof unwrapped.value !== "string" || unwrapped.value !== "/"; + } + + if (unwrapped.type === "TemplateLiteral") { + return astArray(unwrapped.quasis).some((quasi) => { + const value = getObjectProperty(quasi, "value"); + const raw = getObjectProperty(value, "raw"); + const cooked = getObjectProperty(value, "cooked"); + return ( + (typeof raw === "string" && raw !== "" && raw !== "/") || + (typeof cooked === "string" && cooked !== "" && cooked !== "/") + ); + }); + } + + if (unwrapped.type === "BinaryExpression") { + return unwrapped.operator === "+" && stringConcatenationHasStaticPart(unwrapped); + } + + if (unwrapped.type === "LogicalExpression" || unwrapped.type === "ConditionalExpression") { return ( requestHasStaticPart(toAstRecord(unwrapped.left)) || requestHasStaticPart(toAstRecord(unwrapped.right)) || @@ -210,6 +241,15 @@ function walkAstWithBindings( return; } + if ( + node.type === "TSEnumDeclaration" || + node.type === "TSModuleDeclaration" || + (node.type === "TSImportEqualsDeclaration" && node.importKind !== "type") + ) { + collectBindingNames(node.id, targetScope); + return; + } + if (node.type === "VariableDeclaration") { const isBlockLetConst = scopeType === "block" && (node.kind === "let" || node.kind === "const"); diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index e3c2be39c..633a3eb90 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -3388,6 +3388,28 @@ value(); 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("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"); @@ -3412,6 +3434,37 @@ value(); expect(result).toBeNull(); }); + it("leaves non-string constant requests untouched for existing analysis", () => { + for (const request of ["42", "true", "false", "null", "/pattern/i"]) { + expect(_ignoreVeryDynamicRequests(`require(${request});`, "/app/page.ts")).toBeNull(); + expect(_ignoreVeryDynamicRequests(`import(${request});`, "/app/page.ts")).toBeNull(); + } + }); + + it("rewrites slash-only request patterns as very dynamic", () => { + for (const request of ['"/"', "`${left}/${right}`", "`${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("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"); From 38977d6200c0774326289c4b165f51484bb34269 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 03:51:17 +0100 Subject: [PATCH 22/32] fix(build): respect resource require bindings --- .../src/plugins/ignore-dynamic-requests.ts | 27 ++++++++++--------- tests/build-optimization.test.ts | 11 ++++++++ 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index ab9cfbe5e..a817a1769 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -93,6 +93,7 @@ function requestHasStaticPart(node: AstRecord | null): boolean { } 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 @@ -149,7 +150,7 @@ function walkAstWithBindings( * 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 — `let`, `const`, `class`, and function + * from inside a nested block — lexical variables, `class`, and function * declarations remain block-scoped. */ function collectVarDeclarationsFromNestedBlocks(node: AstRecord, targetScope: Set): void { @@ -182,10 +183,10 @@ function walkAstWithBindings( * both visible for the entire scope. * * Scope types: - * - "module": everything (function, var, let, const, class, import) + * - "module": everything (function, variables, class, import) * declared at the top level of the module, plus `var` * hoisted from nested blocks. - * - "function": everything (function, var, let, const, class) + * - "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 @@ -193,7 +194,7 @@ function walkAstWithBindings( * 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 (let, const, class, + * - "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 @@ -251,9 +252,9 @@ function walkAstWithBindings( } if (node.type === "VariableDeclaration") { - const isBlockLetConst = - scopeType === "block" && (node.kind === "let" || node.kind === "const"); - if (isHoistingScope(scopeType) || isBlockLetConst) { + 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); } @@ -339,14 +340,14 @@ function walkAstWithBindings( return; } - // `for` / `for-in` / `for-of` with a `let`/`const` header introduce a + // `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" && (init.kind === "let" || init.kind === "const"); + init?.type === "VariableDeclaration" && BLOCK_SCOPED_VARIABLE_KINDS.has(String(init.kind)); if (hasLexicalHeader) { scopeStack.push(new Set()); @@ -372,7 +373,7 @@ function walkAstWithBindings( if (node.type === "ForInStatement" || node.type === "ForOfStatement") { const left = toAstRecord(node.left); const hasLexicalHeader = - left?.type === "VariableDeclaration" && (left.kind === "let" || left.kind === "const"); + left?.type === "VariableDeclaration" && BLOCK_SCOPED_VARIABLE_KINDS.has(String(left.kind)); if (hasLexicalHeader) { scopeStack.push(new Set()); @@ -396,7 +397,7 @@ function walkAstWithBindings( // `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 - // `let`/`const`/`class`/`function` from every case consequent. + // lexical variables, `class`, and `function` from every case consequent. if (node.type === "SwitchStatement") { scopeStack.push(new Set()); const switchScope = scopeStack[scopeStack.length - 1]; @@ -405,7 +406,7 @@ function walkAstWithBindings( for (const stmt of astArray(caseNode.consequent)) { if ( stmt.type === "VariableDeclaration" && - (stmt.kind === "let" || stmt.kind === "const") + BLOCK_SCOPED_VARIABLE_KINDS.has(String(stmt.kind)) ) { for (const decl of astArray(stmt.declarations)) { collectBindingNames(decl.id, switchScope); @@ -445,7 +446,7 @@ function walkAstWithBindings( // 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 `var`/`let`/`const`/`class`/`function` at 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") { diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 633a3eb90..49149d0db 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -3399,6 +3399,17 @@ value(); } }); + 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 }", From 1cd8fd595eca3eb25355ee8b6549aef459894b7e Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 03:56:27 +0100 Subject: [PATCH 23/32] fix(build): complete require scope tracking --- packages/vinext/src/plugins/ast-utils.ts | 3 +++ .../src/plugins/ignore-dynamic-requests.ts | 13 +++++++++++++ tests/build-optimization.test.ts | 19 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/packages/vinext/src/plugins/ast-utils.ts b/packages/vinext/src/plugins/ast-utils.ts index 8efca242d..b24217ff0 100644 --- a/packages/vinext/src/plugins/ast-utils.ts +++ b/packages/vinext/src/plugins/ast-utils.ts @@ -119,6 +119,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 index a817a1769..5ce0482fd 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -340,6 +340,19 @@ function walkAstWithBindings( 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 diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 49149d0db..a3116e149 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -3399,6 +3399,25 @@ value(); } }); + 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); }", From 8d6a1c44b17d71818bf5c34fae790a195484851b Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 04:01:50 +0100 Subject: [PATCH 24/32] fix(build): respect named class heritage bindings --- .../vinext/src/plugins/ignore-dynamic-requests.ts | 12 +++++------- tests/build-optimization.test.ts | 5 +++++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 5ce0482fd..6869d5701 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -475,19 +475,17 @@ function walkAstWithBindings( // 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. `superClass` and decorators are evaluated in the - // enclosing scope, so they are walked before the class scope is - // pushed. An unnamed class expression does not introduce a new - // binding, so we just walk its body in the enclosing scope. + // 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") { - const superClass = toAstRecord(node.superClass); - if (superClass) walkNode(superClass); 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(); diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index a3116e149..72843cbae 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -3337,6 +3337,11 @@ const C = class require extends Base { 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 after `declare const require` (type-only, no runtime binding)", () => { const code = `declare const require: unknown; require(id); From 7bf52e8efc951319c7800a3d4d35b37afb31800a Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 04:13:43 +0100 Subject: [PATCH 25/32] fix(build): finish dynamic request pattern parity --- .../src/plugins/ignore-dynamic-requests.ts | 113 +++++++++++++----- tests/build-optimization.test.ts | 52 +++++++- 2 files changed, 134 insertions(+), 31 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 6869d5701..444d15b6c 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -12,7 +12,6 @@ import { getObjectProperty, hasRange, isIdentifierNamed, - templateElementHasStaticPart, toAstRecord, unwrapTransparentExpression, } from "./ast-utils.js"; @@ -34,6 +33,15 @@ function singleArgument(node: AstRecord): AstRecord | null { return arg; } +function templateElementHasSignificantStaticPart(node: AstRecord): boolean { + const value = getObjectProperty(node, "value"); + const cooked = getObjectProperty(value, "cooked"); + if (typeof cooked === "string") return cooked !== "" && cooked !== "/"; + + const raw = getObjectProperty(value, "raw"); + return typeof raw === "string" && raw !== "" && raw !== "/"; +} + function stringConcatenationHasStaticPart(node: AstRecord | null): boolean { const unwrapped = unwrapTransparentExpression(node); if (!unwrapped) return false; @@ -43,7 +51,7 @@ function stringConcatenationHasStaticPart(node: AstRecord | null): boolean { } if (unwrapped.type === "TemplateLiteral") { - return astArray(unwrapped.quasis).some(templateElementHasStaticPart); + return astArray(unwrapped.quasis).some(templateElementHasSignificantStaticPart); } if (unwrapped.type === "BinaryExpression" && unwrapped.operator === "+") { @@ -56,7 +64,36 @@ function stringConcatenationHasStaticPart(node: AstRecord | null): boolean { return false; } -function requestHasStaticPart(node: AstRecord | null): boolean { +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 (isIdentifierNamed(unwrapped, "undefined") && !isBound("undefined")) { + return false; + } + + return null; +} + +function isSyntacticallySideEffectFree(node: AstRecord | null): boolean { + const unwrapped = unwrapTransparentExpression(node); + if (!unwrapped) return false; + return ( + unwrapped.type === "Identifier" || + unwrapped.type === "Literal" || + unwrapped.type === "StringLiteral" || + unwrapped.type === "ThisExpression" + ); +} + +function requestHasStaticPart(node: AstRecord | null, isBound: (name: string) => boolean): boolean { const unwrapped = unwrapTransparentExpression(node); if (!unwrapped) return false; @@ -64,31 +101,44 @@ function requestHasStaticPart(node: AstRecord | null): boolean { return typeof unwrapped.value !== "string" || unwrapped.value !== "/"; } + if (isIdentifierNamed(unwrapped, "undefined")) { + return !isBound("undefined"); + } + if (unwrapped.type === "TemplateLiteral") { - return astArray(unwrapped.quasis).some((quasi) => { - const value = getObjectProperty(quasi, "value"); - const raw = getObjectProperty(value, "raw"); - const cooked = getObjectProperty(value, "cooked"); - return ( - (typeof raw === "string" && raw !== "" && raw !== "/") || - (typeof cooked === "string" && cooked !== "" && cooked !== "/") - ); - }); + return astArray(unwrapped.quasis).some(templateElementHasSignificantStaticPart); } if (unwrapped.type === "BinaryExpression") { return unwrapped.operator === "+" && stringConcatenationHasStaticPart(unwrapped); } + if (unwrapped.type === "ConditionalExpression") { + const truthiness = staticTruthiness(toAstRecord(unwrapped.test), isBound); + if (truthiness !== null) { + return requestHasStaticPart( + toAstRecord(truthiness ? unwrapped.consequent : unwrapped.alternate), + isBound, + ); + } + } + if (unwrapped.type === "LogicalExpression" || unwrapped.type === "ConditionalExpression") { return ( - requestHasStaticPart(toAstRecord(unwrapped.left)) || - requestHasStaticPart(toAstRecord(unwrapped.right)) || - requestHasStaticPart(toAstRecord(unwrapped.consequent)) || - requestHasStaticPart(toAstRecord(unwrapped.alternate)) + requestHasStaticPart(toAstRecord(unwrapped.left), isBound) || + requestHasStaticPart(toAstRecord(unwrapped.right), isBound) || + requestHasStaticPart(toAstRecord(unwrapped.consequent), isBound) || + requestHasStaticPart(toAstRecord(unwrapped.alternate), isBound) ); } + if (unwrapped.type === "SequenceExpression") { + const expressions = astArray(unwrapped.expressions); + if (expressions.length === 0) return false; + if (!expressions.slice(0, -1).every(isSyntacticallySideEffectFree)) return false; + return requestHasStaticPart(expressions.at(-1) ?? null, isBound); + } + return false; } @@ -135,13 +185,13 @@ function isHoistingScope(scopeType: ScopeType): boolean { function walkAstWithBindings( value: unknown, - visit: (node: AstRecord, isRequireBound: () => boolean) => void, + visit: (node: AstRecord, isBound: (name: string) => boolean) => void, ): void { const scopeStack: Set[] = [new Set()]; - function isRequireBound(): boolean { + function isBound(name: string): boolean { for (const scope of scopeStack) { - if (scope.has("require")) return true; + if (scope.has(name)) return true; } return false; } @@ -280,7 +330,7 @@ function walkAstWithBindings( function walkNode(node: AstRecord): void { if (!node) return; - visit(node, isRequireBound); + visit(node, isBound); if (isFunctionLike(node)) { scopeStack.push(new Set()); @@ -504,20 +554,23 @@ function walkAstWithBindings( } } -function isVeryDynamicRequireCall(node: AstRecord, isRequireBound: () => boolean): boolean { +function isVeryDynamicRequireCall(node: AstRecord, isBound: (name: string) => boolean): boolean { if (node.type !== "CallExpression") return false; if (!isIdentifierNamed(node.callee, "require")) return false; - if (isRequireBound()) return false; + if (isBound("require")) return false; const request = singleArgument(node); if (!request) return false; - return !requestHasStaticPart(request); + return !requestHasStaticPart(request, isBound); } -function isVeryDynamicImportExpression(node: AstRecord): boolean { +function isVeryDynamicImportExpression( + node: AstRecord, + isBound: (name: string) => boolean, +): boolean { if (node.type !== "ImportExpression") return false; - return !requestHasStaticPart(toAstRecord(node.source)); + return !requestHasStaticPart(toAstRecord(node.source), isBound); } function dynamicRequireFailureExpression(): string { @@ -552,7 +605,9 @@ export function isTransformableModuleId(id: string): boolean { } export function mayContainVeryDynamicRequestTarget(code: string): boolean { - return /\b(?:require\s*\(|import\s*\()/.test(code); + return /\b(?:require|import)(?:(?:\s+)|(?:\/\*[\s\S]*?\*\/)|(?:\/\/[^\r\n]*(?:\r?\n|$)))*\(/.test( + code, + ); } export function ignoreVeryDynamicRequests(code: string, id: string): TransformResult | null { @@ -584,15 +639,15 @@ export function ignoreVeryDynamicRequests(code: string, id: string): TransformRe changed = true; } - walkAstWithBindings(ast, (node, isRequireBound) => { + walkAstWithBindings(ast, (node, isBound) => { if (isInsideRewrittenRange(node)) return; - if (isVeryDynamicRequireCall(node, isRequireBound)) { + if (isVeryDynamicRequireCall(node, isBound)) { overwriteNode(node, dynamicRequireFailureExpression()); return; } - if (isVeryDynamicImportExpression(node)) { + if (isVeryDynamicImportExpression(node, isBound)) { overwriteNode(node, dynamicImportFailureExpression()); } }); diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 72843cbae..0fb711cd8 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -3008,6 +3008,13 @@ export const result = value + renamed; 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("leaves require() untouched", () => { const result = _ignoreVeryDynamicRequests(`require();`, "/app/page.ts"); expect(result).toBeNull(); @@ -3470,14 +3477,32 @@ value(); }); it("leaves non-string constant requests untouched for existing analysis", () => { - for (const request of ["42", "true", "false", "null", "/pattern/i"]) { + 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}/${right}/`"]) { + for (const request of [ + '"/"', + "`\\/`", + "`${left}/${right}`", + "`${left}\\/`", + "`${left}/${right}/`", + ]) { const requireResult = _ignoreVeryDynamicRequests(`require(${request});`, "/app/page.ts"); expect(requireResult).not.toBeNull(); expectDynamicRequireFailure(requireResult!.code); @@ -3500,6 +3525,29 @@ value(); } }); + it("folds statically decidable conditional request branches", () => { + for (const code of ['require(true ? id : "./unused");', 'import(false ? "./unused" : id);']) { + expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).not.toBeNull(); + } + + for (const code of ['require(false ? id : "./module");', 'import(true ? "./module" : id);']) { + expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).toBeNull(); + } + }); + + it("uses the final value of side-effect-free sequence requests", () => { + for (const code of ['require((0, "./module"));', 'import((value, "./module"));']) { + expect(_ignoreVeryDynamicRequests(code, "/app/page.ts")).toBeNull(); + } + + for (const code of [ + 'require((sideEffect(), "./module"));', + 'import((value = 1, "./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"); From 8798c14e873b0aa92c2b3ff6ecea507d6a3f0d29 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 04:20:10 +0100 Subject: [PATCH 26/32] fix(build): normalize dynamic request expressions --- .../src/plugins/ignore-dynamic-requests.ts | 137 +++++++++++++++++- tests/build-optimization.test.ts | 32 +++- 2 files changed, 159 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 444d15b6c..fa1c87996 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -33,13 +33,22 @@ function singleArgument(node: AstRecord): AstRecord | 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 cooked !== "" && cooked !== "/"; + if (typeof cooked === "string") return concatenatedStringHasSignificantStaticPart(cooked); const raw = getObjectProperty(value, "raw"); - return typeof raw === "string" && raw !== "" && raw !== "/"; + return typeof raw === "string" && concatenatedStringHasSignificantStaticPart(raw); } function stringConcatenationHasStaticPart(node: AstRecord | null): boolean { @@ -47,7 +56,10 @@ function stringConcatenationHasStaticPart(node: AstRecord | null): boolean { if (!unwrapped) return false; if (unwrapped.type === "Literal" || unwrapped.type === "StringLiteral") { - return typeof unwrapped.value === "string" && unwrapped.value !== "" && unwrapped.value !== "/"; + return ( + typeof unwrapped.value === "string" && + concatenatedStringHasSignificantStaticPart(unwrapped.value) + ); } if (unwrapped.type === "TemplateLiteral") { @@ -75,6 +87,16 @@ function staticTruthiness( 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; } @@ -82,15 +104,97 @@ function staticTruthiness( 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; - return ( + + if ( unwrapped.type === "Identifier" || unwrapped.type === "Literal" || unwrapped.type === "StringLiteral" || - unwrapped.type === "ThisExpression" - ); + 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 requestHasStaticPart(node: AstRecord | null, isBound: (name: string) => boolean): boolean { @@ -98,7 +202,9 @@ function requestHasStaticPart(node: AstRecord | null, isBound: (name: string) => if (!unwrapped) return false; if (unwrapped.type === "Literal" || unwrapped.type === "StringLiteral") { - return typeof unwrapped.value !== "string" || unwrapped.value !== "/"; + return ( + typeof unwrapped.value !== "string" || constantStringHasSignificantStaticPart(unwrapped.value) + ); } if (isIdentifierNamed(unwrapped, "undefined")) { @@ -124,6 +230,23 @@ function requestHasStaticPart(node: AstRecord | null, isBound: (name: string) => } if (unwrapped.type === "LogicalExpression" || unwrapped.type === "ConditionalExpression") { + if (unwrapped.type === "LogicalExpression") { + const left = toAstRecord(unwrapped.left); + const truthiness = staticTruthiness(left, isBound); + if (unwrapped.operator === "&&" && truthiness !== null) { + return requestHasStaticPart(truthiness ? toAstRecord(unwrapped.right) : left, isBound); + } + if (unwrapped.operator === "||" && truthiness !== null) { + return requestHasStaticPart(truthiness ? left : toAstRecord(unwrapped.right), isBound); + } + if (unwrapped.operator === "??") { + const nullishness = staticNullishness(left, isBound); + if (nullishness !== null) { + return requestHasStaticPart(nullishness ? toAstRecord(unwrapped.right) : left, isBound); + } + } + } + return ( requestHasStaticPart(toAstRecord(unwrapped.left), isBound) || requestHasStaticPart(toAstRecord(unwrapped.right), isBound) || diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 0fb711cd8..c2baad488 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -3498,9 +3498,12 @@ value(); 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"); @@ -3526,23 +3529,46 @@ value(); }); it("folds statically decidable conditional request branches", () => { - for (const code of ['require(true ? id : "./unused");', 'import(false ? "./unused" : id);']) { + 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);']) { + 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("uses the final value of side-effect-free sequence requests", () => { - for (const code of ['require((0, "./module"));', 'import((value, "./module"));']) { + 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(); } From cee27953b370a1710f08d62ce45ad3928e8ad726 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 04:29:11 +0100 Subject: [PATCH 27/32] fix(build): preserve dynamic request scope parity --- .../src/plugins/ignore-dynamic-requests.ts | 39 +++++++++---------- tests/build-optimization.test.ts | 18 +++++++++ 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index fa1c87996..3d8649d83 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -227,31 +227,28 @@ function requestHasStaticPart(node: AstRecord | null, isBound: (name: string) => isBound, ); } + return false; } - if (unwrapped.type === "LogicalExpression" || unwrapped.type === "ConditionalExpression") { - if (unwrapped.type === "LogicalExpression") { - const left = toAstRecord(unwrapped.left); - const truthiness = staticTruthiness(left, isBound); - if (unwrapped.operator === "&&" && truthiness !== null) { - return requestHasStaticPart(truthiness ? toAstRecord(unwrapped.right) : left, isBound); - } - if (unwrapped.operator === "||" && truthiness !== null) { - return requestHasStaticPart(truthiness ? left : toAstRecord(unwrapped.right), isBound); - } - if (unwrapped.operator === "??") { - const nullishness = staticNullishness(left, isBound); - if (nullishness !== null) { - return requestHasStaticPart(nullishness ? toAstRecord(unwrapped.right) : left, isBound); - } + if (unwrapped.type === "LogicalExpression") { + const left = toAstRecord(unwrapped.left); + const truthiness = staticTruthiness(left, isBound); + if (unwrapped.operator === "&&" && truthiness !== null) { + return requestHasStaticPart(truthiness ? toAstRecord(unwrapped.right) : left, isBound); + } + if (unwrapped.operator === "||" && truthiness !== null) { + return requestHasStaticPart(truthiness ? left : toAstRecord(unwrapped.right), isBound); + } + if (unwrapped.operator === "??") { + const nullishness = staticNullishness(left, isBound); + if (nullishness !== null) { + return requestHasStaticPart(nullishness ? toAstRecord(unwrapped.right) : left, isBound); } } return ( requestHasStaticPart(toAstRecord(unwrapped.left), isBound) || - requestHasStaticPart(toAstRecord(unwrapped.right), isBound) || - requestHasStaticPart(toAstRecord(unwrapped.consequent), isBound) || - requestHasStaticPart(toAstRecord(unwrapped.alternate), isBound) + requestHasStaticPart(toAstRecord(unwrapped.right), isBound) ); } @@ -585,6 +582,9 @@ function walkAstWithBindings( // 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]; @@ -605,9 +605,6 @@ function walkAstWithBindings( } } - const discriminant = toAstRecord(node.discriminant); - if (discriminant) walkNode(discriminant); - for (const caseNode of astArray(node.cases)) { for (const stmt of astArray(caseNode.consequent)) { walkNode(stmt); diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index c2baad488..9d7e55e0c 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -3349,6 +3349,13 @@ const C = class require extends Base { 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); @@ -3552,6 +3559,17 @@ value(); } }); + 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"));', From 224ea1c718c9026df2cd23eed5e9d792e504f9b8 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 04:33:09 +0100 Subject: [PATCH 28/32] fix(build): remove unused AST helper --- packages/vinext/src/plugins/ast-utils.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/vinext/src/plugins/ast-utils.ts b/packages/vinext/src/plugins/ast-utils.ts index b24217ff0..b9ce8ee6a 100644 --- a/packages/vinext/src/plugins/ast-utils.ts +++ b/packages/vinext/src/plugins/ast-utils.ts @@ -89,22 +89,6 @@ export function unwrapTransparentExpression(node: AstRecord | null): AstRecord | return unwrapTransparentExpression(toAstRecord(getObjectProperty(node, "expression"))); } -function getTemplateElementValue(node: AstRecord): { raw: string; cooked: string } | null { - if (node.type !== "TemplateElement") return null; - const value = getObjectProperty(node, "value"); - if (typeof value !== "object" || value === null) return null; - const raw = getObjectProperty(value, "raw"); - const cooked = getObjectProperty(value, "cooked"); - if (typeof raw !== "string" || typeof cooked !== "string") return null; - return { raw, cooked }; -} - -export function templateElementHasStaticPart(node: AstRecord): boolean { - const value = getTemplateElementValue(node); - if (!value) return false; - return value.raw.length > 0 || value.cooked.length > 0; -} - export function collectBindingNames(pattern: unknown, target: Set): void { const node = toAstRecord(pattern); if (!node) return; From 316c77e59e2c30ce8a2219c180bf393117d2aeae Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 04:38:58 +0100 Subject: [PATCH 29/32] fix(build): handle wrapped dynamic require calls --- packages/vinext/src/plugins/ignore-dynamic-requests.ts | 9 ++++++--- tests/build-optimization.test.ts | 9 +++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index 3d8649d83..e9908e05b 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -676,7 +676,9 @@ function walkAstWithBindings( function isVeryDynamicRequireCall(node: AstRecord, isBound: (name: string) => boolean): boolean { if (node.type !== "CallExpression") return false; - if (!isIdentifierNamed(node.callee, "require")) return false; + if (!isIdentifierNamed(unwrapTransparentExpression(toAstRecord(node.callee)), "require")) { + return false; + } if (isBound("require")) return false; const request = singleArgument(node); @@ -725,8 +727,9 @@ export function isTransformableModuleId(id: string): boolean { } export function mayContainVeryDynamicRequestTarget(code: string): boolean { - return /\b(?:require|import)(?:(?:\s+)|(?:\/\*[\s\S]*?\*\/)|(?:\/\/[^\r\n]*(?:\r?\n|$)))*\(/.test( - code, + return ( + /\brequire\b/.test(code) || + /\bimport(?:(?:\s+)|(?:\/\*[\s\S]*?\*\/)|(?:\/\/[^\r\n]*(?:\r?\n|$)))*\(/.test(code) ); } diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 9d7e55e0c..9829d72a4 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -3015,6 +3015,15 @@ export const result = value + renamed; } }); + 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(); From e3563fa78825f302b1ce0f5ef3f8e2c77f58e3cf Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 05:03:24 +0100 Subject: [PATCH 30/32] fix(build): avoid dynamic import precheck backtracking --- packages/vinext/src/plugins/ignore-dynamic-requests.ts | 2 +- tests/build-optimization.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index e9908e05b..edfec0443 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -729,7 +729,7 @@ export function isTransformableModuleId(id: string): boolean { export function mayContainVeryDynamicRequestTarget(code: string): boolean { return ( /\brequire\b/.test(code) || - /\bimport(?:(?:\s+)|(?:\/\*[\s\S]*?\*\/)|(?:\/\/[^\r\n]*(?:\r?\n|$)))*\(/.test(code) + /\bimport\b(?:\s|(?:\/\*[\s\S]*?\*\/)|(?:\/\/[^\r\n]*(?:\r?\n|$)))*\(/.test(code) ); } diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 9829d72a4..83bd92327 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -2992,6 +2992,11 @@ export const result = value + renamed; 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")), From 1eb708099a26d5d4c840c56fa5aac7303f714963 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:26:23 +1000 Subject: [PATCH 31/32] docs(app-router): clarify dynamic request graph boundary --- packages/vinext/src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 023af76a5..3387ac7c8 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1025,9 +1025,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { ...(viteMajorVersion >= 8 ? [] : [tsconfigPaths()]), // React Fast Refresh + JSX transform for client components. reactPluginPromise, - // Next.js/Turbopack ignores "very dynamic" requests with no static path - // part during graph analysis. Preserve that behaviour before the CJS - // plugin tries to expand require(dynamic) as a static glob. + // 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.) From f4bf5aaeff316cac25e5f87827417e5ec7fd85a8 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:28:54 +1000 Subject: [PATCH 32/32] refactor(app-router): name dynamic request classification --- .../src/plugins/ignore-dynamic-requests.ts | 70 +++++++++++++------ 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/packages/vinext/src/plugins/ignore-dynamic-requests.ts b/packages/vinext/src/plugins/ignore-dynamic-requests.ts index edfec0443..0d726b43e 100644 --- a/packages/vinext/src/plugins/ignore-dynamic-requests.ts +++ b/packages/vinext/src/plugins/ignore-dynamic-requests.ts @@ -25,6 +25,15 @@ type TransformResult = { 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; @@ -197,69 +206,84 @@ function isSyntacticallySideEffectFree(node: AstRecord | null): boolean { return false; } -function requestHasStaticPart(node: AstRecord | null, isBound: (name: string) => boolean): boolean { +function classifyRequestExpression( + node: AstRecord | null, + isBound: (name: string) => boolean, +): RequestClassification { const unwrapped = unwrapTransparentExpression(node); - if (!unwrapped) return false; + if (!unwrapped) return { kind: "very-dynamic" }; if (unwrapped.type === "Literal" || unwrapped.type === "StringLiteral") { - return ( - typeof unwrapped.value !== "string" || constantStringHasSignificantStaticPart(unwrapped.value) - ); + return typeof unwrapped.value !== "string" || + constantStringHasSignificantStaticPart(unwrapped.value) + ? { kind: "static" } + : { kind: "very-dynamic" }; } if (isIdentifierNamed(unwrapped, "undefined")) { - return !isBound("undefined"); + return isBound("undefined") ? { kind: "very-dynamic" } : { kind: "static" }; } if (unwrapped.type === "TemplateLiteral") { - return astArray(unwrapped.quasis).some(templateElementHasSignificantStaticPart); + return astArray(unwrapped.quasis).some(templateElementHasSignificantStaticPart) + ? { kind: "partly-static" } + : { kind: "very-dynamic" }; } if (unwrapped.type === "BinaryExpression") { - return unwrapped.operator === "+" && stringConcatenationHasStaticPart(unwrapped); + 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 requestHasStaticPart( + return classifyRequestExpression( toAstRecord(truthiness ? unwrapped.consequent : unwrapped.alternate), isBound, ); } - return false; + return { kind: "very-dynamic" }; } if (unwrapped.type === "LogicalExpression") { const left = toAstRecord(unwrapped.left); const truthiness = staticTruthiness(left, isBound); if (unwrapped.operator === "&&" && truthiness !== null) { - return requestHasStaticPart(truthiness ? toAstRecord(unwrapped.right) : left, isBound); + return classifyRequestExpression(truthiness ? toAstRecord(unwrapped.right) : left, isBound); } if (unwrapped.operator === "||" && truthiness !== null) { - return requestHasStaticPart(truthiness ? left : toAstRecord(unwrapped.right), isBound); + return classifyRequestExpression(truthiness ? left : toAstRecord(unwrapped.right), isBound); } if (unwrapped.operator === "??") { const nullishness = staticNullishness(left, isBound); if (nullishness !== null) { - return requestHasStaticPart(nullishness ? toAstRecord(unwrapped.right) : left, isBound); + return classifyRequestExpression( + nullishness ? toAstRecord(unwrapped.right) : left, + isBound, + ); } } - return ( - requestHasStaticPart(toAstRecord(unwrapped.left), isBound) || - requestHasStaticPart(toAstRecord(unwrapped.right), 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 false; - if (!expressions.slice(0, -1).every(isSyntacticallySideEffectFree)) return false; - return requestHasStaticPart(expressions.at(-1) ?? null, isBound); + 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 false; + return { kind: "very-dynamic" }; } type ScopeType = "module" | "function" | "block" | "static-block"; @@ -684,7 +708,7 @@ function isVeryDynamicRequireCall(node: AstRecord, isBound: (name: string) => bo const request = singleArgument(node); if (!request) return false; - return !requestHasStaticPart(request, isBound); + return classifyRequestExpression(request, isBound).kind === "very-dynamic"; } function isVeryDynamicImportExpression( @@ -692,7 +716,7 @@ function isVeryDynamicImportExpression( isBound: (name: string) => boolean, ): boolean { if (node.type !== "ImportExpression") return false; - return !requestHasStaticPart(toAstRecord(node.source), isBound); + return classifyRequestExpression(toAstRecord(node.source), isBound).kind === "very-dynamic"; } function dynamicRequireFailureExpression(): string {