Skip to content

fix(pages): defer router query until ready#2539

Draft
WilliamK112 wants to merge 44 commits into
cloudflare:mainfrom
WilliamK112:codex/vinext-pages-router-isready-1971
Draft

fix(pages): defer router query until ready#2539
WilliamK112 wants to merge 44 commits into
cloudflare:mainfrom
WilliamK112:codex/vinext-pages-router-isready-1971

Conversation

@WilliamK112

Copy link
Copy Markdown
Contributor

Summary

  • Defer useRouter().query during SSR for pre-ready auto-export dynamic Pages routes so it matches Next.js's {} not-ready snapshot
  • Apply the same empty-query behavior to pre-ready fallback SSR shells
  • Add regressions proving not-ready snapshots stay empty while ready snapshots still expose the merged route/search query

Closes #1971

Validation

  • corepack pnpm exec vitest run tests/shims.test.ts -t "pre-ready auto-export" --reporter dot
  • corepack pnpm exec vitest run tests/shims.test.ts -t "pre-ready fallback" --reporter verbose
  • corepack pnpm test tests/shims.test.ts (1187/1187)
  • corepack pnpm run check (format/lint/type)
  • git diff --check

Preflight

Signed-off-by: WilliamK112 <164879897+WilliamK112@users.noreply.github.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 11cfe69d73

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/shims/router.ts Outdated
Comment on lines +1262 to +1263
const shouldDeferQuery =
_ssrCtx.navigationIsReady === false && (_ssrCtx.isFallback === true || isAutoExportDynamic);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Align client query deferral with SSR

When this server branch returns an empty query for a pre-ready auto-export dynamic or fallback render, the browser hydration path still initializes PagesRouterProvider through the client branch of getPathnameAndQuery, which always merges window.location.search and route params into query even while isPagesRouterReady() is false. For a page like /posts/[id]?view=full that renders router.query, the server now ships {} but the first client render hydrates with { id: "42", view: "full" }, causing the hydration mismatch this change is trying to avoid; please defer the client snapshot under the same readiness condition or keep the SSR snapshot aligned.

Useful? React with 👍 / 👎.

@pkg-pr-new

pkg-pr-new Bot commented Jul 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2539
npm i https://pkg.pr.new/create-vinext-app@2539
npm i https://pkg.pr.new/vinext@2539

commit: 6a0bca9

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

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

Reviewed the change. The core fix is correct and matches Next.js's render.tsx carve-out (query = {} for isAutoExport || isFallback renders, per issue #1971's citations). I verified:

  • Dev/prod parity holds. The SSR branch reads autoExport from _ssrCtx.nextData (populated via buildPagesReadinessNextDataapplySSRContext({ nextData: pagesNextData }) in pages-page-handler.ts:488), and the fallback republish at pages-page-handler.ts:672 sets isFallback: true, which shouldDeferPagesRouterQuery keys off (it does not depend on nextData for the fallback path). ✅
  • Client/SSR alignment. The Codex P2 comment about the client hydration path leaking { id, view } while SSR ships {} is addressed by this PR's second commit — the client branch now gates the same merge behind shouldDeferPagesRouterQuery(pathname, isPagesRouterReady(), nextData), and pagesRouterReady initializes to false for pre-ready auto-export dynamic routes (router.ts:460). The added client-hydration test exercises this. ✅
  • Tests pass. vitest run tests/shims.test.ts -t "pre-ready" → 4 passed. ✅

Two non-blocking observations below.

Comment thread packages/vinext/src/shims/router.ts Outdated
): boolean {
const isAutoExportDynamic =
nextData?.autoExport === true && extractRouteParamNames(routePattern).length > 0;
return !isReady && (nextData?.isFallback === true || isAutoExportDynamic);

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.

Non-blocking scope note: shouldDeferPagesRouterQuery gates the auto-export case on extractRouteParamNames(routePattern).length > 0 (dynamic only). In Next.js render.tsx, the query = {} reset fires for isAutoExport || isFallback where isAutoExport is not gated on the route being dynamic — so a static auto-export page loaded with a query string (e.g. /about?foo=bar) also renders with query = {} while isReady === false in Next.js, whereas here it would not defer.

This matches issue #1971's stated scope (auto-export dynamic routes) and is consistent with the sibling deferral in getPagesNavigationContext (line 1004-1005) and _buildClientPagesNavigationContext (line 1004), so it's a pre-existing, deliberate scoping choice rather than a regression. Flagging only so it's a conscious decision — if strict parity on static auto-export + querystring is desired, that would be a follow-up touching all these sites together.

Comment thread packages/vinext/src/shims/router.ts Outdated
nextData: RouteQueryNextData | undefined,
): boolean {
const isAutoExportDynamic =
nextData?.autoExport === true && extractRouteParamNames(routePattern).length > 0;

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.

Minor maintainability: the isAutoExportDynamic predicate (nextData?.autoExport === true && extractRouteParamNames(...).length > 0) is now duplicated in at least four places — here, _buildClientPagesNavigationContext (line 1004-1005), getPagesNavigationContext (line 1076-1077), and referenced by resolvePagesNavigationPathname. Since these must stay in lockstep to avoid reintroducing a hydration mismatch (a risk explicitly called out in the resolvePagesNavigationPathname doc comment), consider extracting a single isAutoExportDynamicRoute(routePattern, nextData) helper and reusing it. Not required for this PR, but it reduces the chance a future edit updates one site and not the others.

@ask-bonk

ask-bonk Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Summary of my review of PR #2539:

Verdict: Approve (with two non-blocking notes)

The fix correctly implements the Next.js render.tsx carve-out that sets query = {} for isAutoExport || isFallback renders while isReady === false, resolving the parity gap in issue #1971.

What I verified:

  • Dev/prod parity — The SSR branch reads autoExport from the request-scoped nextData (populated in pages-page-handler.ts:488), and the fallback republish at pages-page-handler.ts:672 sets isFallback: true, which the deferral keys off independently of nextData. Both dev-server and prod-handler paths are covered.
  • The Codex P2 concern is addressed — The original single-commit version only deferred the SSR branch, which would have caused a hydration mismatch ({} server vs {id, view} client). This PR's second commit adds the matching client-side gate, and pagesRouterReady initializes to false for pre-ready auto-export dynamic routes (router.ts:460), so client and server agree. The new client-hydration test exercises this.
  • Tests pass — 4 pre-ready tests green.

Two non-blocking observations posted inline:

  1. Scope note — The auto-export deferral is gated on the route being dynamic, but Next.js's isAutoExport isn't. A static auto-export page with a query string (/about?foo=bar) would defer in Next.js but not here. This matches the issue's stated scope and the existing sibling code, so it's a deliberate pre-existing choice, not a regression.
  2. Maintainability — The isAutoExportDynamic predicate is now duplicated in ~4 sites that must stay in lockstep to avoid hydration mismatches; suggested extracting a shared helper.

github run

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 6a0bca9 against base aa1b666 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 126.6 KB 126.6 KB ⚫ +0.0%
Client entry size (gzip) vinext 120.6 KB 120.6 KB ⚫ +0.0%
Dev server cold start vinext 2.79 s 2.77 s ⚫ -0.5%
Production build time vinext 3.15 s 3.17 s ⚫ +0.5%
RSC entry closure size (gzip) vinext 98.5 KB 98.6 KB ⚫ +0.1%
Server bundle size (gzip) vinext 165.1 KB 165.2 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

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

Reviewed the change against the checked-out branch. The core fix is correct and matches Next.js's render.tsx carve-out (query = {} for isAutoExport || isFallback renders while isReady === false). I verified locally:

  • Targeted tests pass. vitest run tests/shims.test.ts -t "pre-ready" → 4 passed; tests/pages-router.test.ts + tests/entry-templates.test.ts → 392 passed; tsc --noEmit clean.
  • Shared runtime state is coherent across module instances. pagesRouterInitialState lives on the global-symbol routerRuntimeState (router.ts:446/487), so duplicated next/router chunks observe the same flag — no split-state hydration hazard.
  • State transitions flip correctly. markPagesRouterReady clears pagesRouterInitialState for non-fallback renders before the early-return (router.ts:1390), so the ready timer re-reads the merged query even for already-ready routes; fallback/auto-export paths flip it via markPagesRouterLiveState on the hydration Router.replace. The reactive PagesRouterProvider (router.ts:3335) re-renders after that flip, resolving the mismatch.
  • Dev/prod parity holds. The SSR deferral reads autoExport/isFallback from request-scoped nextData, and the fallback republish path is covered independently. Both dev-server and pages-page-handler apply the same getPagesInitialRouterQuery gate.

Two non-blocking notes below.

if (isFallback) return {};
if (nextData?.autoExport === true) return {};
return nextData?.gsp === true ? params : query;
}

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.

Non-blocking (DRY): getPagesInitialRouterQuery and getPagesSerializedRouterQuery have byte-identical bodies — same guards, same gsp ? params : query result. The only difference is the name and declared return type. Since they must stay in lockstep (the runtime SSR snapshot and the serialized __NEXT_DATA__.query have to agree to avoid a hydration mismatch — the exact class of bug this PR fixes), consider collapsing them into one function so a future edit can't diverge one from the other.

If the intent is that they may diverge later (e.g. serialized query needs different escaping/shaping than the live SSR query), a short comment on each explaining the expected future difference would prevent someone from "deduplicating" them incorrectly.

