Skip to content

fix(script): dedupe concurrent same-src loads#1263

Merged
james-elicx merged 2 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/fix-script-load-dedupe
May 16, 2026
Merged

fix(script): dedupe concurrent same-src loads#1263
james-elicx merged 2 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/fix-script-load-dedupe

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Overview

Area Detail
Goal Prevent duplicate execution when multiple next/script components request the same remote script before it finishes loading.
Core change Add an in-flight script cache keyed by src, separate from the completed-load cache.
Primary files packages/vinext/src/shims/script.tsx, tests/e2e/pages-router/script.spec.ts
Expected impact Same-src scripts mount as one DOM script while later callers still receive load/error callbacks.

Why

next/script must treat a remote script request as owned by its src, including the interval between DOM insertion and the browser load event. Without an in-flight cache, simultaneous components can all miss the completed-load cache and each append a <script>, which double-executes third-party code.

Area Principle / invariant What this PR changes
Client script loading A same-src remote script should be inserted once, even while loading. Tracks loadingScripts promises separately from loadedScripts.
Callback behavior Duplicate callers should not create duplicate DOM nodes. Attaches duplicate callers to the existing load promise for onLoad and onError.
Next.js compatibility Follow Next.js' ScriptCache / LoadCache split. Mirrors the relevant cache shape in vinext's smaller shim.

What changed

Scenario Before After
Two same-src scripts mount together Two DOM scripts can be appended and executed. One DOM script is appended and executed once.
A later same-src caller arrives while the first is loading It can bypass loadedScripts. It reuses the existing load promise.
Maintainer review path
  1. packages/vinext/src/shims/script.tsx: inspect the split between completed loads and in-flight remote loads, plus the shared loadClientScript path used by both component and imperative loading.
  2. tests/fixtures/pages-basic/pages/script-dedupe.tsx: minimal reproduction page with two simultaneous same-src scripts.
  3. tests/e2e/pages-router/script.spec.ts: browser assertion for one DOM script and one execution.
Validation
  • vp test run tests/script.test.ts
  • PLAYWRIGHT_PROJECT=pages-router npx playwright test tests/e2e/pages-router/script.spec.ts
  • vp check

The new Playwright regression failed before the fix with two script[src="/dedupe-script.js"] nodes, then passed after the in-flight cache was added.

Risk / compatibility
  • Public API: no API shape changes.
  • Runtime: client-side next/script remote loading now matches Next.js' same-src de-duplication semantics more closely.
  • Existing apps: the intended behavior removes duplicate third-party script execution. Duplicate same-src callers still get load/error callbacks through the existing promise.

References

Reference Why it matters
Next.js ScriptCache and LoadCache Upstream implementation tracks in-flight remote scripts separately from loaded scripts.
Next.js script loader tests Covers next/script duplicate and readiness behavior around script loading.
Next.js next/script docs Public component semantics for loading third-party scripts.

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.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 16, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1263

commit: fe2bba7

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Good refactor — extracting loadClientScript to share between the component and imperative paths is the right call, and the in-flight dedup cache mirrors the Next.js ScriptCache / LoadCache split correctly. The test fixture and Playwright spec are clean.

Three issues to address, one correctness bug and two behavioral gaps:

  1. Bug: premature loadedScripts.add(key) on in-flight join — marking the key loaded before the promise settles means a third+ caller arriving before load completes skips attaching callbacks entirely.
  2. loadingScripts never cleaned up — settled promises (especially rejected ones) accumulate forever, preventing retry after transient failures.
  3. onReady missing for duplicate callers — the in-flight join path fires onLoad/onError but not onReady, diverging from both the first-caller path and Next.js behavior.

Comment thread packages/vinext/src/shims/script.tsx Outdated
if (src) {
const existingLoad = loadingScripts.get(src);
if (existingLoad) {
if (key) loadedScripts.add(key);
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.

Bug: this marks the key as loaded immediately, before the script has actually finished loading. If a third caller arrives while the script is still in-flight, it will hit the loadedScripts.has(key) early return at line 136, skip attaching onLoad/onError callbacks, and (if fireReadyWhenAlreadyLoaded is true) fire onReady prematurely — before the script has executed.

Defer this into the .then() success handler:

Suggested change
if (key) loadedScripts.add(key);
void existingLoad.then(
(event) => {
if (key) loadedScripts.add(key);
onLoad?.(event);
onReady?.();
},
(event) => onError?.(event),
);

This also fixes issue #3 below (onReady missing for duplicate callers) — see the onReady?.() call added in the success branch.

});
});
loadPromise.catch(() => undefined);
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.

const existingLoad = loadingScripts.get(src);
if (existingLoad) {
if (key) loadedScripts.add(key);
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.

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.

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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted. Here's a summary of the findings:

Review Summary for PR #1263

Overall: Good refactor that correctly identifies the dedup gap and mirrors Next.js's ScriptCache/LoadCache split. The shared loadClientScript extraction and test coverage are solid. Three issues need fixing:

1. Bug: Premature loadedScripts.add(key) on in-flight join (line 146)

When a duplicate caller joins an in-flight load, the key is immediately added to loadedScripts before the script has actually loaded. A third caller arriving later would hit the early return at line 136, skip attaching its callbacks, and potentially fire onReady before the script executes. Fix: defer loadedScripts.add(key) into the .then() success handler.

2. loadingScripts never cleaned up (line 197)

Settled promises accumulate forever. More critically, after a load error, the rejected promise stays in the map — any future retry of the same src joins the already-rejected promise and immediately fails. Fix: delete the entry on both resolve and reject.

3. onReady missing for duplicate callers (line 147)

The in-flight join path calls onLoad/onError but never onReady, diverging from the first-caller path (line 189) and Next.js behavior. Fix: add onReady?.() in the success branch of the .then().

github run

- 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
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Clean refactor that correctly mirrors the Next.js ScriptCache/LoadCache split. The three issues from the previous review (premature loadedScripts.add, missing loadingScripts cleanup, missing onReady for duplicate callers) are all addressed in the second commit.

The loadClientScript extraction is a nice win — it eliminates the duplicated DOM manipulation logic between the component and imperative paths.

Two small observations (neither blocking):

});
});
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.

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.

(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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted. Here's the summary:

Approved PR #1263 — The script dedup fix is solid. All three issues from the prior review round are resolved:

  1. loadedScripts.add(key) is now deferred into the .then() success handler for in-flight joins
  2. loadingScripts is cleaned up via .finally() on promise settlement, enabling retry after failure
  3. onReady is now fired for duplicate callers in the in-flight join path

Left two non-blocking comments:

  • A cosmetic suggestion to swap lines 199–200 so the loadingScripts.set() reads before the cleanup chain (runtime-identical, just clearer intent)
  • A positive note that firing onReady for duplicate callers is actually more correct than upstream Next.js, which silently drops it

github run

@james-elicx james-elicx merged commit d558923 into cloudflare:main May 16, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants