Skip to content
Open
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
90 changes: 86 additions & 4 deletions packages/vinext/src/plugins/dynamic-preload-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Plugin } from "vite";
import type { ESTree, Plugin } from "vite";
import { parseAst } from "vite";
import MagicString from "magic-string";
import path, { toSlash } from "pathslash";
Expand All @@ -10,6 +10,7 @@ import { getAstName, stringLiteralValue, walkAst } from "./ast-utils.js";
import { magicStringTransformResult } from "./transform-result.js";

type AstRecord = Record<string, unknown>;
type AstCallExpression = ESTree.CallExpression & AstRecord;

type TransformResult = {
code: string;
Expand Down Expand Up @@ -72,7 +73,7 @@ function isIdentifierNameInSet(node: unknown, names: Set<string>): boolean {
return getString(node, "type") === "Identifier" && names.has(getString(node, "name") ?? "");
}

function isDynamicCall(node: AstRecord, dynamicLocals: Set<string>): boolean {
function isDynamicCall(node: AstRecord, dynamicLocals: Set<string>): node is AstCallExpression {
if (getString(node, "type") !== "CallExpression") return false;
return isIdentifierNameInSet(node.callee, dynamicLocals);
}
Expand Down Expand Up @@ -227,7 +228,7 @@ function withoutBindings(activeNames: Set<string>, localNames: Set<string>): Set
function visitChildren(
node: AstRecord,
dynamicLocals: Set<string>,
visitor: (node: AstRecord) => void,
visitor: (node: AstCallExpression) => void,
): void {
for (const [key, child] of Object.entries(node)) {
if (key === "parent") continue;
Expand All @@ -244,7 +245,7 @@ function visitChildren(
function visitDynamicCalls(
value: unknown,
dynamicLocals: Set<string>,
visitor: (node: AstRecord) => void,
visitor: (node: AstCallExpression) => void,
): void {
if (!isRecord(value) || dynamicLocals.size === 0) return;

Expand Down Expand Up @@ -381,6 +382,84 @@ function dynamicLoaderNode(firstArg: unknown): unknown {
return loaderProperty?.value;
}

function dynamicLoaderArgument(firstArg: ESTree.Argument | undefined): ESTree.Argument | undefined {
if (firstArg?.type !== "ObjectExpression") return firstArg;

const loaderProperty = firstArg.properties.find(
(property): property is ESTree.ObjectProperty =>
property.type === "Property" && propertyKeyName(property) === "loader",
);
const modulesProperty = firstArg.properties.find(
(property): property is ESTree.ObjectProperty =>
property.type === "Property" && propertyKeyName(property) === "modules",
);
return (loaderProperty ?? modulesProperty)?.value;
}

function hasUncertainObjectProperties(node: ESTree.Argument | undefined): boolean {
if (node?.type !== "ObjectExpression") return false;

let loaderProperties = 0;
let modulesProperties = 0;
for (const property of node.properties) {
if (property.type === "SpreadElement" || property.computed) return true;
const name = propertyKeyName(property);
if (name === "loader") loaderProperties += 1;
if (name === "modules") modulesProperties += 1;
}

return loaderProperties > 1 || modulesProperties > 1;
}

function isFunctionOrImportLoader(node: ESTree.Argument | undefined): boolean {
return (
node?.type === "ArrowFunctionExpression" ||
node?.type === "FunctionExpression" ||
node?.type === "ImportExpression"
);
}

function hasObjectAccessor(node: ESTree.Argument | undefined): boolean {
return (
node?.type === "ObjectExpression" &&
node.properties.some((property) => property.type === "Property" && property.kind !== "init")
);
}

/**
* A pure annotation suppresses effects from inside `dynamic()`, while argument
* evaluation remains observable. Limit the hint to loader shapes that
* `dynamic()` does not execute eagerly and plain options whose property reads
* cannot invoke accessors.
*/
function canAnnotateDynamicCallAsPure(callNode: AstCallExpression): boolean {
const [firstArg, secondArg] = callNode.arguments;
if (!isFunctionOrImportLoader(dynamicLoaderArgument(firstArg))) return false;
if (hasObjectAccessor(firstArg) || hasUncertainObjectProperties(firstArg)) return false;
if (secondArg === undefined) return true;

// normalizeDynamicOptions() spreads the second argument over the first, so an
// unknown options object or a `loader` property can replace the loader proved
// safe above. In particular, replacing it with a loader map makes dynamic()
// throw; marking that call pure would let the bundler erase the validation.
if (secondArg.type !== "ObjectExpression") return false;
if (hasObjectAccessor(secondArg) || hasUncertainObjectProperties(secondArg)) return false;
return !hasObjectProperty(secondArg, "loader");
}

function annotateCallAsPure(
output: MagicString,
code: string,
callNode: AstCallExpression,
): boolean {
// Avoid stacking our hint on top of a user/compiler-provided annotation.
const prefix = code.slice(Math.max(0, callNode.start - 80), callNode.start);
if (/\/\*\s*[#@]__PURE__\s*\*\/\s*$/.test(prefix)) return false;

output.prependLeft(callNode.start, "/* @__PURE__ */ ");
return true;
}

function findLastEndedProperty(node: AstRecord): AstRecord | null {
const properties = objectProperties(node);
for (let index = properties.length - 1; index >= 0; index -= 1) {
Expand Down Expand Up @@ -622,6 +701,9 @@ export async function transformNextDynamicPreloadMetadata(
resolveManifestModuleIds(specifiers, id, root, resolveDynamicImport).then((moduleIds) => {
if (moduleIds.length === 0) return;
if (applyLoadableGenerated(output, code, node, moduleIds)) {
if (canAnnotateDynamicCallAsPure(node)) {
annotateCallAsPure(output, code, node);
}
changed = true;
}
}),
Expand Down
1 change: 1 addition & 0 deletions packages/vinext/src/shims/script-nonce-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function withScriptNonce(element: React.ReactElement, nonce?: string): Re
return React.createElement(ScriptNonceProvider, { nonce }, element);
}

/* @__NO_SIDE_EFFECTS__ */
function createScriptNonceHook(context: typeof ScriptNonceContext): () => string | undefined {
if (!context || typeof React.useContext !== "function") {
return function useScriptNonceFromContext(): string | undefined {
Expand Down
202 changes: 201 additions & 1 deletion tests/build-optimization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { describe, it, expect, beforeEach, afterEach } from "vite-plus/test";
import { createBuilder, parseAst } from "vite";
import { augmentSsrManifestFromBundle as _augmentSsrManifestFromBundle } from "../packages/vinext/src/build/ssr-manifest.js";
Expand Down Expand Up @@ -1248,6 +1249,149 @@ export default function Page() {

// ─── Remaining treeshake config integration ─────────────────────────────

describe("no-side-effect annotations", () => {
it("tree-shakes unused transformed dynamic calls", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-dynamic-dce-"));
const entryPath = path.join(tmpDir, "entry.js");
const widgetPath = path.join(tmpDir, "widget.js");
const outDir = path.join(tmpDir, "dist");
const source = [
`import dynamic from "next/dynamic";`,
`const Unused = dynamic(() => import("./widget.js"));`,
`globalThis.__entryMarker = true;`,
].join("\n");
const transformed = await _transformNextDynamicPreloadMetadata(
source,
entryPath,
tmpDir,
async (specifier) => (specifier === "./widget.js" ? widgetPath : null),
);
expect(transformed?.code).toContain("/* @__PURE__ */ dynamic(");

await fsp.writeFile(
path.join(tmpDir, "dynamic.js"),
`export default function dynamic(loader) { globalThis.__dynamicCallMarker = loader; return loader; }\n`,
);
await fsp.writeFile(widgetPath, `globalThis.__widgetMarker = true;\n`);
await fsp.writeFile(entryPath, transformed!.code.replace(`"next/dynamic"`, `"./dynamic.js"`));

try {
const builder = await createBuilder({
root: tmpDir,
configFile: false,
logLevel: "silent",
build: {
outDir,
minify: false,
rolldownOptions: {
input: entryPath,
output: { entryFileNames: "entry.js" },
},
},
});
await builder.buildApp();

const output = await fsp.readFile(path.join(outDir, "entry.js"), "utf8");
expect(output).toContain("__entryMarker");
expect(output).not.toContain("__dynamicCallMarker");
expect(output).not.toContain("__widgetMarker");
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 15_000);

it("preserves an eager import when its unused dynamic call is tree-shaken", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-dynamic-eager-dce-"));
const entryPath = path.join(tmpDir, "entry.js");
const widgetPath = path.join(tmpDir, "widget.js");
const outDir = path.join(tmpDir, "dist");
const source = [
`import dynamic from "next/dynamic";`,
`const Unused = dynamic(import("./widget.js"));`,
`globalThis.__entryMarker = true;`,
].join("\n");
const transformed = await _transformNextDynamicPreloadMetadata(
source,
entryPath,
tmpDir,
async (specifier) => (specifier === "./widget.js" ? widgetPath : null),
);
expect(transformed?.code).toContain("/* @__PURE__ */ dynamic(import(");

await fsp.writeFile(
path.join(tmpDir, "dynamic.js"),
`export default function dynamic(loader) { globalThis.__dynamicCallMarker = loader; return loader; }\n`,
);
await fsp.writeFile(widgetPath, `globalThis.__widgetMarker = true;\n`);
await fsp.writeFile(entryPath, transformed!.code.replace(`"next/dynamic"`, `"./dynamic.js"`));

try {
const builder = await createBuilder({
root: tmpDir,
configFile: false,
logLevel: "silent",
build: {
outDir,
minify: false,
rolldownOptions: {
input: entryPath,
output: { entryFileNames: "entry.js", chunkFileNames: "[name].js" },
},
},
});
await builder.buildApp();

const entryOutput = await fsp.readFile(path.join(outDir, "entry.js"), "utf8");
expect(entryOutput).toContain("__entryMarker");
expect(entryOutput).not.toContain("__dynamicCallMarker");
expect(entryOutput).toContain("import(");

const widgetOutput = await fsp.readFile(path.join(outDir, "widget.js"), "utf8");
expect(widgetOutput).toContain("__widgetMarker");
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 15_000);

it("tree-shakes unused script nonce hook setup", async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-script-nonce-dce-"));
const entryPath = path.join(tmpDir, "entry.js");
const outDir = path.join(tmpDir, "dist");
const scriptNonceContextUrl = pathToFileURL(
path.resolve(import.meta.dirname, "../packages/vinext/src/shims/script-nonce-context.tsx"),
).href;
await fsp.writeFile(
entryPath,
`import { withScriptNonce } from ${JSON.stringify(scriptNonceContextUrl)};\nglobalThis.__withScriptNonce = withScriptNonce;\n`,
);

try {
const builder = await createBuilder({
root: tmpDir,
configFile: false,
logLevel: "silent",
build: {
outDir,
minify: false,
rolldownOptions: {
input: entryPath,
external: ["react"],
output: { entryFileNames: "entry.js" },
},
},
});
await builder.buildApp();

const output = await fsp.readFile(path.join(outDir, "entry.js"), "utf8");
expect(output).toContain("withScriptNonce");
expect(output).not.toContain("createScriptNonceHook");
expect(output).not.toContain("useScriptNonceFromContext");
} finally {
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}, 15_000);
});

describe("treeshake config integration", () => {
it("plugin config hook applies treeshake to non-SSR builds", async () => {
const vinext = (await import("../packages/vinext/src/index.js")).default;
Expand Down Expand Up @@ -2048,6 +2192,62 @@ describe("next/dynamic preload metadata transform", () => {
);

expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic-widget.tsx"] }`);
expect(result?.code).toContain(`const Widget = /* @__PURE__ */ dynamic(`);
});

it("does not duplicate an existing pure annotation", async () => {
const result = await _transformNextDynamicPreloadMetadata(
[
`import dynamic from "next/dynamic";`,
`const Widget = /*#__PURE__*/ dynamic(() => import("./dynamic-widget"));`,
].join("\n"),
importer,
root,
resolveDynamicImport,
);

expect(result?.code.match(/__PURE__/g)).toHaveLength(1);
expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic-widget.tsx"] }`);
});

it("does not mark dynamic calls with accessor options as pure", async () => {
const result = await _transformNextDynamicPreloadMetadata(
[
`import dynamic from "next/dynamic";`,
`const Widget = dynamic(() => import("./dynamic-widget"), {`,
` get loading() { recordOptionRead(); return Loading; },`,
`});`,
].join("\n"),
importer,
root,
resolveDynamicImport,
);

expect(result?.code).not.toContain("__PURE__");
expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic-widget.tsx"] }`);
});

it("does not mark dynamic calls with loader overrides as pure", async () => {
const calls = [
`dynamic({ loader: () => import("./dynamic-widget"), ...overrides })`,
`dynamic({ loader: () => import("./dynamic-widget"), ["loader"]: unsupportedLoader })`,
`dynamic({ loader: () => import("./dynamic-widget"), loader: unsupportedLoader })`,
`dynamic({ modules: () => import("./dynamic-widget"), loader: { Widget: () => import("./dynamic-widget") } })`,
`dynamic(() => import("./dynamic-widget"), { loader: unsupportedLoader })`,
`dynamic(() => import("./dynamic-widget"), { ...overrides })`,
];

for (const call of calls) {
const result = await _transformNextDynamicPreloadMetadata(
[`import dynamic from "next/dynamic";`, `const Widget = ${call};`].join("\n"),
importer,
root,
resolveDynamicImport,
);

expect(result?.code).not.toContain("__PURE__");
expect(result?.code).toContain(`loadableGenerated: { modules: ["app/dynamic-widget.tsx"] }`);
}
});

it("preserves existing explicit loadableGenerated metadata", async () => {
Expand Down Expand Up @@ -2391,7 +2591,7 @@ describe("next/dynamic preload metadata transform", () => {
expect(result?.code).toBe(
[
`import dynamic from "next/dynamic";`,
`const W = dynamic(() => import("./dynamic-widget"), { loadableGenerated: { modules: ["app/dynamic-widget.tsx"] } });`,
`const W = /* @__PURE__ */ dynamic(() => import("./dynamic-widget"), { loadableGenerated: { modules: ["app/dynamic-widget.tsx"] } });`,
].join("\n"),
);
expect(firstDynamicCallArgTypes(result!.code)).toEqual([
Expand Down
Loading