fix(app-router): match Pages navigation params in hybrid builds#1741
fix(app-router): match Pages navigation params in hybrid builds#1741NathanDrake2406 wants to merge 9 commits into
Conversation
commit: |
8652698 to
06bc621
Compare
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: Pages navigation params readiness in hybrid builds
Solid, well-targeted PR. I verified the core readiness model against Next.js v16.2.6 source and it is a faithful port:
getPagesNavigationIsReadyFromSerializedStatemirrors the PagesRouterconstructorisReadypredicate exactly (gssp || gip || isExperimentalCompile || (appGip && !gsp) || (!autoExportDynamic && !search && !__NEXT_HAS_REWRITES)). TheautoExport && isDynamicRoutegating and thehasRewritesterm match upstream.- The two-phase snapshot (pre-ready
nullparams / empty search params, then ready values published via the queued notification) correctly produces the "exactly 2 params effect runs" behavior the upstream test observes, and theisReady-keyed client context cache keeps the post-ready params object referentially stable so unrelated rerenders don't add a third run. isPagesRouterDocumentActive()correctly prevents Pages compat state from shadowing App Router navigation snapshots in hybrid documents (App browser entry setswindow.next.appDir = trueviainstallWindowNext), and the__VINEXT_PAGE_LOADERS__-first ordering gives Pages fallback documents priority.- The client-entry manifest approach (emitting
vinext-client-entry-manifest.jsonfrom bundle metadata) is a clean fix for discovering hashed Pages entries in mixed builds, with good unit coverage for the marker-priority lookup and on-disk fallback.
Test coverage is strong (unit + e2e + ported Next.js fixtures). A few non-blocking observations:
1. useParams<T>() return type vs. nullable pre-ready snapshots. getServerParamsSnapshot()/getClientParamsSnapshot() can now return null (pre-ready Pages state), but useParams<T>() is typed T and casts the snapshots with as () => T. This matches Next.js runtime behavior (Pages useParams returns null until ready), but the type lies to TS consumers — const p = useParams(); p.foo typechecks yet p is null during the pre-ready window. Next.js's public useParams type is Params | null. Consider widening the return type or documenting the nullable window so consumers handle it rather than hitting a runtime Cannot read properties of null.
2. Readiness flip depends on PagesRouterProvider mounting. The transition is driven by setTimeout(0) inside PagesRouterProvider's effect (router.ts ~L1862), and _pagesRouterReady + the ready notification only fire if that provider mounts. Next.js flips isReady inside Router.change() (the hydration query-update), independent of any component. If a deferred (pre-ready) Pages route renders useParams/useSearchParams from next/navigation but never instantiates a PagesRouterProvider (a page that doesn't use next/router's useRouter), the readiness transition won't fire and params stay null indefinitely. Worth confirming PagesRouterProvider always wraps Pages content in the hydration entry rather than only when useRouter() is called — or moving the ready-flip to the runtime install path so it's component-independent.
3. App Router startup now unconditionally calls installPagesClientAssetGlobals (prod-server.ts ~L1173). For pure App Router projects (no pages/) this sets __VINEXT_SSR_MANIFEST__/__VINEXT_LAZY_CHUNKS__/__VINEXT_CLIENT_ENTRY__ globals that were previously left unset. These globals are only consumed by pages-server-entry.ts, and for App-only builds they resolve to undefined/{}, so the change is benign — but consider gating on a hasPagesDir-equivalent signal or adding a comment so it's clear it's intentionally inert for App-only deployments and to avoid reading/parsing manifests that won't be used.
4. Minor dead branch in getClientParamsSnapshot (navigation.ts ~L1432). The if (state && Object.keys(state.clientParams).length > 0) return state.clientParams; block is redundant with the following return state?.clientParams ?? _EMPTY_PARAMS; — both return state.clientParams when state is truthy. The block (and the getClientNavigationState() call) can be dropped.
|
Review posted successfully: #1741 (review) SummaryI reviewed PR #1741 (fix: match Pages navigation params in hybrid builds) against the Next.js v16.2.6 source and verified the core logic is correct. Strengths confirmed:
Non-blocking observations raised (4):
No correctness blockers found. I posted these as a single grouped review (line-anchored comments were rejected with 422 because the target lines weren't part of the diff hunks, so I consolidated them into the summary body with file/line references). |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 06bc621d43
ℹ️ 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".
06bc621 to
00e29dd
Compare
00e29dd to
6688869
Compare
|
/bigbonk review for issues |
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review for issues |
2 similar comments
|
/bigbonk review for issues |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: Pages navigation readiness in hybrid builds
Follow-up review focused on correctness and dev/prod parity. The readiness model (getPagesNavigationIsReadyFromSerializedState), the two-phase pre-ready/ready snapshots, the hashed-entry manifest, and the isPagesRouterDocumentActive() guard all look sound and well-tested. I verified SSR/client readiness parity holds because __NEXT_DATA__ now serializes the same gssp/gsp/gip/appGip/autoExport/isExperimentalCompile/__vinext.hasRewrites fields the SSR predicate consumes (pages-page-response.ts:156-162, pages-server-entry.ts:1048), so the client's shouldDeferInitialPagesRouterReady() recomputes the identical result.
I did find one genuine correctness gap plus a couple of minor cleanups.
1. (likely bug) useRouter().isReady is always true during SSR, diverging from the deferred next/navigation hooks
This PR correctly defers useParams/useSearchParams/usePathname server snapshots using ssrCtx.navigationIsReady (router.ts:560-570). But useRouter().isReady is fed by PagesRouterProvider → getRouterSnapshot() → isPagesRouterReady(), and isPagesRouterReady() returns true unconditionally on the server (typeof window === "undefined"). It never consults ssrCtx.navigationIsReady.
Consequences:
- Next.js parity gap. Next's server-side readiness rule (the same
render.tsx#L641-L654predicate this PR cites) returnsfalsefor auto-export dynamic / query-string / rewrite-capable routes, sorouter.isReadyshould befalsein that initial render, nottrue. - Hydration-mismatch hazard. For a pre-ready route the server now renders
router.isReady === truewhile the client's initial_pagesRouterReadyis deliberatelyfalse. Any component that readsrouter.isReadydirectly in JSX (rather than in a post-hydrationuseEffect, as the fixtures do) will mismatch on first paint. The existing fixtures only readpagesRouter.isReadyinsideuseEffect, so this case is currently untested.
Suggested direction: have the server branch of the readiness check read the SSR context, e.g. make isPagesRouterReady() (or getRouterSnapshot()) return _getSSRContext()?.navigationIsReady ?? true on the server instead of always true, and add an SSR assertion that useRouter().isReady is false for an auto-export dynamic / query-string render.
2. (nit) Dead branch in getClientParamsSnapshot
The early-return is redundant with the fall-through on the next line — both yield state.clientParams when state is truthy.
3. (type-safety, carried over) useParams<T>(): T can now return null
getClientParamsSnapshot/getServerParamsSnapshot now legitimately return null during the pre-ready window, but useParams<T>() is typed T and casts via as () => T. This matches runtime behavior but the type hides the nullable window (Next's public type is Params | null). Worth widening the return type or documenting it so consumers guard before dereferencing.
4. (nit) App-only startup now reads Pages manifests
startAppRouterServer unconditionally calls installPagesClientAssetGlobals (prod-server.ts:1173), which reads the SSR manifest + client build manifest from disk even for pure App-only deployments. Benign (results resolve to undefined), but a hasPagesDir-style guard or a clarifying comment would avoid the unnecessary disk reads and make the intent clear.
| } | ||
|
|
||
| function getRouterSnapshot(): ReturnType<typeof getPathnameAndQuery> & { isReady: boolean } { | ||
| return { ...getPathnameAndQuery(), isReady: isPagesRouterReady() }; |
There was a problem hiding this comment.
getRouterSnapshot() feeds useRouter().isReady via PagesRouterProvider. On the server isPagesRouterReady() returns true unconditionally and never reads _getSSRContext()?.navigationIsReady, so for pre-ready routes (auto-export dynamic / query string / rewrites) SSR renders isReady: true while the client hydrates with isReady: false. That diverges from Next's server readiness rule and risks a hydration mismatch for any component reading router.isReady directly in JSX.
Consider deriving readiness from the SSR context on the server:
| return { ...getPathnameAndQuery(), isReady: isPagesRouterReady() }; | |
| function getRouterSnapshot(): ReturnType<typeof getPathnameAndQuery> & { isReady: boolean } { | |
| const isReady = | |
| typeof window === "undefined" | |
| ? (_getSSRContext()?.navigationIsReady ?? true) | |
| : isPagesRouterReady(); | |
| return { ...getPathnameAndQuery(), isReady }; | |
| } |
| if (state && Object.keys(state.clientParams).length > 0) { | ||
| return state.clientParams; | ||
| } | ||
| return state?.clientParams ?? _EMPTY_PARAMS; |
There was a problem hiding this comment.
The preceding if (state && Object.keys(state.clientParams).length > 0) return state.clientParams; is redundant with this line — both return state.clientParams whenever state is truthy. The guard block can be dropped:
| return state?.clientParams ?? _EMPTY_PARAMS; | |
| return state?.clientParams ?? _EMPTY_PARAMS; |
|
Review posted successfully: #1741 (review) SummaryI reviewed PR #1741 against the Next.js v16.2.6 source. The core design is sound and well-tested — I verified SSR/client readiness parity holds because I found one likely correctness bug plus three minor items:
Codex's earlier |
…arams # Conflicts: # packages/vinext/src/entries/pages-server-entry.ts
Address review feedback on cloudflare#1741: - getRouterSnapshot() now reads navigationIsReady from the SSR context on the server instead of unconditionally returning true. For pre-ready Pages routes (auto-export dynamic / query-string / rewrite-capable builds) this keeps useRouter().isReady consistent between SSR and client hydration, avoiding a hydration mismatch for components that read it in JSX. Mirrors Next.js render.tsx's server readiness rule. - Drop the redundant guard branch in getClientParamsSnapshot. - Add a regression test asserting server useRouter().isReady reflects the SSR navigationIsReady context.
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: Pages navigation params readiness in hybrid builds
Reviewed for correctness and dev/prod parity. The PR is well-architected and the previous review rounds (the useRouter().isReady SSR bug, the Page.getInitialProps detection, the dead branch) have all been addressed in the current diff. I verified the following against Next.js v16.2.6 source:
- Readiness predicate (
getPagesNavigationIsReadyFromSerializedState, router.ts:728) is a faithful port of the PagesRouterconstructorisReadyrule (gssp || gip || isExperimentalCompile || (appGip && !gsp) || (!autoExportDynamic && !search && !hasRewrites)). - SSR/client parity for
__NEXT_DATA__: prod (pages-page-handler.ts:395-404+serializedPagesNextDataat :465) and dev (dev-server.ts:577-586+serializedPagesNextDataat :1252) serialize the identicalgssp/gsp/gip/appGip/autoExport/__vinext.hasRewritesfields the clientshouldDeferInitialPagesRouterReady()reads, so the client recomputes the sameisReady. The devhasPageGipcheck now readspageModule.default?.getInitialPropsmatching prod. useRouter().isReadySSR fix (router.ts:789-801): now derives from_getSSRContext()?.navigationIsReadyon the server instead of unconditionaltrue, with a regression test in shims.test.ts. Removes the hydration-mismatch hazard.isReadyaccessor on theRouterobject (router.ts:2325) correctly switched from a statictrueto a getter delegating toisPagesRouterReady().- Readiness flip depends on
PagesRouterProvidermounting viasetTimeout(0); confirmedwrapWithRouterContextalways wraps Pages content in both SSR (pages-page-handler.ts:502,629) and the Pages client entry (pages-client-entry.ts:186), and App documents never mount it but are gated out byisPagesRouterDocumentActive(). - Root-cause fix (the hashed Pages client entry discovery): the new
vinext-client-entry-manifest.json(index.ts:2730) plusinstallPagesClientAssetGlobals(prod-server.ts:1086) restore__VINEXT_CLIENT_ENTRY__for Pages fallback routes in mixed app+pages App Router prod/Cloudflare builds, whichcollectAssetTags(pages-asset-tags.ts:133) injects as the hydration script. nextConfig?.rewrites.beforeFiles.length(index.ts:3776, pages-server-entry.ts:367) follows the existing normalized-config pattern (rewritesis always{ beforeFiles, afterFiles, fallback }) — not a new throw hazard.
Ran locally: client-build-manifest.test.ts (12 passed), the new shims readiness tests, and the pages-router.test.ts nav-compat suite — all green.
No correctness blockers. Two minor, non-blocking observations below.
| ? (getRouteParamsFromQuery(ssrCtx.pathname, ssrCtx.query) ?? | ||
| extractRouteParamsFromPath(ssrCtx.pathname, resolvedPath) ?? | ||
| {}) | ||
| : null; |
There was a problem hiding this comment.
(nit) SSR vs. client param-resolution ordering differs. Here the SSR path tries getRouteParamsFromQuery(ssrCtx.pathname, ssrCtx.query) before extractRouteParamsFromPath, while the client path in _buildClientPagesNavigationContext (router.ts:496) does the reverse — extractRouteParamsFromPath first, then getRouteParamsFromQuery. For ordinary dynamic and catch-all routes the two helpers resolve to the same params, so this is benign in practice. But if a query key ever collides with a route-param name (or a serialized query array diverges from path extraction), SSR and client could publish different params on the ready snapshot, risking a subtle post-hydration mismatch. Worth aligning the two orderings to keep them provably identical.
| // no prefix is configured). The URL prefix the prod-server needs to strip | ||
| // before locating files on disk includes this path plus `_next/static/`. | ||
| const appAssetPathPrefix = assetPrefixPathname(appRouterAssetPrefix); | ||
| const appAssetBase = appRouterBasePath ? `${appRouterBasePath}/` : "/"; |
There was a problem hiding this comment.
(nit, carried over) App-only startup now reads Pages manifests. startAppRouterServer unconditionally calls installPagesClientAssetGlobals, which reads ssr-manifest.json + manifest.json from disk and sets __VINEXT_SSR_MANIFEST__/__VINEXT_CLIENT_ENTRY__/__VINEXT_LAZY_CHUNKS__ even for pure App-only deployments (no pages/). It's benign — those globals are only consumed by the Pages renderer (pages-asset-tags.ts) and resolve to undefined/{} for App-only — but a hasPagesDir-style guard or a one-line comment noting it's intentionally inert for App-only builds would avoid the unnecessary disk reads and clarify intent.
|
Review posted successfully: #1741 (review) SummaryI reviewed PR #1741 for correctness and dev/prod parity against Next.js v16.2.6 source. The PR is well-architected and faithful to Next.js semantics. All three concerns from prior review rounds (the Verified correct:
I ran the client-build-manifest, shims readiness, and pages-router nav-compat tests locally — all green. Posted 2 non-blocking nits:
No correctness blockers found. |
Address review feedback (ask-bonk + self-review) on cloudflare#1741: - Align client Pages navigation params to query-first, matching the server snapshot and Next.js's adaptForPathParams (derives path params from router.query). __NEXT_DATA__.query is kept current on client navigation, so the two snapshots are now provably identical and avoid a latent post- hydration mismatch. - Populate window.__VINEXT_PAGE_PATTERNS__ in the dev server (own script tag) so the next/navigation compat hooks can resolve a dynamic pattern from a resolved path in dev exactly as in production. Closes a dev/prod parity gap (prod set this via the client entry; dev never did). - Drop the redundant pre-readiness setSSRContext in dev; the single post- module-load call now publishes navigationIsReady before rendering, matching the prod handler. - Document that installPagesClientAssetGlobals is intentionally inert for App-only builds, and that getReadonlyPagesSearchParams' string-keyed cache is safe to share across concurrent SSR requests (immutable wrappers). - Add a dev regression test asserting __VINEXT_PAGE_PATTERNS__ exposure.
|
/bigbonk review for issues |
1 similar comment
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: Pages navigation params readiness in hybrid builds
Reviewed for correctness and dev/prod parity against Next.js semantics. The PR is sound and the substantive concerns from prior review rounds are all resolved in the current diff. I independently verified:
- Readiness predicate (
getPagesNavigationIsReadyFromSerializedState, router.ts:734) faithfully mirrors the PagesRouterconstructorisReadyrule (gssp || gip || isExperimentalCompile || (appGip && !gsp) || (!autoExportDynamic && !search && !hasRewrites)), gating the dynamic-route branch onautoExportas Next.js does. useRouter().isReadySSR fix (router.ts:803-806) now derives from_getSSRContext()?.navigationIsReadyinstead of unconditionaltrue, removing the hydration-mismatch hazard, with a regression test in shims.test.ts.- Dev/prod
__NEXT_DATA__parity: dev (dev-server.ts:580-604) and prod (pages-page-handler.ts:395-439) serialize the identicalgssp/gsp/gip/appGip/autoExport/__vinext.hasRewritesfields the clientshouldDeferInitialPagesRouterReady()recomputes, and both now detectgipviapageModule.default?.getInitialProps(Codex's earlier concern). The prod main render path setsnextDataon the SSR context (pages-page-handler.ts:439); the ISR-revalidation re-render at pages-page-data.ts:589 omits it, but that branch only runs for already-cached GSP routes (autoExport: false), so the pathname-null logic is unaffected. - Readiness flip depends on
PagesRouterProvidermounting viasetTimeout(0); confirmedwrapWithRouterContextalways wraps Pages content in SSR (pages-page-handler.ts:502) and the Pages client entry (pages-client-entry.ts:186), and App documents are gated out byisPagesRouterDocumentActive(). The notify wiring (PAGES_NAVIGATION_NOTIFY_KEY) correctly re-reads the navigation snapshot throughuseSyncExternalStoreafter the flip. - Root-cause fix: the new
vinext-client-entry-manifest.json(index.ts:2730, facade matching handles the\0prefix at index.ts:556) plusinstallPagesClientAssetGlobals(prod-server.ts:1178) restore__VINEXT_CLIENT_ENTRY__for hashed Pages entries in mixed app+pages builds. The App-only-startup unconditional call now carries an explanatory comment (prod-server.ts:1173-1177). - The dead branch in
getClientParamsSnapshotis gone; the function is clean (navigation.ts:1433).
No correctness blockers. Two non-blocking observations:
1. (type-safety, now newly reachable) useParams<T>(): T can return null
The hook casts the snapshot via getClientParamsSnapshot as () => T / getServerParamsSnapshot as () => T (navigation.ts:1533/1538-1539). On main, getServerParamsSnapshot() returned Record<...> and defaulted to _EMPTY_PARAMS — it could never be null. This PR changes both snapshot helpers to return Record<...> | null and now legitimately returns null during the pre-ready Pages window (navigation.ts:1436/1440/1452). So the as () => T cast hides a null that previously could not occur: const p = useParams(); p.foo typechecks but throws at runtime during the pre-ready window. Next.js's public type is Params | null. Consider widening the return type to T | null (or documenting the nullable window) so consumers guard before dereferencing. This was raised in earlier rounds and remains open.
2. (nit) Confirm SSR/client ready-snapshot param ordering stays aligned
The SSR ready path resolves params query-first — getRouteParamsFromQuery(ssrCtx.pathname, ssrCtx.query) then extractRouteParamsFromPath (router.ts:570-572) — and _buildClientPagesNavigationContext uses the same order (router.ts:501-504), with the comment at router.ts:495 noting query-first is intentional for byte-for-byte SSR/client parity. They look aligned. Just flagging to keep them provably identical: if a query key ever collided with a route-param name, divergent ordering would publish different params on the ready snapshot and risk a post-hydration mismatch. No change needed if they stay in lockstep.
|
Review posted successfully: #1741 (review) SummaryI reviewed PR #1741 for correctness and dev/prod parity against Next.js semantics. The PR is well-architected and all substantive concerns from prior review rounds are resolved in the current diff. I independently verified each fix against the source rather than relying on the prior bot reviews. Verified correct:
Two non-blocking items raised:
No correctness blockers. Inline anchors were rejected with 422 (target lines outside diff hunks), so I consolidated into the review body with file:line references. |
|
/bigbonk review for issues |
Summary
app/+pages/production buildsnext/navigationcompat hooks: pre-ready Pages snapshots expose empty search params and nullable path params, then publish the ready params once the Pages router is readyuseParams()hydration snapshot stable by deriving route params from__NEXT_DATA__until the Pages router readiness transition provides the real Pages contextRoot Cause
The upstream navigation deploy suite exercises a Pages Router dynamic page inside a hybrid App Router project. That route was server-rendered, but vinext did not reliably discover the Pages Router client entry after the client bundle was emitted with generic hashed filenames, so production HTML for the fallback page could omit every module script needed for hydration.
Once hydration was restored, the remaining failure was identity-level: vinext treated the Pages router as immediately ready, while Next.js's Pages adapter returns
nullpath params untilrouter.isReady, then publishes path params throughPathParamsContext. The upstream test observes exactly two params effect runs: initial hydration and the ready params object; unrelated rerenders must not create another object.Next.js References
SearchParamsContextandPathParamsContext: packages/next/src/shared/lib/router/adapters.tsxuseParamsdocs: nextjs.org/docs/app/api-reference/functions/use-paramsVerification
vp test run tests/client-build-manifest.test.ts tests/app-router.test.ts -t "client build manifest helpers|emits the Pages Router client entry"PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e tests/e2e/app-router/pages-router-use-params.spec.tsvp run vinext#buildvp checkReferences #1555