window.location.search,
nextData,
);
if (!initialRouterIsReady) {

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.

Behavior-expansion note (non-blocking): the hydration Router.replace(..., { _h: 1 }) was previously gated on nextData.isFallback; it now fires for every !initialRouterIsReady case — including auto-export dynamic routes and any route loaded with a query string. This is the correct parity behavior (Next.js runs the same _h: 1 query-update replace on hydration for not-ready routes), and it's what makes the client query converge to the merged value. But it does mean a plain auto-export dynamic page (no data functions) now goes through navigateClient on first load where it previously did not.

Worth confirming that path short-circuits cleanly for a pure auto-export page (no getStaticProps/getServerSideProps) rather than issuing a stray _next/data fetch — dataTarget?.dataKind should be neither static nor server for those, but it's the one runtime side effect this change broadens. An E2E assertion that no extra network request fires on hydration of a non-fallback auto-export dynamic page would lock this down.

@ask-bonk

ask-bonk Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Summary of my review of PR #2539:

Verdict: Approve (two non-blocking notes)

The fix correctly implements the Next.js render.tsx carve-out that sets query = {} for isAutoExport || isFallback renders while isReady === false, resolving the parity gap in issue #1971.

What I verified locally (checked-out branch):

  • Targeted tests passvitest -t "pre-ready" (4), tests/pages-router.test.ts + tests/entry-templates.test.ts (392), and tsc --noEmit clean.
  • Shared runtime state is coherent — the new pagesRouterInitialState flag lives on the global-symbol routerRuntimeState, so duplicated next/router chunks observe the same value (no split-state hydration hazard).
  • State transitions flip correctlymarkPagesRouterReady clears pagesRouterInitialState before its early return, so the ready timer re-reads the merged query; fallback/auto-export paths flip it via markPagesRouterLiveState during the hydration Router.replace; the reactive provider re-renders afterward.
  • Dev/prod parity — both dev-server and pages-page-handler apply the same getPagesInitialRouterQuery gate, and the fallback republish path is covered independently.

Two non-blocking observations posted inline:

  1. DRYgetPagesInitialRouterQuery and getPagesSerializedRouterQuery are byte-identical. Since they must stay in lockstep to avoid the exact hydration mismatch this PR fixes, suggested collapsing them (or documenting the expected future divergence).
  2. Behavior expansion — the hydration Router.replace({ _h: 1 }) now fires for all not-ready routes (auto-export dynamic, query-string), not just fallback. This matches Next.js, but broadens the one runtime side effect; suggested an E2E assertion that a pure auto-export dynamic page issues no stray _next/data fetch on hydration.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

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

Review: fix(pages): defer router query until ready

Reviewed the checked-out branch. The core fix is correct and matches Next.js's render.tsx carve-out: query = {} for isAutoExport || isFallback renders while isReady === false, resolving the parity gap in #1971.

Verified locally:

  • Targeted tests pass: vitest -t "pre-ready" (4), tests/pages-page-handler.test.ts + tests/entry-templates.test.ts (67), and the full tests/pages-router.test.ts (351).
  • pnpm run check clean (format, lint, type — 1085 files).
  • Dev/prod parity holds. Both dev-server.ts and pages-page-handler.ts apply the same getPagesInitialRouterQuery gate for the live SSR snapshot, and both serialize via getPagesSerializedRouterQuery. The fallback republish path is covered independently.
  • Shared runtime state is coherent. pagesRouterInitialState lives on the global-symbol routerRuntimeState, so duplicated next/router chunks observe the same flag. initializePagesRouterReadyFromNextData runs before the hydration Router.replace({_h:1}), and markPagesRouterReady clears the flag before its early return — the state transitions line up.
  • No stray data fetch for pure auto-export dynamic pages. The broadened _h:1 replace routes a no-data auto-export page through navigateClientNoData (dataKind is neither static nor server), so hydration doesn't issue a _next/data fetch. The prior reviewers' suggested E2E assertion would be a nice guardrail but the code path is correct.

No blocking issues. Two non-blocking notes below (one is a behavior expansion worth calling out explicitly).

if (isFallback) return {};
if (nextData?.autoExport === true) return {};
return nextData?.gsp === true ? params : query;
}

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.

Non-blocking (DRY / correctness hazard): getPagesInitialRouterQuery and getPagesSerializedRouterQuery are byte-identical — same guards, same isFallback / autoExport / gsp branches, same result. They must stay in lockstep, because the live SSR router.query (from getPagesInitialRouterQuery) and the serialized __NEXT_DATA__.query (from getPagesSerializedRouterQuery) have to agree — a divergence reintroduces the exact hydration mismatch this PR fixes.

Consider collapsing them into one function so a future edit can't drift one from the other:

Suggested change
}
export function getPagesInitialRouterQuery<T extends Record<string, unknown>>(
query: T,
params: Record<string, unknown>,
nextData: PagesRouterQueryNextData | undefined,
isFallback: boolean,
): T | Record<string, unknown> {
if (isFallback) return {};
if (nextData?.autoExport === true) return {};
return nextData?.gsp === true ? params : query;
}
// Serialized `__NEXT_DATA__.query` must equal the live SSR snapshot, so it is
// intentionally the same computation. Kept as an alias so the two call sites
// stay in lockstep by construction rather than by convention.
export const getPagesSerializedRouterQuery = getPagesInitialRouterQuery;

If they are expected to diverge later (e.g. serialized query needs different shaping/escaping than the live SSR query), a short comment on each stating the expected future difference would prevent someone from "deduplicating" them incorrectly.

