Skip to content
Merged
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
172 changes: 107 additions & 65 deletions packages/vinext/src/shims/script.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@ export type ScriptProps = {
[key: string]: unknown;
};

// Track scripts that have already been loaded to avoid duplicates
// Track scripts that have already been loaded, plus remote scripts currently
// loading, to avoid duplicate DOM insertion when same-src components mount
// before the first load event fires.
const loadedScripts = new Set<string>();
const loadingScripts = new Map<string, Promise<Event>>();

function getClientAutoNonce(): string | undefined {
if (typeof document === "undefined") return undefined;
Expand Down Expand Up @@ -96,51 +99,118 @@ function buildBeforeInteractiveScriptProps(options: {
return scriptProps;
}

/**
* Load a script imperatively (outside of React).
*/
export function handleClientScriptLoad(props: ScriptProps): void {
function setScriptAttributes(el: HTMLScriptElement, rest: Record<string, unknown>): void {
for (const [attr, value] of Object.entries(rest)) {
if (attr === "dangerouslySetInnerHTML") continue;
if (attr === "className") {
el.setAttribute("class", String(value));
} else if (typeof value === "string") {
el.setAttribute(attr, value);
} else if (typeof value === "boolean" && value) {
el.setAttribute(attr, "");
}
}
}

function loadClientScript(
props: ScriptProps,
options: {
resolvedNonce?: string;
fireReadyWhenAlreadyLoaded: boolean;
},
): void {
const {
src,
id,
onLoad,
onReady,
onError,
strategy: _strategy,
onReady: _onReady,
strategy = "afterInteractive",
children,
dangerouslySetInnerHTML,
...rest
} = props;
if (typeof window === "undefined") return;

const key = id ?? src ?? "";
if (key && loadedScripts.has(key)) return;
if (key && loadedScripts.has(key)) {
if (options.fireReadyWhenAlreadyLoaded) {
onReady?.();
}
return;
}

if (src) {
const existingLoad = loadingScripts.get(src);
if (existingLoad) {
void existingLoad.then(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The in-flight join path calls onLoad on success and onError on failure, but never calls onReady. Compare with the first-caller path at line 189 which calls onReady?.() after onLoad. Next.js fires onReady for every caller after load completes — this diverges.

See the suggestion on line 146 above which addresses this together with the premature loadedScripts.add fix.

(event) => {
if (key) loadedScripts.add(key);
onLoad?.(event);
onReady?.();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting: this is actually more correct than Next.js, which silently drops onReady for duplicate callers who chain onto an in-flight load (their tier-2 path only does .then(onLoad, onError)). Good divergence.

},
(event) => onError?.(event),
);
return;
}
}

const el = document.createElement("script");
if (src) el.src = src;
if (id) el.id = id;
const resolvedNonce = resolveScriptNonce(rest.nonce);

for (const [attr, value] of Object.entries(rest)) {
if (attr === "dangerouslySetInnerHTML" || attr === "className") continue;
if (typeof value === "string") {
el.setAttribute(attr, value);
} else if (typeof value === "boolean" && value) {
el.setAttribute(attr, "");
}
setScriptAttributes(el, rest);
if (options.resolvedNonce && !el.getAttribute("nonce")) {
el.setAttribute("nonce", options.resolvedNonce);
}
if (resolvedNonce && !el.getAttribute("nonce")) {
el.setAttribute("nonce", resolvedNonce);

if (strategy === "worker") {
el.setAttribute("type", "text/partytown");
}

if (children && typeof children === "string") {
const markLoaded = () => {
if (key) loadedScripts.add(key);
onReady?.();
};

if (dangerouslySetInnerHTML?.__html) {
// Intentional: mirrors the Next.js <Script> API where dangerouslySetInnerHTML
// is developer-supplied inline script content (not user input). The prop name
// itself signals developer awareness of the XSS risk, consistent with React's
// design. User-supplied data must never flow into this prop.
el.innerHTML = dangerouslySetInnerHTML.__html;
markLoaded();
} else if (children && typeof children === "string") {
el.textContent = children;
markLoaded();
} else if (src) {
const loadPromise = new Promise<Event>((resolve, reject) => {
el.addEventListener("load", (event) => {
resolve(event);
if (key) loadedScripts.add(key);
onLoad?.(event);
onReady?.();
});
el.addEventListener("error", (event) => {
reject(event);
onError?.(event);
});
});
loadPromise.catch(() => undefined).finally(() => loadingScripts.delete(src));
loadingScripts.set(src, loadPromise);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loadingScripts map is never cleaned up after load or error. Two problems:

  1. Memory leak — every unique src accumulates a settled Promise for the lifetime of the page.
  2. No retry after failure — if a script fails to load (network error, 404), the rejected promise stays in the map forever. Any future attempt to load the same src will join the already-rejected promise and immediately fail, even if the resource is now available.

Clean up on both settle paths:

Suggested change
loadingScripts.set(src, loadPromise);
loadingScripts.set(src, loadPromise);
void loadPromise.then(
() => loadingScripts.delete(src),
() => loadingScripts.delete(src),
);

Alternatively, use .finally() if the project's TS target supports it. Note that the no-op .catch() on line 196 is still needed to suppress the unhandled rejection, so keep both.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: set runs after .catch().finally() is chained, which is correct — but the visual ordering reads like "clean up, then register". Consider swapping lines 199–200 so the map insertion comes first and the cleanup chain second:

Suggested change
loadingScripts.set(src, loadPromise);
loadingScripts.set(src, loadPromise);
loadPromise.catch(() => undefined).finally(() => loadingScripts.delete(src));

Purely cosmetic — the runtime behavior is identical since .finally() always runs as a microtask.

}

if (onLoad) el.addEventListener("load", onLoad);
if (onError) el.addEventListener("error", onError);

document.body.appendChild(el);
if (key) loadedScripts.add(key);
}

/**
* Load a script imperatively (outside of React).
*/
export function handleClientScriptLoad(props: ScriptProps): void {
loadClientScript(props, {
resolvedNonce: resolveScriptNonce(props.nonce),
fireReadyWhenAlreadyLoaded: false,
});
}

/**
Expand Down Expand Up @@ -192,48 +262,20 @@ function Script(props: ScriptProps): React.ReactElement | null {
return;
}

const el = document.createElement("script");
if (src) el.src = src;
if (id) el.id = id;

for (const [attr, value] of Object.entries(rest)) {
if (attr === "className") {
el.setAttribute("class", String(value));
} else if (typeof value === "string") {
el.setAttribute(attr, value);
} else if (typeof value === "boolean" && value) {
el.setAttribute(attr, "");
}
}
if (resolvedNonce && !el.getAttribute("nonce")) {
el.setAttribute("nonce", resolvedNonce);
}

if (strategy === "worker") {
el.setAttribute("type", "text/partytown");
}

if (dangerouslySetInnerHTML?.__html) {
// Intentional: mirrors the Next.js <Script> API where dangerouslySetInnerHTML
// is developer-supplied inline script content (not user input). The prop name
// itself signals developer awareness of the XSS risk, consistent with React's
// design. User-supplied data must never flow into this prop.
el.innerHTML = dangerouslySetInnerHTML.__html as string;
} else if (children && typeof children === "string") {
el.textContent = children;
}

el.addEventListener("load", (e) => {
if (key) loadedScripts.add(key);
onLoad?.(e);
onReady?.();
});

if (onError) {
el.addEventListener("error", onError);
}

document.body.appendChild(el);
loadClientScript(
{
src,
id,
strategy,
onLoad,
onReady,
onError,
children,
dangerouslySetInnerHTML,
...rest,
},
{ resolvedNonce, fireReadyWhenAlreadyLoaded: true },
);
};

if (strategy === "lazyOnload") {
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/pages-router/script.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { test, expect } from "@playwright/test";

const BASE = "http://localhost:4173";

test.describe("next/script", () => {
// Ported from Next.js: packages/next/src/client/script.tsx
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the reference comment says "Ported from" packages/next/src/client/script.tsx, which is the implementation file, not a test. Consider updating to reference the actual Next.js test file:

Suggested change
// Ported from Next.js: packages/next/src/client/script.tsx
// Mirrors Next.js ScriptCache behavior in packages/next/src/client/script.tsx
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/script.tsx

Or reference the Next.js E2E test if there's a specific dedup test there.

// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/script.tsx
// Next.js keeps a ScriptCache for in-flight remote scripts so same-src
// components mounted together only append one DOM script.
test("deduplicates simultaneous same-src scripts before load completes", async ({ page }) => {
await page.goto(`${BASE}/script-dedupe`);
await expect(page.getByRole("heading", { name: "Script Dedupe" })).toBeVisible();

await expect.poll(() => page.locator('script[src="/dedupe-script.js"]').count()).toBe(1);
await expect
.poll(() => page.evaluate(() => Reflect.get(window, "__vinextScriptDedupeExecutions")))
.toBe(1);
});
});
11 changes: 11 additions & 0 deletions tests/fixtures/pages-basic/pages/script-dedupe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Script from "next/script";

export default function ScriptDedupePage() {
return (
<main>
<h1>Script Dedupe</h1>
<Script src="/dedupe-script.js" />
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider also testing the onLoad / onReady callback dedup behavior — e.g., verify that both <Script> components receive their onLoad callbacks even though only one DOM node is created. That would catch the onReady gap flagged above. Could be a follow-up test.

<Script src="/dedupe-script.js" />
</main>
);
}
1 change: 1 addition & 0 deletions tests/fixtures/pages-basic/public/dedupe-script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
window.__vinextScriptDedupeExecutions = (window.__vinextScriptDedupeExecutions || 0) + 1;
Loading