From dce388fa948e1d83d5e4359eb74e9f7a5bb197e0 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 22:22:53 +1000 Subject: [PATCH 1/2] 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. --- packages/vinext/src/shims/script.tsx | 169 +++++++++++------- tests/e2e/pages-router/script.spec.ts | 19 ++ .../pages-basic/pages/script-dedupe.tsx | 11 ++ .../pages-basic/public/dedupe-script.js | 1 + 4 files changed, 135 insertions(+), 65 deletions(-) create mode 100644 tests/e2e/pages-router/script.spec.ts create mode 100644 tests/fixtures/pages-basic/pages/script-dedupe.tsx create mode 100644 tests/fixtures/pages-basic/public/dedupe-script.js diff --git a/packages/vinext/src/shims/script.tsx b/packages/vinext/src/shims/script.tsx index 0380b3299..5d9c5c536 100644 --- a/packages/vinext/src/shims/script.tsx +++ b/packages/vinext/src/shims/script.tsx @@ -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(); +const loadingScripts = new Map>(); function getClientAutoNonce(): string | undefined { if (typeof document === "undefined") return undefined; @@ -96,51 +99,115 @@ function buildBeforeInteractiveScriptProps(options: { return scriptProps; } -/** - * Load a script imperatively (outside of React). - */ -export function handleClientScriptLoad(props: ScriptProps): void { +function setScriptAttributes(el: HTMLScriptElement, rest: Record): 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) { + if (key) loadedScripts.add(key); + void existingLoad.then( + (event) => onLoad?.(event), + (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