props: freshRenderProps,
page: patternToNextFormat(route.pattern),
query: params,
query: getPagesSerializedRouterQuery(

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.

Behavior-expansion note (non-blocking): this path (and the equivalent at ~L1871, plus pages-page-handler.ts) now serializes the full merged query into __NEXT_DATA__.query for gssp/gip routes, where previously it serialized params only (the old query: params). This is closer to Next.js — its __NEXT_DATA__.query for gssp/gip pages includes route params and search params — so it's an improvement, and the new shims.test.ts case ("serializes the full initial query for ordinary server renders") locks it in.

Worth flagging only because it's a change beyond the stated PR scope (auto-export/fallback empty-query). I confirmed the client stays consistent: with pagesRouterInitialState true, getPathnameAndQuery returns copySerializedPagesQuery(nextData.query) directly, so the hydrated snapshot equals the serialized query for gssp — no mismatch. No action needed; just documenting the widened behavior.

@ask-bonk

ask-bonk Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Summary of my review of PR #2539:

Verdict: Approve (two non-blocking notes)

The fix correctly implements the Next.js render.tsx carve-out (query = {} for isAutoExport || isFallback renders while isReady === false), resolving the parity gap in issue #1971.

What I verified locally on the checked-out branch:

  • Tests passvitest -t "pre-ready" (4), tests/pages-page-handler.test.ts + tests/entry-templates.test.ts (67), and the full tests/pages-router.test.ts (351).
  • pnpm run check clean — format, lint, type (1085 files).
  • Dev/prod parity holds — both dev-server.ts and pages-page-handler.ts apply the same getPagesInitialRouterQuery gate; the fallback republish path is covered independently.
  • Shared runtime state coherentpagesRouterInitialState lives on the global-symbol routerRuntimeState; init runs before the hydration Router.replace({_h:1}), and markPagesRouterReady clears the flag before its early return.
  • No stray data fetch — the broadened _h:1 replace routes a no-data auto-export page through navigateClientNoData (dataKind is neither static nor server).

Two non-blocking inline comments posted:

  1. DRY / correctness hazardgetPagesInitialRouterQuery and getPagesSerializedRouterQuery are byte-identical and must stay in lockstep (a divergence reintroduces the exact hydration mismatch this PR fixes). Suggested collapsing to an alias, with a suggestion block.
  2. Behavior-expansion note — the serialization paths now emit the full merged query into __NEXT_DATA__.query for gssp/gip routes (previously params only). This is closer to Next.js and is tested, but is beyond the stated scope; flagged for visibility. Confirmed the client stays consistent (no mismatch).

No blocking issues found.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

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

Review: fix(pages): defer router query until ready

The core empty-query deferral for auto-export dynamic / fallback renders is correct and matches Next.js render.tsx. The shared runtime-state coherence, the _h:1 hydration replace, and the pre-ready/ready snapshot tests all look sound. Prior reviewers already covered the DRY concern on the two getPages*RouterQuery helpers.

However, I believe there is a real dev/prod parity gap in the widened __NEXT_DATA__.query serialization behavior that the current tests do not catch. Details inline. This is exactly the class of divergence AGENTS.md calls out as high-risk, so flagging it as blocking-until-confirmed rather than a nit.

props: options.props ?? { pageProps: options.pageProps },
page: options.routePattern,
query: options.params,
query: getPagesSerializedRouterQuery(

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.

Dev/prod parity gap (likely a hydration mismatch in production).

This new getPagesSerializedRouterQuery(options.query ?? options.params, ...) widens serialized __NEXT_DATA__.query from params-only to the full merged query — but only when a query is actually passed in. The production caller renderPagesPageResponse (this file, ~L499) builds the script without forwarding options.query:

  const nextDataScript = buildPagesNextDataScript({
    buildId: options.buildId,
    ...
    params: options.params,   // <-- no `query:` here
    ...
  });

so options.query ?? options.params falls back to params. Meanwhile the dev path (dev-server.ts) calls buildPagesNextDataScript/serialize with the full merged query. Result: dev serializes the full query for gssp/gip routes, production serializes params-only.

Why it bites gssp/gip specifically (autoExport/gsp get {}/params regardless):

  • Server React render uses ssrCtx.query = getPagesInitialRouterQuery(query, params, nextData, false) = the full query for gssp/gip, so SSR HTML renders router.query = { id, ...search }.
  • Client hydration with pagesRouterInitialState === true returns copySerializedPagesQuery(nextData.query) directly. In production nextData.query is now { id } only.
  • { id, view } (server) vs { id } (client) => React hydration mismatch for e.g. /posts/42?view=full in production.

Fix: forward options.query in the renderPagesPageResponse call to buildPagesNextDataScript (~L499). Note L539 in this same function already forwards options.query ?? options.params to the _document context, so the omission is internally inconsistent too. renderPagesIsrHtml in pages-page-data.ts:650 has the same missing forward (harmless for gsp today, but worth aligning).

expect(nextData.__vinext).toMatchObject({
routeUrl: "/posts/001?draft=1",
routeParams: { id: "001" },
});

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.

Since renderNextData already captures the full serialized __NEXT_DATA__ object and the request carries a query string (/posts/001?draft=1), this test is one assertion away from locking down the parity gap flagged in pages-page-response.ts. For the gssp case, adding:

expect(nextData.query).toEqual({ id: "001", draft: "1" });

would fail today (production serializes { id: "001" } only) and guards against the SSR-vs-hydration query divergence. A matching expect(nextData.query).toEqual({ id: "001" }) on the gsp case would pin the params-only behavior there.

routeUrl || currentUrl,
routeUrl ? currentUrl : undefined,
fallbackRoute || routeUrl || currentUrl,
fallbackRoute || routeUrl ? currentUrl : undefined,

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.

Minor readability: fallbackRoute || routeUrl ? currentUrl : undefined relies on || binding tighter than the ternary. It is correct ((fallbackRoute || routeUrl) ? currentUrl : undefined), but reads ambiguously next to line 313 where fallbackRoute || routeUrl || currentUrl is a value rather than a condition. Consider parenthesizing the condition:

Suggested change
fallbackRoute || routeUrl ? currentUrl : undefined,
await Router.replace(
fallbackRoute || routeUrl || currentUrl,
(fallbackRoute || routeUrl) ? currentUrl : undefined,
{ _h: 1, scroll: false },
);

nextConfig?.clientTraceMetadata,
nextConfig?.htmlLimitedBots,
nextConfig?.reactStrictMode === true,
nextConfig?.buildId ?? process.env.__VINEXT_BUILD_ID ?? "development",

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.

Behavior note (non-blocking): the dev __NEXT_DATA__.buildId now defaults to "development" where it was previously process.env.__VINEXT_BUILD_ID (typically undefined in dev). Client-side navigations build /_next/data/<buildId>/... fetch URLs from this, so worth confirming a dev-mode data navigation still resolves with buildId === "development" (or that data URLs aren't keyed on it in dev).

@ask-bonk

ask-bonk Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

I reviewed PR #2539 (fix(pages): defer router query until ready) and posted a review with one significant finding and three minor notes.

Main finding — dev/prod parity gap (potential production hydration mismatch)

The PR widens the serialized __NEXT_DATA__.query for gssp/gip routes from params-only to the full merged query — but only on the dev path. The production render path (renderPagesPageResponsebuildPagesNextDataScript in pages-page-response.ts:499) does not forward options.query, so the new options.query ?? options.params falls back to params.

Consequence for gssp/gip routes loaded with a query string (e.g. /posts/42?view=full):

  • Server React render uses the full query (getPagesInitialRouterQuery returns full query for gssp/gip) → SSR HTML has router.query = { id, view }
  • Client hydration reads copySerializedPagesQuery(nextData.query) directly → production nextData.query = { id } only
  • Result: React hydration mismatch in production, not in dev

I suggested forwarding options.query in the renderPagesPageResponse call (and noted L539 already forwards it to the _document context, so it's internally inconsistent), plus flagged the same missing forward in renderPagesIsrHtml. I also pointed out that the new pages-page-handler.test.ts test is one assertion away from catching this.

Minor notes

  • pages-client-entry.ts:314 — ambiguous ||/ternary precedence; suggested parenthesizing.
  • index.ts:5230 — dev __NEXT_DATA__.buildId now defaults to "development" (was undefined); worth confirming dev data-navigation URLs still resolve.

Verdict: not a rubber-stamp approval — the parity gap should be confirmed or fixed before merge, since it's the exact SSR-vs-hydration query divergence this PR is trying to eliminate.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

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

Review: fix(pages): defer router query until ready

Reviewed at HEAD 916cc42four commits newer than the last automated round (2136078). The new commits (preserve route and cache ordering, order invalidation replacements, bypass ISR for header overrides, preserve streamed no-store responses) had not been reviewed before, so I focused there and re-verified the two long-standing ISR concerns.

The four new commits are correct

  1. Monotonic mutation timestamp (1af2030 / 8dce219)MemoryCacheHandler and KVCacheHandler now allocate a strictly-increasing timestamp for set/revalidateTag via Math.max(now, last + 1). This is a genuine correctness fix: get() evicts when revalidatedAt >= entry.lastModified (cache-handler.ts:240), so a set() landing in the same millisecond as a preceding revalidateTag() was previously evicted immediately. Both handlers are covered by a same-ms test. Sound.
  2. App-relative asPath history normalization (1af2030)getCurrentHistoryAsPath now uses removeNavigationLocalePrefix(state.as) instead of getPagesRouterVisiblePath(state.as). Correct: state.as is already app-relative (basePath-stripped), so re-stripping basePath would corrupt an asPath whose first segment equals basePath. The new i18n-sticky-locale test pins this.
  3. Bypass ISR for header overrides (691bac6) — when middleware overrides request headers (buildRequestHeadersFromMiddlewareResponse returns non-null), ISR read/write is disabled (isrGet→null, isrSet→noop, isrRevalidateSeconds→null) and the response is forced no-store. This matches Next.js: a getServerSideProps/getStaticProps render that depends on middleware-injected request headers is request-specific and must not be CDN-shared. Coherent.
  4. Preserve streamed no-store responses (916cc42) — the no-store re-wrap now copies __vinextStreamedHtmlResponse onto the new Response, matching the established pattern in pages-request-pipeline.ts:798-802. Without it, the streaming marker would be lost when the response is rebuilt and the adapter would buffer instead of stream. Correct.

Verified locally on this HEAD

  • tests/pages-page-handler.test.ts + tests/kv-cache-handler.test.ts + tests/isr-cache.test.ts (165), tests/pages-router-i18n-sticky-locale.test.ts + pages-page-response + pages-page-data + pages-request-pipeline (210), shims -t "pre-ready" (4).
  • The prior bundle-budget blocker stays resolvedtests/pages-router.test.ts -t "produces client bundle" passes.
  • tsc --noEmit -p packages/vinext clean.
  • Core router-query deferral, dev/prod query-serialization parity, and buildId default all remain correct.

The two ISR-isolation concerns from prior rounds are still open

The four new commits refine cache ordering and the request-header-override case, but neither of the two substantive ISR concerns flagged in the 81f0612/2136078 rounds is addressed. 691bac6 narrows one slice (request-header overrides bypass ISR), but a plain NextResponse.next() match, a response-header set, or a rewrite still triggers full origin-isolation + no-store. Details inline. These are the highest-risk part of the diff and unrelated to the stated fix.

Process (as every prior round noted)

The PR is scoped/titled as router-query deferral for #1971 but now spans ~40 commits and 37 files including middleware ISR origin-isolation, forceOrigin, CDN no-store gating, middlewareMatchCanVaryByRequest, query-keyed isolation, tag invalidation, monotonic cache timestamps, and header-override ISR bypass. Per AGENTS.md's stacked-PR guidance the ISR/middleware half would ideally be split for independent review/revert. Maintainer judgment.

}): boolean {
return (
input.initialMiddlewareMatched === true || input.initialMiddlewareMatchCanVaryByRequest === true
);

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.

Still open (flagged at 81f0612 and 2136078): isolation is much broader than the rewrite-aliasing case it targets. shouldIsolatePagesMiddlewareIsr isolates on plain initialMiddlewareMatched === true, which is set on any matcher hit (pages-request-pipeline.ts:337result.matched === true), including middleware that only returns nothing or calls NextResponse.next() (middleware-runtime.ts:361-373).

The new 691bac6 commit narrows one slice — when middleware overrides request headers, ISR is now bypassed — but a plain pass-through match, a response-header set, or a rewrite still triggers origin-isolation.

Effect by adapter:

  • default in-process (ownsBackgroundRevalidation === true): previously not isolated → now isolated for every matched getStaticProps page.
  • Cloudflare CDN (false): isolated either way.

With the common broad matcher (/((?!api|_next|favicon\.ico|...).*)), essentially every getStaticProps page behind that middleware routes ISR reads/writes to origin-only and emits no-store (pages-page-response.ts), so it is never CDN-cacheable. Next.js keeps getStaticProps pages CDN-cacheable even when non-request-varying middleware matched the path.

Keep the adapter gate for the plain-matched branch and only unconditionally isolate when the match can actually vary by request:

Suggested change
);
return (
input.initialMiddlewareMatchCanVaryByRequest === true ||
(input.initialMiddlewareMatched === true && !getCdnCacheAdapter().ownsBackgroundRevalidation)
);

(If broadening is deliberate for the rewrite-aliasing fix, isolating by visible pathname — see the pages-page-handler.ts comment — fixes aliasing without the CDN fallout.)

? (router: string, _pathname: string) =>
pageIsrCacheKey(
`${router}:middleware`,
`${routerAsPath}::route=${encodeURIComponent(routeUrl)}`,

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.

Still open (flagged at 81f0612 and 2136078): the isolated ISR cache key includes the query string → unbounded growth. routerAsPath retains parsed.search (via normalizeVisiblePagesUrl), and the composed key ${routerAsPath}::route=${encodeURIComponent(routeUrl)} embeds it directly. The new pages-page-handler.test.ts pins this — /visible-a?draft=1 and /visible-b?draft=2 are asserted to produce distinct keys (L500-501).

Consequence: a matched-middleware getStaticProps page hit with ?utm_source=a, ?utm_source=b, … creates a distinct origin cache entry per query string, each with its own background render — defeating ISR dedup and letting an unauthenticated client grow the in-memory origin store without bound (memory-exhaustion / cache-stampede vector). Next.js keys SSG/ISR by normalized pathname, not query.

The path-based tag already strips the query (getPagesPathCacheTags), and the on-demand invalidation at L609 uses the query-stripped tag — so the tag and the key disagree on query handling, which is what makes targeted invalidation fragile. The rewrite-aliasing case you want (/rewritten vs /mw-rewrite-query/ssr) only needs isolation by visible pathname. Strip the query:

Suggested change
`${routerAsPath}::route=${encodeURIComponent(routeUrl)}`,
const isrIdentityUrl = isolateMiddlewareIsr ? routerAsPath.split("?")[0] : routeUrl;

(and drop the query from the composed key at L615 the same way)

return nextData?.gsp === true ? params : query;
}

export function getPagesSerializedRouterQuery<T extends Record<string, unknown>>(

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.

Non-blocking DRY hazard (open since round 1). getPagesInitialRouterQuery (L32) and getPagesSerializedRouterQuery (L43) are still byte-identical apart from the declared return type. They must stay in lockstep — the live SSR router.query and the serialized __NEXT_DATA__.query have to agree, and a silent divergence reintroduces the exact hydration mismatch this PR fixes.

Suggested change
export function getPagesSerializedRouterQuery<T extends Record<string, unknown>>(
// The serialized `__NEXT_DATA__.query` must equal the live SSR snapshot, so it
// is intentionally the same computation. Kept as an alias so the two call sites
// stay in lockstep by construction rather than by convention.
export const getPagesSerializedRouterQuery = getPagesInitialRouterQuery;

(Drop the standalone body and export the alias. If a future divergence is intended, document the expected difference so nobody "re-deduplicates" incorrectly.)

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

I reviewed PR #2539 at HEAD 916cc42four commits newer than the last automated round (2136078). Those four commits (preserve route and cache ordering, order invalidation replacements, bypass ISR for header overrides, preserve streamed no-store responses) had never been reviewed, so I focused there and re-verified the two long-standing ISR concerns.

The four new commits are all correct

  1. Monotonic mutation timestamp (MemoryCacheHandler + KVCacheHandler) — genuine correctness fix. A set() landing in the same millisecond as a preceding revalidateTag() was previously evicted immediately because get() compares revalidatedAt >= entry.lastModified. The Math.max(now, last+1) counter fixes it; both handlers have same-ms tests.
  2. App-relative asPath history normalizationremoveNavigationLocalePrefix instead of getPagesRouterVisiblePath for state.as (already basePath-stripped), avoiding corruption when an asPath segment equals basePath. Pinned by a new i18n test.
  3. Bypass ISR for header overrides — request-header-varying renders correctly bypass ISR read/write and force no-store, matching Next.js.
  4. Preserve streamed no-store responses — the re-wrap copies __vinextStreamedHtmlResponse, matching the established pattern; without it the adapter would buffer instead of stream.

Verified locally

  • 375+ targeted tests pass (handler, kv/isr cache, i18n, response, data, pipeline, pre-ready).
  • The prior REQUEST_CHANGES bundle-budget blocker stays resolved.
  • tsc --noEmit clean.

Two ISR-isolation concerns from prior rounds remain open (inline, non-blocking)

  1. shouldIsolatePagesMiddlewareIsr still isolates on plain matched, so any getStaticProps page behind a broad matcher becomes origin-only + no-store on the default in-process adapter — never CDN-cacheable, diverging from Next.js.
  2. The isolated ISR cache key still embeds the query string → distinct origin entry per query string (unbounded-growth/stampede vector); disagrees with the query-stripped tag.

Plus the long-standing DRY hazard (getPagesInitialRouterQuery/getPagesSerializedRouterQuery byte-identical) and the scope-creep process note (~40 commits, 37 files, well beyond the stated router-query fix).

Verdict: no merge-blocking correctness bug on this HEAD; the two ISR concerns are flagged for maintainer judgment.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

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

Review: fix(pages): defer router query until ready

Reviewed at HEAD b30d60fone commit newer than the last automated round (916cc42). The new commit (fix(pages): publish fallback hydration before data) had not been reviewed before, so I focused there and re-verified the two long-standing ISR concerns.

The new commit is correct

b30d60f calls _markPagesRouterHydrated() for fallback routes immediately after hydrationCommitted, before the _h:1 Router.replace (pages-client-entry.ts:296-298). I traced the chain:

  • For a fallback page the PagesRouterHydrationMarker effect only fires markPagesRouterHydrated() when isReady === true (router.ts:1454-1457), but isReady stays false until the _h:1 replace's async runNavigateClient data fetch completes and reaches markPagesRouterReady() (router.ts:3298, non-shallow fallback path). So without this commit, __NEXT_HYDRATED/__NEXT_HYDRATED_CB would not fire until the fallback data resolves — coupling the hydration signal to data loading. Next.js sets the hydration marker at hydration-commit time, independent of fallback data.
  • The early call is safe: markPagesRouterHydrated is idempotent (window.__NEXT_HYDRATED === true guard, router.ts:1445), and window.next.router.isReady still gates on isPagesRouterReady() && __NEXT_HYDRATED (router.ts:4000), so it correctly stays false for the pre-ready fallback window. The _h abort guard (router.ts:3258) can't trip during the single initial hydration replace, so readiness still resolves.
  • entry-templates.test.ts pins the new import + call in the generated entry.

Verified locally on this HEAD

  • tests/entry-templates.test.ts (13), shims -t "pre-ready" (4) pass.
  • Bundle-budget test passestests/pages-router.test.ts -t "produces client bundle" green, so the prior REQUEST_CHANGES blocker stays resolved.
  • Core router-query deferral, dev/prod query-serialization parity, and buildId default all remain correct.

The two ISR-isolation concerns from prior rounds are still open

I re-confirmed both against this HEAD. They are the highest-risk part of the diff and are unrelated to the stated router-query fix. Details inline.

Process (as every prior round noted)

The PR is scoped/titled as router-query deferral for #1971 but now spans ~41 commits and 37 files including middleware ISR origin-isolation, forceOrigin, CDN no-store gating, middlewareMatchCanVaryByRequest, query-keyed isolation, tag invalidation, monotonic cache timestamps, and header-override ISR bypass. Per AGENTS.md's stacked-PR guidance the ISR/middleware half would ideally be split for independent review/revert. Maintainer judgment; not a hard block.

Event is COMMENT — I do not have merge authority; the ISR findings are flagged for maintainer judgment.

}): boolean {
return (
input.initialMiddlewareMatched === true || input.initialMiddlewareMatchCanVaryByRequest === true
);

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.

Still open (flagged at 81f0612, 2136078, 916cc42): isolation is much broader than the rewrite-aliasing case it targets. shouldIsolatePagesMiddlewareIsr isolates on plain initialMiddlewareMatched === true, which is set on any matcher hit (pages-request-pipeline.ts:337result.matched === true), including middleware that returns nothing (middleware-runtime.ts:361-362) or calls NextResponse.next() to set a header (middleware-runtime.ts:365-373).

Effect by adapter:

  • default in-process (ownsBackgroundRevalidation === true): previously not isolated → now isolated for every matched getStaticProps page.
  • Cloudflare CDN (false): isolated either way.

With the common broad matcher (the repo fixture uses /((?!api|_next|favicon\.ico|...).*)), essentially every getStaticProps page behind that middleware routes ISR reads/writes to origin-only and emits no-store (pages-page-response.ts), so it is never CDN-cacheable. Next.js keeps getStaticProps pages CDN-cacheable even when non-request-varying middleware matched the path.

Keep the adapter gate for the plain-matched branch and only unconditionally isolate when the match can actually vary by request:

Suggested change
);
return (
input.initialMiddlewareMatchCanVaryByRequest === true ||
(input.initialMiddlewareMatched === true && !getCdnCacheAdapter().ownsBackgroundRevalidation)
);

(If broadening is deliberate for the rewrite-aliasing fix, isolating by visible pathname — see the pages-page-handler.ts comment — fixes aliasing without the CDN fallout.)

? (router: string, _pathname: string) =>
pageIsrCacheKey(
`${router}:middleware`,
`${routerAsPath}::route=${encodeURIComponent(routeUrl)}`,

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.

Still open (flagged at 81f0612, 2136078, 916cc42): the isolated ISR cache key includes the query string → unbounded growth. routerAsPath retains parsed.search (via normalizeVisiblePagesUrl, L234), and the composed key ${routerAsPath}::route=${encodeURIComponent(routeUrl)} embeds it directly.

Consequence: a matched-middleware getStaticProps page hit with ?utm_source=a, ?utm_source=b, … creates a distinct origin cache entry per query string, each with its own background render — defeating ISR dedup and letting an unauthenticated client grow the in-memory origin store without bound (memory-exhaustion / cache-stampede vector). Next.js keys SSG/ISR by normalized pathname, not query.

Note the tag and the key disagree on query handling: the path-based tag strips the query (getPagesPathCacheTags), and the on-demand invalidation at L609 uses the query-stripped tag, but the cache key keeps it — which makes targeted invalidation fragile. The isolates origin ISR entries... test only varies path+query together (/visible-a?draft=1 vs /visible-b?draft=2), so it doesn't catch the pure-query-variation growth case. The rewrite-aliasing case you want (/rewritten vs /mw-rewrite-query/ssr) only needs isolation by visible pathname. Strip the query:

Suggested change
`${routerAsPath}::route=${encodeURIComponent(routeUrl)}`,
const isrIdentityUrl = isolateMiddlewareIsr ? routerAsPath.split("?")[0] : routeUrl;

(and drop the query from the composed key at L615 the same way)

return nextData?.gsp === true ? params : query;
}

export function getPagesSerializedRouterQuery<T extends Record<string, unknown>>(

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.

Non-blocking DRY hazard (open since round 1). getPagesInitialRouterQuery (L32) and getPagesSerializedRouterQuery (L43) are still byte-identical apart from the declared return type — same isFallback / autoExport / gsp guards, same result. They must stay in lockstep: the live SSR router.query and the serialized __NEXT_DATA__.query have to agree, and a silent divergence reintroduces the exact hydration mismatch this PR fixes.

Suggested change
export function getPagesSerializedRouterQuery<T extends Record<string, unknown>>(
// The serialized `__NEXT_DATA__.query` must equal the live SSR snapshot, so it
// is intentionally the same computation. Kept as an alias so the two call sites
// stay in lockstep by construction rather than by convention.
export const getPagesSerializedRouterQuery = getPagesInitialRouterQuery;

(Drop the standalone body and export the alias. If a future divergence is intended, document the expected difference so nobody "re-deduplicates" incorrectly.)


if (nextData.isFallback) {
const currentUrl = window.location.pathname + window.location.search + window.location.hash;
if (nextData.isFallback === true) {

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.

Nice — publishing the hydration marker for fallback here (before the async _h:1 data fetch) is the right parity call, since otherwise __NEXT_HYDRATED/__NEXT_HYDRATED_CB wouldn't fire until fallback data resolves (the non-shallow _h:1 replace only reaches markPagesRouterReady() after runNavigateClient). Worth a one-line comment noting the invariant: this is safe because markPagesRouterHydrated is idempotent and window.next.router.isReady still gates on isPagesRouterReady(), so the fallback stays not-ready until the replace completes — a future reader might otherwise worry this leaks isReady: true early.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

I reviewed PR #2539 at HEAD b30d60fone commit newer than the last automated round (916cc42). The new commit (fix(pages): publish fallback hydration before data) hadn't been reviewed before, so I focused there and re-verified the two long-standing ISR concerns.

Verdict: no blocking correctness bug found on this HEAD (posted as COMMENT)

The new commit is correct. b30d60f publishes the hydration marker for fallback routes immediately after hydration commit, before the async _h:1 data fetch. I traced the full chain and confirmed:

  • Without it, __NEXT_HYDRATED/__NEXT_HYDRATED_CB wouldn't fire until fallback data resolves (the non-shallow _h:1 replace only reaches markPagesRouterReady() after runNavigateClient), coupling the hydration signal to data loading — divergent from Next.js.
  • The early call is safe: markPagesRouterHydrated is idempotent, and window.next.router.isReady still gates on isPagesRouterReady() && __NEXT_HYDRATED, so the fallback correctly stays not-ready until the replace completes.

Verified locally: entry-templates (13) + pre-ready (4) pass; the prior REQUEST_CHANGES bundle-budget blocker stays resolved (produces client bundle green); core deferral, dev/prod query-serialization parity, and buildId default all remain correct.

Findings posted inline

  1. ISR isolation breadth (still open)shouldIsolatePagesMiddlewareIsr isolates on plain matched, so any getStaticProps page behind a broad matcher (even pass-through NextResponse.next()) becomes origin-only + no-store on the default in-process adapter, never CDN-cacheable. Confirmed matched: true is set for empty/pass-through middleware. Suggested restoring the adapter gate.
  2. Query-keyed ISR cache growth (still open) — the isolated cache key embeds the request query string (routerAsPath retains parsed.search), so ?utm_source=a/b/c… each create a distinct origin entry — unbounded-growth/stampede vector. The tag strips the query while the key doesn't, making invalidation fragile. Suggested stripping the query from the key.
  3. DRY hazard (non-blocking, open since round 1)getPagesInitialRouterQuery/getPagesSerializedRouterQuery byte-identical; suggested collapsing to an alias.
  4. Doc note on the new fallback hydration line documenting the not-ready invariant.

Both ISR concerns are real and unresolved; flagged for maintainer judgment (not merge-blocked, as they're outside the stated router-query scope). Also reiterated the scope-creep process concern per AGENTS.md's stacked-PR guidance.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@james-elicx james-elicx force-pushed the codex/vinext-pages-router-isready-1971 branch 2 times, most recently from 6410d02 to e5eea5c Compare July 8, 2026 12:13
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

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

Review: fix(pages): defer router query until ready

Reviewed at HEAD e5eea5c — one commit newer than the last automated round (b30d60f, which is no longer in the branch history; the branch appears to have been reworked). The new HEAD commit (fix(pages): decouple hydration from readiness) had not been reviewed before, so I focused there and re-verified the two long-standing ISR concerns.

The new commit is correct

e5eea5c reverts the isReady-gate on PagesRouterHydrationMarker so markPagesRouterHydrated() now fires unconditionally in a mount-only effect (router.ts:1454-1457), decoupling __NEXT_HYDRATED from router readiness. I traced the chain and it is sound and closer to Next.js parity:

  • Next.js sets __NEXT_HYDRATED at hydration-commit time, independent of router readiness. The previous isReady-gated version was the divergence; this restores parity.
  • Readiness is still correctly separated: window.next.router.isReady continues to gate on isPagesRouterReady() && __NEXT_HYDRATED (router.ts:3999-4001), so a not-ready fallback/auto-export page stays not-ready until the client entry's post-hydration Router.replace({ _h: 1 }) flips readiness.
  • The marker is idempotent (window.__NEXT_HYDRATED === true guard, router.ts:1445), so it is safe under StrictMode double-invocation and the removed explicit fallback call in the client entry is now correctly unnecessary.
  • waitForHydration (tests/e2e/helpers.ts) waits on both __NEXT_HYDRATED and router.isReady, so E2E coverage still exercises the readiness path.

Doc-note (non-blocking): worth a one-line comment at router.ts:1454 documenting the invariant that keeps readiness separate — __NEXT_HYDRATED signals hydration commit while router.isReady still ANDs isPagesRouterReady() — so a future reader doesn't re-gate this on isReady and reintroduce the coupling this commit removed.

Verified locally on this HEAD

  • shims -t "pre-ready" (4), entry-templates (41), isr-cache + pages-page-handler (100), pages-router-i18n-sticky-locale (12) all pass.
  • The prior REQUEST_CHANGES bundle-budget blocker stays resolvedtests/pages-router.test.ts -t "produces client bundle" passes (the live-search-query helper still lives in the vinext- runtime chunk, keeping the generated entry thin).
  • tsc --noEmit -p packages/vinext clean.
  • The earlier dev/prod asPath locale-parity concern is resolved: both dev (dev-server.ts:755) and prod (pages-page-handler.ts:463-467 via normalizeVisiblePagesUrlextractLocaleFromUrl(...).url) strip the locale from asPath.

The two ISR-isolation concerns from prior rounds are still open

I re-confirmed both against this HEAD. They are the highest-risk part of the diff and unrelated to the stated router-query fix. Details inline.

Process (as every prior round noted)

The PR is scoped/titled as router-query deferral for #1971 but now spans 41 commits and 37 files including middleware ISR origin-isolation, forceOrigin, CDN no-store gating, middlewareMatchCanVaryByRequest, query-keyed isolation, tag invalidation, monotonic cache timestamps, and header-override ISR bypass. Per AGENTS.md's stacked-PR guidance the ISR/middleware half would ideally be split for independent review/revert. Maintainer judgment.

Event is COMMENT — I do not have merge authority; the ISR findings are flagged for maintainer judgment, not a hard block.

Comment thread packages/vinext/src/server/isr-cache.ts Outdated
initialMiddlewareMatchCanVaryByRequest?: boolean;
}): boolean {
return (
input.initialMiddlewareMatched === true || input.initialMiddlewareMatchCanVaryByRequest === true

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.

Still open (flagged at 81f0612, 2136078, 916cc42, b30d60f): isolation is much broader than the rewrite-aliasing case it targets. shouldIsolatePagesMiddlewareIsr isolates on plain initialMiddlewareMatched === true, which is set on any matcher hit (pages-request-pipeline.ts:337result.matched === true), including middleware that returns nothing (middleware-runtime.ts:361-362) or calls NextResponse.next() to set a header (middleware-runtime.ts:365-373).

Effect by adapter:

  • default in-process (ownsBackgroundRevalidation === true): previously not isolated → now isolated for every matched getStaticProps page.
  • Cloudflare CDN (false): isolated either way.

With the common broad matcher (the repo fixture uses /((?!api|_next|favicon\.ico|...).*)), essentially every getStaticProps page behind that middleware routes ISR reads/writes to origin-only and emits no-store, so it is never CDN-cacheable. Next.js keeps getStaticProps pages CDN-cacheable even when non-request-varying middleware matched the path.

Keep the adapter gate for the plain-matched branch and only unconditionally isolate when the match can actually vary by request:

Suggested change
input.initialMiddlewareMatched === true || input.initialMiddlewareMatchCanVaryByRequest === true
input.initialMiddlewareMatchCanVaryByRequest === true ||
(input.initialMiddlewareMatched === true && !getCdnCacheAdapter().ownsBackgroundRevalidation)

(If broadening is deliberate for the rewrite-aliasing fix, isolating by visible pathname — see the pages-page-handler.ts comment — fixes aliasing without the CDN fallout.)

middlewareHeaders !== null &&
middlewareHeaders !== undefined &&
buildRequestHeadersFromMiddlewareResponse(request.headers, middlewareHeaders) !== null;
const isrIdentityUrl = isolateMiddlewareIsr ? routerAsPath : routeUrl;

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.

Still open (flagged at 81f0612, 2136078, 916cc42, b30d60f): the isolated ISR cache key includes the query string → unbounded growth. routerAsPath retains parsed.search (via normalizeVisiblePagesUrl, L234), and the composed key ${routerAsPath}::route=${encodeURIComponent(routeUrl)} (L615) embeds it directly.

Consequence: a matched-middleware getStaticProps page hit with ?utm_source=a, ?utm_source=b, … creates a distinct origin cache entry per query string, each with its own background render — defeating ISR dedup and letting an unauthenticated client grow the in-memory origin store without bound (memory-exhaustion / cache-stampede vector). Next.js keys SSG/ISR by normalized pathname, not query.

Note the tag and the key disagree on query handling: the path-based tag strips the query (getPagesPathCacheTags) and the on-demand invalidation at L609 uses the query-stripped tag, but the cache key keeps it — which makes targeted invalidation fragile. The isolates origin ISR entries... test (pages-page-handler.test.ts:489) only varies path+query together (/visible-a?draft=1 vs /visible-b?draft=2), so it does not catch the pure-query-variation growth case. The rewrite-aliasing case you want (/rewritten vs /mw-rewrite-query/ssr) only needs isolation by visible pathname. Strip the query:

Suggested change
const isrIdentityUrl = isolateMiddlewareIsr ? routerAsPath : routeUrl;
const isrIdentityUrl = isolateMiddlewareIsr ? routerAsPath.split("?")[0] : routeUrl;

(and drop the query from the composed key at L615 the same way)

return nextData?.gsp === true ? params : query;
}

export function getPagesSerializedRouterQuery<T extends Record<string, unknown>>(

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.

Non-blocking DRY hazard (open since round 1). getPagesInitialRouterQuery (L32) and getPagesSerializedRouterQuery (L43) are still byte-identical apart from the declared return type — same isFallback / autoExport / gsp guards, same result. They must stay in lockstep: the live SSR router.query and the serialized __NEXT_DATA__.query have to agree, and a silent divergence reintroduces the exact hydration mismatch this PR fixes.

Suggested change
export function getPagesSerializedRouterQuery<T extends Record<string, unknown>>(
export const getPagesSerializedRouterQuery = getPagesInitialRouterQuery;

(Drop the standalone body and export the alias. If a future divergence is intended, document the expected difference so nobody "re-deduplicates" incorrectly.)

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

I reviewed PR #2539 (fix(pages): defer router query until ready) at HEAD e5eea5c — one commit newer than the last automated round (b30d60f, which was reworked out of the branch history). I focused on the new HEAD commit (fix(pages): decouple hydration from readiness) and re-verified the two long-standing ISR concerns.

Verdict: no blocking correctness bug found on this HEAD (posted as COMMENT)

The new commit is correct. e5eea5c reverts the isReady-gate on PagesRouterHydrationMarker, so __NEXT_HYDRATED now fires at hydration-commit time independent of router readiness — restoring Next.js parity. I traced the full chain:

  • window.next.router.isReady still ANDs isPagesRouterReady() && __NEXT_HYDRATED, so not-ready fallback/auto-export pages stay not-ready until the _h:1 replace flips readiness.
  • The marker is idempotent (StrictMode-safe), and the removed explicit fallback hydration call is now correctly unnecessary.

Verified locally: pre-ready (4), entry-templates (41), isr-cache + pages-page-handler (100), i18n-sticky-locale (12) all pass; the prior bundle-budget blocker stays resolved; tsc clean. The earlier dev/prod asPath locale-parity concern is now resolved (both sides strip locale).

Two ISR-isolation concerns remain open (flagged inline)

  1. shouldIsolatePagesMiddlewareIsr (isr-cache.ts:187) isolates on plain matched — including pass-through NextResponse.next() middleware — so on the default in-process adapter, any getStaticProps page behind a broad matcher becomes origin-only + no-store and never CDN-cacheable, diverging from Next.js. I independently confirmed matched: true is set for empty/header-only middleware.
  2. Isolated ISR cache key (pages-page-handler.ts:607/615) embeds the query string, so ?utm_source=a/b/c… each create a distinct origin entry — an unbounded-growth/stampede vector. The existing test varies path+query together and doesn't catch pure-query variation. Tag and key disagree on query handling.

Plus the long-standing byte-identical getPages*RouterQuery DRY hazard and the scope-creep process note (41 commits, 37 files, well beyond the stated fix). Both ISR findings are flagged for maintainer judgment rather than a hard block.

github run

@james-elicx james-elicx force-pushed the codex/vinext-pages-router-isready-1971 branch from e5eea5c to 1982c20 Compare July 8, 2026 12:21
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

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

Review: fix(pages): defer router query until ready

Reviewed the checked-out branch at HEAD 1982c20 (fix(pages): decouple hydration from readiness). I independently verified the core fix, the two significant concerns resolved in earlier rounds, and re-confirmed the two ISR-isolation concerns that remain open.

The core fix is correct

The empty-router.query deferral for pre-ready auto-export dynamic / fallback renders matches Next.js render.tsx (query = {} for isAutoExport || isFallback while isReady === false). I verified:

  • Hydration/readiness decoupling on this HEAD is sound. markPagesRouterHydrated (router.ts:1444) fires unconditionally in a mount-only effect and is idempotent (window.__NEXT_HYDRATED === true guard), matching Next.js which sets __NEXT_HYDRATED at commit time. router.isReady remains separately gated on isPagesRouterReady() && __NEXT_HYDRATED, so not-ready routes stay not-ready until the post-hydration Router.replace({ _h: 1 }).
  • Dev/prod query-serialization parity holds. renderPagesPageResponse forwards query: options.query to buildPagesNextDataScript (pages-page-response.ts:515), so production serializes the full merged __NEXT_DATA__.query for gssp/gip, matching the dev path and the server React render.
  • Tests pass locally. shims -t "pre-ready" (4) and the prior REQUEST_CHANGES bundle-budget blocker stays resolved — tests/pages-router.test.ts -t "produces client bundle" passes (the live-search-query helper lives in the vinext- runtime chunk, keeping the generated entry thin).

Two ISR-isolation concerns remain open (git-confirmed as regressions in this PR)

I traced both through git history and confirmed they are introduced by this PR, not pre-existing. They are the highest-risk part of the diff and are unrelated to the stated router-query fix. Details inline. Event is COMMENT — flagging for maintainer judgment, not a hard block.

Process

As every prior round noted, the PR is scoped/titled as router-query deferral for #1971 but spans 40+ commits and 37 files including middleware ISR origin-isolation, forceOrigin, CDN no-store gating, middlewareMatchCanVaryByRequest, query-keyed isolation, tag invalidation, monotonic cache timestamps, and header-override ISR bypass. Per AGENTS.md's stacked-PR guidance the ISR/middleware half would ideally be split for independent review/revert.

Comment thread packages/vinext/src/server/isr-cache.ts Outdated
initialMiddlewareMatchCanVaryByRequest?: boolean;
}): boolean {
return (
input.initialMiddlewareMatched === true || input.initialMiddlewareMatchCanVaryByRequest === true

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.

ISR isolation is much broader than the rewrite-aliasing case it targets — git-confirmed regression in this PR.

This predicate was introduced in db42197 with the adapter gate:

input.initialMiddlewareMatchCanVaryByRequest === true ||
(input.initialMiddlewareMatched === true && !getCdnCacheAdapter().ownsBackgroundRevalidation)

Commit 81f0612 dropped the !ownsBackgroundRevalidation gate, so it now isolates on plain matched.

initialMiddlewareMatched is set on any matcher hit (pages-request-pipeline.ts:337result.matched === true), and middleware-runtime.ts returns matched: true for middleware that returns nothing (L362) or calls NextResponse.next() to set a header (L365-373). Effect by adapter:

  • default in-process (ownsBackgroundRevalidation === true, cdn-cache.ts:129): previously not isolated → now isolated for every matched getStaticProps page.
  • Cloudflare CDN (false, cdn-adapter.runtime.ts:126): isolated either way.

With the extremely common broad matcher (the repo fixture uses /((?!api|_next|favicon\.ico|...).*), middleware.ts:296), essentially every getStaticProps page behind that middleware now routes ISR reads/writes to origin-only and emits no-store, so it is never CDN-cacheable. Next.js keeps getStaticProps pages CDN-cacheable even when non-request-varying middleware matched the path.

Restore the adapter gate for the plain-matched branch and only unconditionally isolate when the match can actually vary by request:

Suggested change
input.initialMiddlewareMatched === true || input.initialMiddlewareMatchCanVaryByRequest === true
return (
input.initialMiddlewareMatchCanVaryByRequest === true ||
(input.initialMiddlewareMatched === true && !getCdnCacheAdapter().ownsBackgroundRevalidation)
);

(If broadening is deliberate for the rewrite-aliasing fix, isolating by visible pathname — see the pages-page-handler.ts comment — fixes aliasing without the CDN fallout.)

middlewareHeaders !== null &&
middlewareHeaders !== undefined &&
buildRequestHeadersFromMiddlewareResponse(request.headers, middlewareHeaders) !== null;
const isrIdentityUrl = isolateMiddlewareIsr ? routerAsPath : routeUrl;

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.

Isolated ISR cache key includes the query string → unbounded growth. routerAsPath retains parsed.search (normalizeVisiblePagesUrl, L234: stripBasePath(parsed.pathname, basePath) + parsed.search), and the composed key at L615 (${routerAsPath}::route=${encodeURIComponent(routeUrl)}) embeds it directly.

Consequence: a matched-middleware getStaticProps page hit with ?utm_source=a, ?utm_source=b, … creates a distinct origin cache entry per query string, each with its own background render — defeating ISR dedup and letting an unauthenticated client grow the in-memory origin store without bound (memory-exhaustion / cache-stampede vector). Next.js keys SSG/ISR by normalized pathname, not query.

The tag and the key disagree on query handling: the path-based tag strips the query (getPagesPathCacheTags) and the on-demand invalidation at L609 uses the query-stripped tag, but the cache key keeps it — which makes targeted invalidation fragile. The isolates origin ISR entries... test (pages-page-handler.test.ts:489) only varies path+query together (/visible-a?draft=1 vs /visible-b?draft=2), so it does not catch the pure-query-variation growth case.

The rewrite-aliasing case you want (/rewritten vs /mw-rewrite-query/ssr) only needs isolation by visible pathname. Strip the query:

Suggested change
const isrIdentityUrl = isolateMiddlewareIsr ? routerAsPath : routeUrl;
const isrIdentityUrl = isolateMiddlewareIsr ? routerAsPath.split("?")[0] : routeUrl;

(and drop the query from the composed key at L615 the same way)

return nextData?.gsp === true ? params : query;
}

export function getPagesSerializedRouterQuery<T extends Record<string, unknown>>(

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.

Non-blocking DRY hazard (open since round 1). getPagesInitialRouterQuery (L32) and getPagesSerializedRouterQuery (L43) are still byte-identical apart from the declared return type — same isFallback / autoExport / gsp guards, same result. They must stay in lockstep: the live SSR router.query (from getPagesInitialRouterQuery) and the serialized __NEXT_DATA__.query (from getPagesSerializedRouterQuery) have to agree, and a silent divergence reintroduces the exact hydration mismatch this PR fixes.

Collapse them so a future edit can't drift one from the other:

Suggested change
export function getPagesSerializedRouterQuery<T extends Record<string, unknown>>(
// The serialized `__NEXT_DATA__.query` must equal the live SSR snapshot, so it
// is intentionally the same computation. Kept as an alias so the two call sites
// stay in lockstep by construction rather than by convention.
export const getPagesSerializedRouterQuery = getPagesInitialRouterQuery;

(Drop the standalone function body and export the alias. If a future divergence is intended, document the expected difference so nobody "re-deduplicates" incorrectly.)

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

I reviewed PR #2539 at HEAD 1982c20 (fix(pages): decouple hydration from readiness) and posted a review with two open ISR-isolation concerns, one long-standing DRY note, and a process concern.

Verdict: No blocking correctness bug in the core fix; two ISR concerns flagged for maintainer judgment (COMMENT event)

The core router-query fix is correct. I independently verified:

  • The empty-router.query deferral matches Next.js render.tsx (query = {} for isAutoExport || isFallback while not ready).
  • The hydration/readiness decoupling on this HEAD is sound — markPagesRouterHydrated fires unconditionally and idempotently at commit time (Next.js parity), while isReady stays separately gated.
  • Dev/prod query-serialization parity holds (renderPagesPageResponse forwards query: options.query).
  • Tests pass locally, including the previously-blocking bundle-budget test.

Two ISR-isolation concerns remain open — I git-confirmed both are regressions introduced by this PR:

  1. shouldIsolatePagesMiddlewareIsr broadened past its target. Commit 81f0612 dropped the !ownsBackgroundRevalidation adapter gate that commit db42197 originally shipped. Since matched: true fires even for pass-through NextResponse.next() middleware (verified in middleware-runtime.ts:362/365), the default in-process adapter now isolates and emits no-store for essentially every getStaticProps page behind the common broad matcher — making them never CDN-cacheable, diverging from Next.js.
  2. Isolated ISR cache key embeds the query string. routerAsPath retains parsed.search, so ?utm_source=a/b/c… each create a distinct origin cache entry — an unbounded-growth/stampede vector. The existing test only varies path+query together, so it doesn't catch pure-query variation. The tag strips the query while the key doesn't.

Both came with suggestion blocks. I also reiterated the byte-identical getPages*RouterQuery DRY hazard and the scope-creep process note per AGENTS.md's stacked-PR guidance.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

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

Review: fix(pages): defer router query until ready

Reviewed at HEAD 42f47bdone commit newer than the last automated round (1982c20). The new commit, fix(pages): preserve middleware ISR identity, directly reworks the two long-standing ISR concerns from prior rounds — and it's a good direction: it stops isolating on plain matched (introducing middlewareAffectsCacheIdentity = matched && (rewrite || middleware set headers)) and strips the query from the isolated cache key (isrIdentityUrl = visiblePathname, key uses visiblePathname/routePathname). The prior over-broad-isolation and query-keyed-growth concerns are addressed on the MISS path.

However, this commit introduces a blocking regression. It changed the shouldIsolatePagesMiddlewareIsr signature to read middlewareAffectsCacheIdentity instead of initialMiddlewareMatched, but only the MISS path in pages-page-handler.ts computes and passes that field. The three HIT/STALE/background-regen call sites in pages-page-data.ts still call shouldIsolatePagesMiddlewareIsr(options.vinext ?? {}), and options.vinext is the serialized __vinext block, which carries initialMiddlewareMatched / initialMiddlewareMatchCanVaryByRequest but never middlewareAffectsCacheIdentity (see the type in client/vinext-next-data.ts:40-54 and the serialization at pages-page-handler.ts:547-556). So on those paths, isolation now silently keys off initialMiddlewareMatchCanVaryByRequest only, dropping the rewrite / header-modifying (middlewareAffectsCacheIdentity) case entirely.

Blocking: tests/pages-page-data.test.ts fails deterministically on this HEAD

AssertionError: expected 'public, max-age=0, must-revalidate' to be 'no-store'
  at tests/pages-page-data.test.ts:1125

The test keeps matched middleware cache HITs out of an active edge cache sets vinext: { hasMiddleware: true, initialMiddlewareMatched: true } and expects the HIT to be forced no-store. It now returns public, max-age=0, must-revalidate because shouldIsolatePagesMiddlewareIsr(options.vinext) at pages-page-data.ts:919 returns false (the field it needs isn't serialized). This test was not updated by the commit, and the Vitest required check will fail, blocking merge. (I ran the file: 1 failed / 61 passed, reproducible.)

Beyond the failing test, this is the exact MISS-vs-HIT cache-parity divergence AGENTS.md warns about: a rewrite-aliased getStaticProps page isolates correctly on its first (MISS) render but, once cached under the isolated key, its HIT/STALE responses no longer get no-store, and the background-regen forceOrigin at pages-page-data.ts:1017 is false when it should be true — so regeneration writes back through the wrong (shared) cache.

Suggested fix: the handler already computes isolateMiddlewareIsr (pages-page-handler.ts:606) and forwards it to renderPagesPageResponse (:862). Forward that same boolean into resolvePagesPageData and consume it at the three pages-page-data.ts sites instead of re-deriving from the serialized __vinext block — mirroring how this very commit changed renderPagesPageResponse to take an explicit isolateMiddlewareIsr option rather than calling shouldIsolatePagesMiddlewareIsr(options.nextData?.__vinext ?? {}).

What I verified is correct on this HEAD

  • Core router-query deferral matches Next.js render.tsx; pre-ready tests (4) green.
  • Prior bundle-budget blocker stays resolved (produces client bundle passes).
  • The query-stripping fix is sound: visiblePathname/routePathname are both .pathname (query-dropped), so the rewrite detection isn't confused by query strings, and the new pure-query test (pages-page-handler.test.ts:508-517) asserts one key for ?draft=1/?draft=2.
  • Pass-through NextResponse.next() (empty staged headers, no rewrite) correctly no longer isolates on the default adapter — the flipped preserves edge caching for matched pass-through middleware test passes.

Long-standing DRY note (non-blocking, open since round 1)

getPagesInitialRouterQuery and getPagesSerializedRouterQuery are still byte-identical; suggested collapsing to an alias inline.

cached.value.cacheControl,
cachedValue.status,
);
if (shouldIsolatePagesMiddlewareIsr(options.vinext ?? {})) {

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.

Blocking — MISS-vs-HIT ISR isolation divergence introduced by 42f47bd. This (and L1017, L1033) call shouldIsolatePagesMiddlewareIsr(options.vinext ?? {}), but the new predicate signature reads middlewareAffectsCacheIdentity, which is never serialized into the __vinext block (pages-page-handler.ts:547-556; type at client/vinext-next-data.ts:40-54). So the HIT / STALE / background-regen paths now isolate on initialMiddlewareMatchCanVaryByRequest only, silently dropping the rewrite / header-modifying case that the MISS path in pages-page-handler.ts:600-608 correctly isolates.

This breaks tests/pages-page-data.test.ts:1093 (keeps matched middleware cache HITs out of an active edge cache) deterministically:

expected 'public, max-age=0, must-revalidate' to be 'no-store'  (L1125)

The Vitest required check will fail.

The handler already computes isolateMiddlewareIsr (pages-page-handler.ts:606) and forwards it to renderPagesPageResponse (:862). Forward the same boolean into resolvePagesPageData and consume it here instead of re-deriving from the serialized block — mirroring how this commit changed renderPagesPageResponse to take an explicit isolateMiddlewareIsr flag:

Suggested change
if (shouldIsolatePagesMiddlewareIsr(options.vinext ?? {})) {
if (options.isolateMiddlewareIsr === true) {

routePath: options.routePattern,
routeType: "render",
},
{ forceOrigin: shouldIsolatePagesMiddlewareIsr(options.vinext ?? {}) },

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.

Same root cause as L919: forceOrigin here is derived from the serialized __vinext block, which lacks middlewareAffectsCacheIdentity, so for a rewrite/header-identity HIT this is false when the MISS path isolated it to origin — background regeneration then writes back through the shared cache instead of origin. Use the forwarded options.isolateMiddlewareIsr boolean:

Suggested change
{ forceOrigin: shouldIsolatePagesMiddlewareIsr(options.vinext ?? {}) },
{ forceOrigin: options.isolateMiddlewareIsr === true },

cached.value.cacheControl,
cachedValue.status,
);
if (shouldIsolatePagesMiddlewareIsr(options.vinext ?? {})) {

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.

Same root cause as L919/L1017 — STALE responses for a rewrite/header-identity page won't get no-store because middlewareAffectsCacheIdentity isn't in the serialized block. Use the forwarded flag:

Suggested change
if (shouldIsolatePagesMiddlewareIsr(options.vinext ?? {})) {
if (options.isolateMiddlewareIsr === true) {

@@ -632,6 +693,7 @@ export function createPagesPageHandler(
triggerBackgroundRegeneration,
vinext: serializedPagesNextData.__vinext,

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.

resolvePagesPageData receives isrGet/isrSet (L644-653) and cacheRouteUrl (L696) computed from isolateMiddlewareIsr, but not the boolean itself — so the HIT/STALE/regen paths inside pages-page-data.ts re-derive isolation from options.vinext and get it wrong for the identity case. Forward the already-computed flag alongside vinext:

Suggested change
vinext: serializedPagesNextData.__vinext,
vinext: serializedPagesNextData.__vinext,
isolateMiddlewareIsr,

(and add isolateMiddlewareIsr?: boolean to ResolvePagesPageDataOptions, then consume it at the three call sites above).

return nextData?.gsp === true ? params : query;
}

export function getPagesSerializedRouterQuery<T extends Record<string, unknown>>(

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.

Non-blocking DRY hazard (open since round 1). getPagesInitialRouterQuery (L32) and getPagesSerializedRouterQuery (L43) are still byte-identical apart from the declared return type — same isFallback / autoExport / gsp guards, same result. They must stay in lockstep: the live SSR router.query and the serialized __NEXT_DATA__.query have to agree, and a silent divergence reintroduces the exact hydration mismatch this PR fixes.

Suggested change
export function getPagesSerializedRouterQuery<T extends Record<string, unknown>>(
// The serialized `__NEXT_DATA__.query` must equal the live SSR snapshot, so it
// is intentionally the same computation. Kept as an alias so the two call sites
// stay in lockstep by construction rather than by convention.
export const getPagesSerializedRouterQuery = getPagesInitialRouterQuery;

(Drop the standalone body and export the alias. If a future divergence is intended, document the expected difference so nobody "re-deduplicates" incorrectly.)

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully as REQUEST_CHANGES.

Summary

I reviewed PR #2539 at HEAD 42f47bd — one commit newer than the last automated round (1982c20). The new commit, fix(pages): preserve middleware ISR identity, was not covered before. It's a genuine attempt to resolve the two long-standing ISR concerns (over-broad isolation, query-keyed cache growth), and it does fix them on the MISS path — but it introduces a blocking regression.

Blocking finding (CI will fail)

The commit changed shouldIsolatePagesMiddlewareIsr's signature to read a new middlewareAffectsCacheIdentity field instead of initialMiddlewareMatched, but only the MISS path (pages-page-handler.ts) computes and passes that field. The three HIT/STALE/background-regen call sites in pages-page-data.ts still call shouldIsolatePagesMiddlewareIsr(options.vinext ?? {}), and the serialized __vinext block never carries middlewareAffectsCacheIdentity. So those paths silently lost isolation for the rewrite / header-modifying case.

I confirmed this concretely: tests/pages-page-data.test.ts fails deterministically (expected 'public, max-age=0, must-revalidate' to be 'no-store' at L1125) — the test wasn't updated by the commit, so the required Vitest check will fail and block merge. Beyond the failing test, it's the exact MISS-vs-HIT cache-parity divergence AGENTS.md warns about (background regen forceOrigin also wrong for the identity case).

Suggested fix: forward the already-computed isolateMiddlewareIsr boolean from the handler into resolvePagesPageData and consume it at the three sites — exactly how this same commit fixed renderPagesPageResponse. Posted inline suggestion blocks for all four locations.

Verified correct

  • Core router-query deferral matches Next.js render.tsx (pre-ready tests green).
  • Prior bundle-budget blocker stays resolved.
  • Query-stripping fix and pass-through-middleware non-isolation are both sound on the MISS path.

Also re-flagged the long-standing byte-identical getPages*RouterQuery DRY note.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

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

Review: fix(pages): defer router query until ready

Reviewed at HEAD 0c61578one commit newer than the last automated round (42f47bd, which was flagged REQUEST_CHANGES for a MISS-vs-HIT ISR isolation regression). The new commit, fix(pages): propagate middleware ISR isolation, directly addresses that blocking finding and it holds up.

The prior blocking issue is resolved

The 42f47bd round found that shouldIsolatePagesMiddlewareIsr had switched to reading middlewareAffectsCacheIdentity, but only the MISS path computed it — the HIT/STALE/background-regen sites in pages-page-data.ts still re-derived isolation from the serialized __vinext block (which never carries that field), causing tests/pages-page-data.test.ts:1125 to fail deterministically. This is fixed on HEAD. The handler now forwards an explicit isolateMiddlewareIsr boolean into resolvePagesPageData (pages-page-handler.ts:658), and the three pages-page-data.ts sites consume options.isolateMiddlewareIsr === true (L913 HIT, L1014 regen forceOrigin, L1030 STALE) instead of re-deriving. I ran the file: tests/pages-page-data.test.ts passes (95/95 with pages-page-handler).

The two long-standing ISR concerns are addressed on this HEAD

Both concerns raised across the 81f06121982c20 rounds are resolved by the 42f47bd/0c61578 reworks:

  1. Over-broad isolationshouldIsolatePagesMiddlewareIsr (isr-cache.ts:186) no longer isolates on plain matched. It isolates on initialMiddlewareMatchCanVaryByRequest OR middlewareAffectsCacheIdentity, and the latter (pages-page-handler.ts:608) requires matched && (rewrite || status-override || response-headers-set). A pass-through NextResponse.next() with no headers no longer isolates, so getStaticProps pages behind a broad matcher stay CDN-cacheable. The flipped preserves edge caching for matched pass-through middleware test confirms this.
  2. Query-keyed cache growth — the isolated key now uses visiblePathname (.pathname, query-stripped) and middlewareRouteIdentity (rewrite pathname+search, or routePathname), not the full routerAsPath with parsed.search. isrIdentityUrl = visiblePathname (pages-page-handler.ts:623). A page hit with ?utm_source=a/b/c… now maps to a single origin entry, and the key aligns with the query-stripped tag.

Verified locally on this HEAD

  • tests/pages-page-data.test.ts + tests/pages-page-handler.test.ts (95) — pass (the prior blocker).
  • tests/isr-cache.test.ts + tests/kv-cache-handler.test.ts + tests/middleware-runtime.test.ts + tests/pages-request-pipeline.test.ts + tests/pages-page-response.test.ts (284) — pass.
  • Full tests/pages-router.test.ts (352) — pass, including the bundle-budget test (produces client bundle...) that was the 6277900 REQUEST_CHANGES blocker.
  • tests/shims.test.ts -t "pre-ready" (4), tests/pages-router-i18n-sticky-locale.test.ts + tests/entry-templates.test.ts + tests/middleware-runtime-trailing-slash.test.ts (66) — pass.
  • tsc --noEmit -p packages/vinext clean; vp lint clean.
  • The monotonic mutation-timestamp fix (Math.max(now, last+1)) in both MemoryCacheHandler and KVCacheHandler is a genuine same-ms set/revalidateTag race fix, consistently applied.
  • middlewareMatchCanVaryByRequest correctly short-circuits unconditional matchers (string patterns and has/missing-free objects) to false.

Verdict: no blocking correctness bug found on this HEAD.

One dev/prod ISR-isolation parity gap (non-blocking, dev ISR is best-effort), the long-standing DRY hazard, and two minor notes below. Plus the scope-creep process concern every prior round flagged.

Process (as prior rounds noted)

The PR is titled/scoped as router-query deferral for #1971 but spans 43 commits and 37 files, also landing middleware ISR origin-isolation, forceOrigin, CDN no-store gating, middlewareMatchCanVaryByRequest, query-keyed isolation, tag invalidation, monotonic cache timestamps, and header-override ISR bypass. Per AGENTS.md's stacked-PR guidance the ISR/middleware half would ideally be split for independent review/revert. Maintainer judgment; not a merge blocker.

const isolateMiddlewareIsr =
initialMiddlewareMatchCanVaryByRequest ||
middlewareRewriteUrl !== null ||
statusCode !== undefined;

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.

Non-blocking dev/prod ISR-isolation parity gap. The dev isolateMiddlewareIsr here diverges from the production handler's decision (pages-page-handler.ts:608-618) in two ways:

  1. Dev does not include the response-header-set case. Prod isolates when middlewareHeaders has keys (pages-page-handler.ts:612-614); dev only checks matchCanVaryByRequest || rewrite || status.
  2. Dev does not gate on initialMiddlewareMatched. Prod's middlewareAffectsCacheIdentity requires initialMiddlewareMatched === true; dev keys off middlewareRewriteUrl/statusCode directly.

In practice dev ISR collapses to the same store and just varies the Cache-Control header, so the blast radius is small — but AGENTS.md explicitly calls out dev/prod parity as high-risk. A middleware that sets a response header on a getStaticProps page would emit no-store in prod but not in dev. Worth aligning the dev computation with the prod middlewareAffectsCacheIdentity predicate (or extracting a shared helper) so the two can't drift, and adding a dev-path test for the header-set case.

return nextData?.gsp === true ? params : query;
}

export function getPagesSerializedRouterQuery<T extends Record<string, unknown>>(

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.

Non-blocking DRY hazard (open since round 1). getPagesInitialRouterQuery (L32) and getPagesSerializedRouterQuery (L43) are still byte-identical apart from the declared return type — same isFallback / autoExport / gsp guards, same result. They must stay in lockstep: the live SSR router.query (from getPagesInitialRouterQuery) and the serialized __NEXT_DATA__.query (from getPagesSerializedRouterQuery) have to agree, and a silent divergence reintroduces the exact hydration mismatch this PR fixes.

Collapse them so a future edit can't drift one from the other:

Suggested change
export function getPagesSerializedRouterQuery<T extends Record<string, unknown>>(
// The serialized `__NEXT_DATA__.query` must equal the live SSR snapshot, so it
// is intentionally the same computation. Kept as an alias so the two call sites
// stay in lockstep by construction rather than by convention.
export const getPagesSerializedRouterQuery = getPagesInitialRouterQuery;

(Drop the standalone body and export the alias. If a future divergence is intended, document the expected difference so nobody "re-deduplicates" incorrectly.)

routeUrl ? currentUrl : undefined,
{ _h: 1, scroll: false },
fallbackRoute || routeUrl || currentUrl,
fallbackRoute || routeUrl ? currentUrl : undefined,

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.

Minor readability (generated template): this relies on || binding tighter than the ternary. It is correct ((fallbackRoute || routeUrl) ? currentUrl : undefined) but reads ambiguously next to L322 where fallbackRoute || routeUrl || currentUrl is a value, not a condition. Parenthesize the condition to make intent explicit:

Suggested change
fallbackRoute || routeUrl ? currentUrl : undefined,
fallbackRoute || routeUrl ? currentUrl : undefined,

(Suggestion left byte-identical; the ask is only to wrap (fallbackRoute || routeUrl) in parens if you touch this line.)

const currentValue = parsed.__vinext.initialMiddlewareMatched === true;
if (currentValue === initialMiddlewareMatched) return html;
const currentLiteral = `"initialMiddlewareMatched":${currentValue ? "true" : "false"}`;
const valueIndex = json.lastIndexOf(currentLiteral);

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.

rewritePagesInitialMiddlewareMatched rewrites only the last occurrence via json.lastIndexOf(currentLiteral). This is correct today because buildPagesNextDataScript serializes user props first and the top-level __vinext block last, and JSON.stringify preserves insertion order — so the top-level __vinext.initialMiddlewareMatched is always the last "initialMiddlewareMatched":<bool> literal. It is a little fragile: if a future serialization-order change ever placed user-controlled data (which could legitimately contain the literal string) after __vinext, the wrong occurrence would be silently rewritten. Worth a one-line comment noting the reliance on __vinext being serialized last so a future reorder doesn't quietly break the rewrite target.

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

I reviewed PR #2539 at HEAD 0c61578one commit newer than the last automated round (42f47bd, which had been flagged REQUEST_CHANGES). The new commit, fix(pages): propagate middleware ISR isolation, directly resolves the blocking finding, and it holds up.

Verdict: no blocking correctness bug found on this HEAD (posted as COMMENT)

The prior blocking issue is fixed. The 42f47bd round found that the HIT/STALE/background-regen sites in pages-page-data.ts re-derived isolation from the serialized __vinext block (which lacked middlewareAffectsCacheIdentity), failing tests/pages-page-data.test.ts:1125. The new commit forwards an explicit isolateMiddlewareIsr boolean from the handler into resolvePagesPageData and consumes it at all three sites — exactly the fix the prior round suggested. I confirmed the test now passes.

The two long-standing ISR concerns are now addressed:

  1. Over-broad isolation — no longer isolates on plain matched; requires matchCanVaryByRequest or matched && (rewrite || status || headers-set). Pass-through NextResponse.next() stays CDN-cacheable.
  2. Query-keyed cache growth — the isolated key now uses the query-stripped visiblePathname, aligning with the query-stripped tag.

Verified locally: pages-page-data+pages-page-handler (95), ISR/kv/middleware/pipeline/response (284), full pages-router.test.ts (352, including the bundle-budget test), pre-ready (4), i18n/entry-templates/trailing-slash (66). tsc clean, vp lint clean.

Non-blocking notes posted inline:

  1. A dev/prod ISR-isolation parity gap — dev's isolateMiddlewareIsr omits the response-header-set case and the matched gate that prod's middlewareAffectsCacheIdentity includes (dev ISR is best-effort, so small blast radius).
  2. The long-standing DRY hazard — getPagesInitialRouterQuery/getPagesSerializedRouterQuery still byte-identical.
  3. Minor ||/ternary readability in pages-client-entry.ts.
  4. rewritePagesInitialMiddlewareMatched lastIndexOf fragility.

Plus the scope-creep process concern (43 commits / 37 files well beyond the stated router-query fix) flagged for maintainer judgment.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

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

Review: fix(pages): defer router query until ready

Reviewed at HEAD 6a0bca9one commit newer than the last automated round (0c61578). The new commit, fix(pages): align middleware ISR identity in dev, directly targets the dev/prod ISR-isolation parity gap flagged in the previous round. I focused there and re-verified the earlier concerns.

Verdict: no blocking correctness bug found on this HEAD (posted as COMMENT)

The new commit materially narrows the dev/prod gap. Dev's isolateMiddlewareIsr now includes initialMiddlewareOverridesRequestHeaders (dev-server.ts:763-767), the field is threaded through the pipeline (pages-request-pipeline.ts) into both the internal renderPage calls and result.renderOptions, and forwarded to the dev SSR handler (index.ts:5286). The dev ISR key is now computed once up front (dev-server.ts:768-774) and reused across the HIT / STALE / regen / data-req / streamed-ISR write paths, replacing the several ad-hoc pagesIsrCacheKey(url.split("?")[0]) sites that previously ignored isolation — a real internal-consistency fix. Request-header-override renders now correctly bypass the ISR read/write in dev (isrGet → null, data-req and streamed-ISR writes skipped).

The two long-standing ISR-isolation concerns from prior rounds are resolved on this HEAD:

  • Over-broad isolation — shouldIsolatePagesMiddlewareIsr (isr-cache.ts:186) no longer isolates on plain matched; it requires matchCanVaryByRequest OR middlewareAffectsCacheIdentity (= matched && (rewrite || status || response-headers-set)). Pass-through NextResponse.next() stays CDN-cacheable.
  • Query-keyed growth — the isolated key uses visiblePathname + query-stripped middlewareRouteIdentity (pages-page-handler.ts:606-608 now drops .search). The new pure-rewrite-query test (pages-page-handler.test.ts:521-530) asserts one key across ?variant=one/two.

Verified locally on this HEAD:

  • tests/pages-page-data.test.ts + tests/pages-page-handler.test.ts + tests/pages-request-pipeline.test.ts (178) pass.
  • tests/isr-cache.test.ts + tests/kv-cache-handler.test.ts + tests/middleware-runtime*.test.ts + tests/pages-router-i18n-sticky-locale.test.ts + tests/entry-templates.test.ts pass.
  • The prior REQUEST_CHANGES bundle-budget blocker stays resolved — tests/pages-router.test.ts -t "produces client bundle" passes.
  • tsc --noEmit -p packages/vinext clean; vp lint clean.
  • Core router-query deferral, dev/prod query-serialization parity, and buildId default all remain correct.

Notes (non-blocking)

  1. Dev/prod ISR-isolation parity still not fully closed. This commit aligns the request-header-override case, but dev's predicate (dev-server.ts:763-767) still diverges from prod's middlewareAffectsCacheIdentity (pages-page-handler.ts:609-615): dev does not include the response-header-set case and does not gate on initialMiddlewareMatched. A getStaticProps page behind middleware that only sets a response header would isolate + no-store in prod but not in dev. Blast radius is small (dev ISR is best-effort, single store), but per AGENTS.md dev/prod parity is high-risk — a shared predicate would prevent future drift.
  2. DRY hazard (open since round 1). getPagesInitialRouterQuery (pages-readiness.ts:32) and getPagesSerializedRouterQuery (L43) are still byte-identical apart from the declared return type; they must stay in lockstep or the exact hydration mismatch this PR fixes returns.
  3. Minor readability in the generated client entry (pages-client-entry.ts:323).

Process (as every prior round noted)

The PR is scoped/titled as router-query deferral for #1971 but now spans 44 commits and 37 files including middleware ISR origin-isolation, forceOrigin, CDN no-store gating, middlewareMatchCanVaryByRequest, query-keyed isolation, tag invalidation, monotonic cache timestamps, and header-override ISR bypass. Per AGENTS.md's stacked-PR guidance the ISR/middleware half would ideally be split for independent review/revert. Maintainer judgment.

initialMiddlewareMatchCanVaryByRequest ||
middlewareRewriteUrl !== null ||
statusCode !== undefined ||
initialMiddlewareOverridesRequestHeaders;

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.

Non-blocking dev/prod ISR-isolation parity gap (partially closed by this commit). This now adds initialMiddlewareOverridesRequestHeaders, aligning the request-header-override case with prod. But the dev predicate still diverges from prod's middlewareAffectsCacheIdentity (pages-page-handler.ts:609-615) in two ways:

  1. Dev does not include the response-header-set case. Prod isolates when the middleware staged response headers ([...middlewareHeaders.keys()].length > 0); dev has no equivalent — it only checks matchCanVaryByRequest || rewrite || status || overridesRequestHeaders.
  2. Dev does not gate on initialMiddlewareMatched. Prod's middlewareAffectsCacheIdentity requires initialMiddlewareMatched === true before the rewrite/status/headers checks.

So a getStaticProps page behind middleware that only sets a response header would emit no-store in prod but not in dev. Dev ISR is best-effort (single store, just varies Cache-Control), so the blast radius is small — but AGENTS.md flags dev/prod parity as high-risk. Consider extracting a shared middlewareAffectsCacheIdentity-style predicate so the two can't drift, and add a dev-path test for the response-header-set case (there is currently none).

return nextData?.gsp === true ? params : query;
}

export function getPagesSerializedRouterQuery<T extends Record<string, unknown>>(

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.

Non-blocking DRY hazard (open since round 1). getPagesInitialRouterQuery (L32) and getPagesSerializedRouterQuery (L43) are still byte-identical apart from the declared return type — same isFallback / autoExport / gsp guards, same result. They must stay in lockstep: the live SSR router.query (from getPagesInitialRouterQuery) and the serialized __NEXT_DATA__.query (from getPagesSerializedRouterQuery) have to agree, and a silent divergence reintroduces the exact hydration mismatch this PR fixes.

Collapse them so a future edit can't drift one from the other:

Suggested change
export function getPagesSerializedRouterQuery<T extends Record<string, unknown>>(
// The serialized `__NEXT_DATA__.query` must equal the live SSR snapshot, so it
// is intentionally the same computation. Kept as an alias so the two call sites
// stay in lockstep by construction rather than by convention.
export const getPagesSerializedRouterQuery = getPagesInitialRouterQuery;

(Drop the standalone function body and export the alias. If a future divergence is intended, document the expected difference so nobody "re-deduplicates" incorrectly.)

routeUrl ? currentUrl : undefined,
{ _h: 1, scroll: false },
fallbackRoute || routeUrl || currentUrl,
fallbackRoute || routeUrl ? currentUrl : undefined,

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.

Minor readability (generated template): this relies on || binding tighter than the ternary. It is correct ((fallbackRoute || routeUrl) ? currentUrl : undefined) but reads ambiguously next to L322 where fallbackRoute || routeUrl || currentUrl is a value, not a condition. Parenthesize the condition to make intent explicit:

Suggested change
fallbackRoute || routeUrl ? currentUrl : undefined,
(fallbackRoute || routeUrl) ? currentUrl : undefined,

@ask-bonk

ask-bonk Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

I reviewed PR #2539 at HEAD 6a0bca9one commit newer than the last automated round (0c61578). The new commit (fix(pages): align middleware ISR identity in dev) directly targets the dev/prod ISR-isolation parity gap flagged in the previous round, so I focused there and re-verified the earlier concerns.

Verdict: no blocking correctness bug found on this HEAD

The new commit materially narrows the dev/prod gap. Dev's isolateMiddlewareIsr now includes initialMiddlewareOverridesRequestHeaders, threaded through the pipeline into both the internal renderPage calls and result.renderOptions. The dev ISR key is now computed once up front and reused across all HIT/STALE/regen/data-req/streamed-ISR write paths, replacing several ad-hoc pagesIsrCacheKey(url.split("?")[0]) sites that previously ignored isolation.

Both long-standing ISR concerns are resolved on this HEAD:

  • Over-broad isolation — no longer isolates on plain matched.
  • Query-keyed growth — isolated key uses query-stripped visiblePathname + middlewareRouteIdentity.

Verified locally: targeted test suites pass (178+ across pages/isr/middleware/pipeline), the prior bundle-budget blocker stays resolved, tsc clean, vp lint clean.

Notes posted (all non-blocking)

  1. Dev/prod ISR parity still not fully closed — dev's predicate aligns the request-header-override case but still omits the response-header-set case and the matched gate that prod's middlewareAffectsCacheIdentity includes. Small blast radius (dev ISR is best-effort), but flagged per AGENTS.md's dev/prod parity guidance; suggested a shared predicate + dev test.
  2. DRY hazardgetPagesInitialRouterQuery/getPagesSerializedRouterQuery still byte-identical (open since round 1).
  3. Minor ||/ternary readability nit.
  4. Scope-creep process concern (44 commits, 37 files) flagged for maintainer judgment.

github run

@james-elicx james-elicx marked this pull request as draft July 8, 2026 13:00
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: useRouter().query populated while isReady is false on auto-exported dynamic routes

2 participants