Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
64 changes: 60 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,58 @@ 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" || propertyKeyName(property) === "modules"),
);
return loaderProperty?.value;
}

function isFunctionOrImportLoader(node: ESTree.Argument | undefined): boolean {
return (
node?.type === "ArrowFunctionExpression" ||
node?.type === "FunctionExpression" ||
node?.type === "ImportExpression"
);
}
Comment thread
james-elicx marked this conversation as resolved.

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)) return false;
return secondArg === undefined || !hasObjectAccessor(secondArg);
}

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 +675,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
126 changes: 125 additions & 1 deletion tests/build-optimization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1248,6 +1248,97 @@ 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("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 scriptNonceContextPath = path.resolve(
import.meta.dirname,
"../packages/vinext/src/shims/script-nonce-context.tsx",
);
await fsp.writeFile(
entryPath,
`import { withScriptNonce } from ${JSON.stringify(scriptNonceContextPath)};\nglobalThis.__withScriptNonce = withScriptNonce;\n`,
);
Comment thread
james-elicx marked this conversation as resolved.
Outdated

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 +2139,39 @@ 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("preserves existing explicit loadableGenerated metadata", async () => {
Expand Down Expand Up @@ -2391,7 +2515,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