Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand Down
230 changes: 230 additions & 0 deletions packages/vinext/src/plugins/ignore-dynamic-requests.ts
Original file line number Diff line number Diff line change
@@ -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<MagicString["generateMap"]>;
};

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<typeof parseAst>;
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);
},
};
}
105 changes: 105 additions & 0 deletions tests/build-optimization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<html lang="en">
<body>{children}</body>
</html>
);
}
`,
);

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 <p>Hello World</p>;
}

${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", () => {
Expand Down
Loading