diff --git a/packages/vinext/src/shims/script.tsx b/packages/vinext/src/shims/script.tsx index 0380b3299..87d81dcf1 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,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): 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( + (event) => { + if (key) loadedScripts.add(key); + onLoad?.(event); + onReady?.(); + }, + (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