Skip to content

Commit d558923

Browse files
fix(script): dedupe concurrent same-src loads (#1263)
* fix(script): dedupe concurrent same-src loads Multiple next/script components with the same src could append duplicate DOM scripts when they mounted before the first script load event fired. That double-executed third-party code and diverged from Next.js' in-flight script cache behavior. The shim now tracks remote scripts that are currently loading separately from scripts that have completed loading, and fans out load/error callbacks without adding another script element. Adds a Pages Router browser regression that verifies simultaneous same-src scripts produce one DOM script and one execution. * fix: address review feedback for script load dedupe - Defer loadedScripts.add(key) to after in-flight promise resolves - Add onReady() callback for duplicate callers joining in-flight load - Clean up loadingScripts entry on promise settlement to allow retry
1 parent ff46514 commit d558923

4 files changed

Lines changed: 138 additions & 65 deletions

File tree

packages/vinext/src/shims/script.tsx

Lines changed: 107 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,11 @@ export type ScriptProps = {
4949
[key: string]: unknown;
5050
};
5151

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

5558
function getClientAutoNonce(): string | undefined {
5659
if (typeof document === "undefined") return undefined;
@@ -96,51 +99,118 @@ function buildBeforeInteractiveScriptProps(options: {
9699
return scriptProps;
97100
}
98101

99-
/**
100-
* Load a script imperatively (outside of React).
101-
*/
102-
export function handleClientScriptLoad(props: ScriptProps): void {
102+
function setScriptAttributes(el: HTMLScriptElement, rest: Record<string, unknown>): void {
103+
for (const [attr, value] of Object.entries(rest)) {
104+
if (attr === "dangerouslySetInnerHTML") continue;
105+
if (attr === "className") {
106+
el.setAttribute("class", String(value));
107+
} else if (typeof value === "string") {
108+
el.setAttribute(attr, value);
109+
} else if (typeof value === "boolean" && value) {
110+
el.setAttribute(attr, "");
111+
}
112+
}
113+
}
114+
115+
function loadClientScript(
116+
props: ScriptProps,
117+
options: {
118+
resolvedNonce?: string;
119+
fireReadyWhenAlreadyLoaded: boolean;
120+
},
121+
): void {
103122
const {
104123
src,
105124
id,
106125
onLoad,
126+
onReady,
107127
onError,
108-
strategy: _strategy,
109-
onReady: _onReady,
128+
strategy = "afterInteractive",
110129
children,
130+
dangerouslySetInnerHTML,
111131
...rest
112132
} = props;
113133
if (typeof window === "undefined") return;
114134

115135
const key = id ?? src ?? "";
116-
if (key && loadedScripts.has(key)) return;
136+
if (key && loadedScripts.has(key)) {
137+
if (options.fireReadyWhenAlreadyLoaded) {
138+
onReady?.();
139+
}
140+
return;
141+
}
142+
143+
if (src) {
144+
const existingLoad = loadingScripts.get(src);
145+
if (existingLoad) {
146+
void existingLoad.then(
147+
(event) => {
148+
if (key) loadedScripts.add(key);
149+
onLoad?.(event);
150+
onReady?.();
151+
},
152+
(event) => onError?.(event),
153+
);
154+
return;
155+
}
156+
}
117157

118158
const el = document.createElement("script");
119159
if (src) el.src = src;
120160
if (id) el.id = id;
121-
const resolvedNonce = resolveScriptNonce(rest.nonce);
122161

123-
for (const [attr, value] of Object.entries(rest)) {
124-
if (attr === "dangerouslySetInnerHTML" || attr === "className") continue;
125-
if (typeof value === "string") {
126-
el.setAttribute(attr, value);
127-
} else if (typeof value === "boolean" && value) {
128-
el.setAttribute(attr, "");
129-
}
162+
setScriptAttributes(el, rest);
163+
if (options.resolvedNonce && !el.getAttribute("nonce")) {
164+
el.setAttribute("nonce", options.resolvedNonce);
130165
}
131-
if (resolvedNonce && !el.getAttribute("nonce")) {
132-
el.setAttribute("nonce", resolvedNonce);
166+
167+
if (strategy === "worker") {
168+
el.setAttribute("type", "text/partytown");
133169
}
134170

135-
if (children && typeof children === "string") {
171+
const markLoaded = () => {
172+
if (key) loadedScripts.add(key);
173+
onReady?.();
174+
};
175+
176+
if (dangerouslySetInnerHTML?.__html) {
177+
// Intentional: mirrors the Next.js <Script> API where dangerouslySetInnerHTML
178+
// is developer-supplied inline script content (not user input). The prop name
179+
// itself signals developer awareness of the XSS risk, consistent with React's
180+
// design. User-supplied data must never flow into this prop.
181+
el.innerHTML = dangerouslySetInnerHTML.__html;
182+
markLoaded();
183+
} else if (children && typeof children === "string") {
136184
el.textContent = children;
185+
markLoaded();
186+
} else if (src) {
187+
const loadPromise = new Promise<Event>((resolve, reject) => {
188+
el.addEventListener("load", (event) => {
189+
resolve(event);
190+
if (key) loadedScripts.add(key);
191+
onLoad?.(event);
192+
onReady?.();
193+
});
194+
el.addEventListener("error", (event) => {
195+
reject(event);
196+
onError?.(event);
197+
});
198+
});
199+
loadPromise.catch(() => undefined).finally(() => loadingScripts.delete(src));
200+
loadingScripts.set(src, loadPromise);
137201
}
138202

139-
if (onLoad) el.addEventListener("load", onLoad);
140-
if (onError) el.addEventListener("error", onError);
141-
142203
document.body.appendChild(el);
143-
if (key) loadedScripts.add(key);
204+
}
205+
206+
/**
207+
* Load a script imperatively (outside of React).
208+
*/
209+
export function handleClientScriptLoad(props: ScriptProps): void {
210+
loadClientScript(props, {
211+
resolvedNonce: resolveScriptNonce(props.nonce),
212+
fireReadyWhenAlreadyLoaded: false,
213+
});
144214
}
145215

146216
/**
@@ -192,48 +262,20 @@ function Script(props: ScriptProps): React.ReactElement | null {
192262
return;
193263
}
194264

195-
const el = document.createElement("script");
196-
if (src) el.src = src;
197-
if (id) el.id = id;
198-
199-
for (const [attr, value] of Object.entries(rest)) {
200-
if (attr === "className") {
201-
el.setAttribute("class", String(value));
202-
} else if (typeof value === "string") {
203-
el.setAttribute(attr, value);
204-
} else if (typeof value === "boolean" && value) {
205-
el.setAttribute(attr, "");
206-
}
207-
}
208-
if (resolvedNonce && !el.getAttribute("nonce")) {
209-
el.setAttribute("nonce", resolvedNonce);
210-
}
211-
212-
if (strategy === "worker") {
213-
el.setAttribute("type", "text/partytown");
214-
}
215-
216-
if (dangerouslySetInnerHTML?.__html) {
217-
// Intentional: mirrors the Next.js <Script> API where dangerouslySetInnerHTML
218-
// is developer-supplied inline script content (not user input). The prop name
219-
// itself signals developer awareness of the XSS risk, consistent with React's
220-
// design. User-supplied data must never flow into this prop.
221-
el.innerHTML = dangerouslySetInnerHTML.__html as string;
222-
} else if (children && typeof children === "string") {
223-
el.textContent = children;
224-
}
225-
226-
el.addEventListener("load", (e) => {
227-
if (key) loadedScripts.add(key);
228-
onLoad?.(e);
229-
onReady?.();
230-
});
231-
232-
if (onError) {
233-
el.addEventListener("error", onError);
234-
}
235-
236-
document.body.appendChild(el);
265+
loadClientScript(
266+
{
267+
src,
268+
id,
269+
strategy,
270+
onLoad,
271+
onReady,
272+
onError,
273+
children,
274+
dangerouslySetInnerHTML,
275+
...rest,
276+
},
277+
{ resolvedNonce, fireReadyWhenAlreadyLoaded: true },
278+
);
237279
};
238280

239281
if (strategy === "lazyOnload") {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { test, expect } from "@playwright/test";
2+
3+
const BASE = "http://localhost:4173";
4+
5+
test.describe("next/script", () => {
6+
// Ported from Next.js: packages/next/src/client/script.tsx
7+
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/script.tsx
8+
// Next.js keeps a ScriptCache for in-flight remote scripts so same-src
9+
// components mounted together only append one DOM script.
10+
test("deduplicates simultaneous same-src scripts before load completes", async ({ page }) => {
11+
await page.goto(`${BASE}/script-dedupe`);
12+
await expect(page.getByRole("heading", { name: "Script Dedupe" })).toBeVisible();
13+
14+
await expect.poll(() => page.locator('script[src="/dedupe-script.js"]').count()).toBe(1);
15+
await expect
16+
.poll(() => page.evaluate(() => Reflect.get(window, "__vinextScriptDedupeExecutions")))
17+
.toBe(1);
18+
});
19+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Script from "next/script";
2+
3+
export default function ScriptDedupePage() {
4+
return (
5+
<main>
6+
<h1>Script Dedupe</h1>
7+
<Script src="/dedupe-script.js" />
8+
<Script src="/dedupe-script.js" />
9+
</main>
10+
);
11+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
window.__vinextScriptDedupeExecutions = (window.__vinextScriptDedupeExecutions || 0) + 1;

0 commit comments

Comments
 (0)