Skip to content

fix(pages-router): dedupe in-flight _next/data fetches by URL (#1464)#1684

Merged
james-elicx merged 1 commit into
mainfrom
fix/issue-1464-next-data-dedup
Jun 1, 2026
Merged

fix(pages-router): dedupe in-flight _next/data fetches by URL (#1464)#1684
james-elicx merged 1 commit into
mainfrom
fix/issue-1464-next-data-dedup

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • Closes Pages Router: client-side _next/data request deduplication not implemented #1464: client-side _next/data request dedup parity for the Pages Router.
  • Concurrent Router.push calls (or back-to-back <Link> clicks) for the same destination now share a single underlying fetch instead of each firing their own request.
  • Add a module-scoped in-flight Promise cache in shims/internal/pages-data-fetch-dedup.ts, keyed by the resolved /_next/data/<id>/<page>.json URL, mirroring fetchNextData()'s inflightCache in packages/next/src/shared/lib/router/router.ts.
  • Wired into both navigateClientData() (the JSON navigation fast path) and resolveMiddlewareDataRedirect() (the soft-redirect probe). Each caller gets a cloned Response so bodies remain independently readable.

Design notes

  • The shared fetch deliberately ignores per-caller AbortSignals: stale navigations are already detected via _navigationId + assertStillCurrent() checkpoints after the await. Aborting the shared request on behalf of one superseded caller would destroy the dedup gain for every other concurrent caller.
  • Pre-await abort still surfaces so callers see the documented NavigationCancelledError / AbortError when supersession happened before the fetch was even attempted.
  • Entries self-evict on settle (success or rejection) so the next navigation always re-fetches fresh.

Test plan

  • New unit test (tests/pages-data-fetch-dedup.test.ts) covers: shared fetch under concurrency, sequential re-fetch, per-URL keying, and rejection eviction.
  • pnpm test tests/pages-data-fetch-dedup.test.ts tests/pages-data-url.test.ts tests/pages-data-route.test.ts tests/router-prefetch-invalid-url.test.ts tests/link-navigation.test.ts tests/navigation-runtime.test.ts tests/shims.test.ts — 1101 tests pass.
  • pnpm run check — format, lint, and type clean.
  • CI: Vitest, Playwright E2E.

Ported from Next.js: https://github.com/vercel/next.js/blob/canary/test/e2e/getserversideprops/test/index.test.ts (should dedupe server data requests).

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

@pkg-pr-new

pkg-pr-new Bot commented May 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 44a3f6e

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
web preview production

@ask-bonk ask-bonk Bot left a comment

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.

Clean implementation. The dedup helper is well-isolated, the Response.clone() approach is spec-correct (tee doesn't disturb the stream, so N clones from the same original are legal), and the signal/abort design is sound — dropping the signal from the shared fetch while keeping pre-await guards is the right call for dedup semantics.

One minor inconsistency to clean up (non-blocking), noted inline.

res = await fetch(target.dataHref, {
res = await dedupedPagesDataFetch(target.dataHref, {
headers: { Accept: "application/json", "x-nextjs-data": "1" },
signal: controller.signal,

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: this AbortError check is now dead code — dedupedPagesDataFetch never passes a signal to fetch(), so the underlying request cannot throw AbortError. The equivalent check was already correctly removed from resolveMiddlewareDataRedirect (the catch block there was simplified to bare catch { return null; }), so this is an inconsistency between the two call sites.

Suggest either removing the try/catch entirely (the DOMException branch is unreachable, and re-throwing everything is the default behavior without a try/catch), or simplifying to match the other site. Not blocking — the dead branch is harmless — but it would be nice for both sites to tell the same story.

@ask-bonk

ask-bonk Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Review posted. Here's the summary:

Approved PR #1684 with one inline nit.

Review findings:

  1. Dedup helper (pages-data-fetch-dedup.ts) -- Clean, well-isolated module. The Response.clone() approach is spec-correct: clone() internally uses tee() which replaces the original stream without disturbing it, so N concurrent callers all cloning the same original Response is safe.

  2. Signal/abort design -- Sound. Dropping AbortSignal from the shared fetch is the correct choice for dedup semantics; aborting on behalf of one superseded caller would destroy the dedup benefit for all other concurrent callers. Pre-await abort guards at both call sites ensure callers still see the documented AbortError/NavigationCancelledError when supersession happened before the fetch was even attempted.

  3. init parameter silently ignored for cached callers -- Not a problem in practice. Both call sites (resolveMiddlewareDataRedirect and navigateClientData) pass identical headers, and the two are mutually exclusive code paths (gated by dataTarget existence), so they never race each other for the same URL.

  4. One nit (inline comment posted): The AbortError check in navigateClientData's catch block (line 835) is now dead code since dedupedPagesDataFetch never passes a signal to fetch(). The equivalent dead code was correctly removed from resolveMiddlewareDataRedirect, creating an inconsistency between the two call sites.

github run

@james-elicx
james-elicx marked this pull request as ready for review May 28, 2026 17:47
Concurrent `Router.push` calls (or back-to-back `<Link>` clicks) to the
same gSSP route each fired their own `_next/data/<id>/<page>.json` fetch.
The Next.js parity test asserts `hit: 1` after 4 rapid navigations to a
slow page; vinext was producing `hit: N`.

Add a module-scoped in-flight Promise cache keyed by the resolved data
URL, mirroring `fetchNextData()`'s `inflightCache` in
`packages/next/src/shared/lib/router/router.ts`. Concurrent callers share
one `fetch()` invocation and each receive a cloned Response. Entries
self-evict on settle so the next navigation re-fetches fresh.

The shared fetch deliberately ignores per-caller `AbortSignal`s: stale
navigations are already detected via `_navigationId` + `assertStillCurrent()`
checkpoints after the await, and aborting the shared request on behalf of
one superseded caller would destroy the dedup gain for every other
concurrent caller. Pre-await abort still surfaces so callers see the
documented `NavigationCancelledError` / `AbortError` when supersession
happened before the fetch was even attempted.

Wired into both `navigateClientData()` (the JSON fast path) and
`resolveMiddlewareDataRedirect()` (the soft-redirect probe).
@james-elicx
james-elicx force-pushed the fix/issue-1464-next-data-dedup branch from de7c291 to 44a3f6e Compare May 29, 2026 09:09
@james-elicx

Copy link
Copy Markdown
Member Author

Rebased on origin/main; resolved adjacent-import conflict in shims/router.ts (kept both main's app-route-detection imports and this PR's dedupedPagesDataFetch import). vp check passes.

@james-elicx
james-elicx merged commit 26d1cc7 into main Jun 1, 2026
38 checks passed
@james-elicx
james-elicx deleted the fix/issue-1464-next-data-dedup branch June 1, 2026 10:14
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.

Pages Router: client-side _next/data request deduplication not implemented

1 participant