Files
Nathan Nguyen 8edc010a10 fix(fetch-cache): dedupe identical render fetches (#1134)
* fix(fetch-cache): dedupe identical render fetches

Uncached and no-store fetches currently bypass the persistent fetch cache by calling the original fetch directly. That diverges from Next.js, where the patched fetch wraps the original fetch in a request-scoped dedupe layer before persistent cache policy runs, so repeated GET and HEAD fetches during a render share one network response.

Add request-scoped fetch dedupe state to the fetch cache context and route network misses and cache-bypass fetches through it. The dedupe key follows the Next.js method, header, mode, redirect, credentials, referrer, referrerPolicy, and integrity semantics while still opting out for abort signals, keepalive, and side-effecting methods.

Update fetch-cache coverage for uncached render dedupe, independent response bodies, request-scope isolation, trace header exclusion, and the changed no-store/no-cache interaction.

* fix(fetch-cache): give each cloned dedupe response its own Headers

Pass `new Headers(response.headers)` per clone instead of sharing the same
Headers object across both branches and the dedupe entry. Matches Next.js'
`cloneResponse` and avoids a future-correctness trap if any code path mutates
response headers post-fetch.

Also drops two redundant `runWithFetchDedupe` calls inside `dispatchAppPage`
(the ISR revalidation render path and the intercept stream render path) — both
sites are inline anonymous functions that already inherit the dedupe scope from
the outer `dispatchAppPage` / `runAppPageRevalidationContext` wrap, so the
inner calls were no-ops and just obscured that inheritance.

* test(fetch-cache): cover Request input dedupe + always clone bodyless responses

Always construct fresh Response objects in cloneDedupeResponse, including the
bodyless path that previously returned the same `response` reference for both
tuple slots. Mirrors Next.js' cloneResponse and avoids any "disturbed response"
surprise if a runtime tracks consumption state on the shared reference. Factor
the per-clone construction into buildDedupeClone() so bodied and bodyless
clones share the headers-copy + url-restore + finalizer-register treatment.

Adds a comment near entry.response assignment noting that the unconsumed tee
branch is bounded by the render scope: when runWithFetchDedupe exits, the
dedupe map becomes unreachable and the FinalizationRegistry cancels any
still-unconsumed branch.

Also adds two tests covering the Request-object input path of
createFetchDedupeCandidate — one verifying dedupe applies for identical
Request inputs, one verifying differing non-trace headers on Request inputs
prevent dedupe.

* fix(fetch-cache): drop failed dedupe entries so later callers can retry

When the original fetch rejects, the dedupe entry stayed in the map with
response: null. Subsequent callers within the same render scope chained on
the rejected promise — propagation was correct, but they could never retry,
because the failed entry blocked fresh attempts for the rest of the scope.
React.cache() (which Next.js uses) avoids this because each call site
naturally retries on failure.

Splice the entry out of the URL bucket on rejection so a later fetch to the
same URL within the same render scope creates a fresh entry and re-issues
the upstream request.

Also align the fetch-dedupe-metadata fixture with fetch-dedupe-isr-metadata
by replacing the `as CountBody` cast with a runtime assertCountBody check.

* docs(fetch-cache): document scope inheritance + harden ISR test stabilization

Rename `dispatchAppPageWithDedupe` to `dispatchAppPageInner` so the name
reflects that it runs *inside* the dedupe scope rather than activating it.

Add comments at the four `runWithFetchDedupe` / `renderToReadableStream`
sites explaining how each one relates to the surrounding dedupe scope:

- app-page-render and app-page-boundary: defensive wrap, no-op under
  dispatch; standalone callers must keep an outer scope alive across async
  stream consumption since `runWithFetchDedupe` of a synchronous fn only
  covers the synchronous portion.
- ISR revalidation render and intercept render in dispatch: explicitly note
  why no inner wrap is needed (the outer revalidation context / dispatch
  wrapper already activated dedupe).
- runWithFetchDedupe doc: document the ALS-scope-vs-async-consumption
  caveat that ties this together.

Replace the fixed 200ms post-condition sleep in the ISR background dedupe
test with a 500ms count-stabilization poll. A stray third upstream fetch
(which would betray dedupe leaking across the metadata + page render
boundary) is now caught regardless of when it fires.

---------

Co-authored-by: James <james@eli.cx>
2026-05-08 22:10:17 +01:00
..