fix(pages): align prerender functional parity - #2471
Conversation
|
/bigbonk review for issues |
commit: |
|
824d23c to
e5b6269
Compare
|
/bigbonk review for issues |
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
/bigbonk review — PR #2471 (fix(pages): align prerender functional parity)
Reviewed all 13 changed files against Next.js canary source. The three main changes are well-founded and match Next.js behavior. One incomplete-feature concern and a couple of minor notes below.
Verified correct
-
Fallback hydration
routeUrl(pages-client-entry.ts+pages-page-handler.ts). Passing the server-resolvedrouteUrlas the navigationurland the browser location asasmatches Next.js's fallback hydration inpackages/next/src/client/index.tsx:130-153, where it callsrouter.replace(router.pathname + '?' + query, asPath, { _h: 1 }). Previously vinext used the browser location for both, which fetched data for the wrong URL under rewrites. Good fix. -
Non-fatal
_hhydration data failures (shims/router.ts). Returning early on!res.ok/ invalid JSON whenisHydrationQueryUpdatematches Next.js'srouter.ts:2123-2131("we don't hard error during query updating ... return null"). Verified the early return still letsperformNavigationrunonStateUpdate?.()(viarunNavigateClient→"completed"), soisReadyis still flipped and the router isn't left in a broken state. -
VINEXT_NEXT_DEPLOY_CACHE_CONTROLgate (cache-control.ts). The env flag is deliberately set only inscripts/e2e-deploy.sh:640to match Vercel-deploy-style browserCache-Controlfor SSG/ISR HTML and_next/datawhen running the Next.js E2E harness. This is an intentional, documented divergence, not dead code — good. -
Props-serialization gating (
pages-page-data.ts+ handler).validatePropsSerialization: NODE_ENV !== "production" || VINEXT_PRERENDER === "1"mirrors Next.js only validating serializable props during dev/build, not production SSR. Matchesrender.tsx.
Concern — res.setPreviewData() writes cookies nothing consumes
setPreviewData (pages-node-compat.ts:338-354) emits __prerender_bypass and __next_preview_data cookies, but no code path reads __next_preview_data to populate context.preview / context.previewData in getStaticProps. The getStaticProps context built at pages-page-data.ts:980-990 has no preview/previewData fields, and a repo-wide search finds no reader for __next_preview_data. So calling res.setPreviewData() sets the cookies but preview mode never actually activates on subsequent requests.
Two sub-points:
- This is a partial feature. If the intent is "align functional parity," calling
setPreviewData()and then loading the page should skip the SSG cache and re-rungetStaticPropswithcontext.preview === true. That half isn't wired up. Consider either wiring the reader in this PR or filing a tracking issue and noting the limitation. - Payload format differs from Next.js. Next.js signs
__next_preview_dataas an encrypted JWT (api-resolver.ts:187-201); vinext stores rawbase64url(JSON). That's fine as an internal representation as long as the future reader decodes the same way — but it means these cookies are not interchangeable with a real Next.js deployment, and the 2KB limit check operates on a smaller payload than Next.js's signed token. Worth a comment documenting the deliberate difference (similar to thegetRevalidateSecret↔previewModeIdnote inisr-cache.ts).
Minor
-
Header-merge key casing (
pages-page-handler.ts:715-717). Iterating aHeadersobject yields lowercased keys (cache-control), which are merged intoinit.headers. Here it's safe because in theelse if (isStaticPropsRoute)branchgsspResis null soinit.headersstarts empty — no pre-existing capitalizedCache-Controlto leave stranded. Fine as-is, but fragile if the branch conditions ever change; a short comment noting the empty-headers precondition would help. -
Test coverage.
tests/pages-node-compat.test.tsonly asserts the two preview cookies are appended and existing cookies preserved. It doesn't assertHttpOnly/Secure/SameSite/Path/Max-Ageattributes or the 2KB-limit throw. Consider adding those, plus a test thatsetPreviewDataactually enables preview mode end-to-end once the reader exists.
Overall the cache-control, hydration-URL, and props-validation changes are solid and match Next.js. The setPreviewData addition is the one piece that is functionally incomplete (cookies with no consumer) and should be flagged as such.
|
Review posted to PR #2471. Summary of findings: Review SummaryVerified correct (3 core changes match Next.js):
Main concern:
Minor:
All relevant tests pass (117 + 40) and type check is clean. |
e5b6269 to
b75d46b
Compare
|
/bigbonk review for issues |
There was a problem hiding this comment.
/bigbonk review — PR #2471 (fix(pages): align prerender functional parity)
Re-reviewed against the current head commit (b75d46b). Note: the previous bonk review was against an earlier version where res.setPreviewData() wrote cookies with no consumer. That gap is now closed — this PR wires the reader (getPagesPreviewDataFromCookieHeader / getPagesPreviewData) into both the dev path (dev-server.ts) and the prod path (pages-page-handler.ts → resolvePagesPageData), and populates context.preview / context.previewData for getStaticProps and getServerSideProps. Preview mode now actually activates. Good — and dev/prod parity is maintained.
Verified correct
-
Preview reader end-to-end.
getPagesPreviewData(request, { isOnDemandRevalidate })is threaded throughcreatePagesPageHandler(pages-page-handler.ts:602) and the dev handler (dev-server.ts:933,1094). ThepreviewData === falseguards consistently skip SSG/ISR cache hits, stale serving,generatedFromDataRequestreuse,isrRevalidateSecondscapture, andshouldPersistFallbackDatawrites — matching Next.js, which bypasses the ISR cache entirely under preview. Empty-object{}(bypass cookie present, no payload) correctly activates preview withpreviewData: {}. -
On-demand-revalidate disables preview.
getPagesPreviewDataFromCookieHeaderreturnsfalsewhenisOnDemandRevalidateis set (pages-node-compat.ts:211), so preview cookies can't poison a regenerated ISR entry. Matches Next.js. Well-commented. -
Cookie round-trip is symmetric.
serializePreviewCookieencodeURIComponents the value;parseCookieHeaderdecodeURIComponents on read (utils/parse-cookie.ts:7). base64url payloads survive intact. -
Fallback hydration
routeUrl. Passing server-resolvedrouteUrlasurland browser location asas(pages-client-entry.ts:298-303) matches Next.jsclient/index.tsxfallback replace, fixing data-fetch under rewrites. -
Non-fatal
_hhydration failures (shims/router.ts:2172-2174,2197-2199) match Next.jsrouter.ts"don't hard error during query updating".performNavigationstill runsonStateUpdatesoisReadyisn't stranded. -
VINEXT_NEXT_DEPLOY_CACHE_CONTROLgate is an intentional E2E-harness flag, not dead code. -
Props-serialization gating (
validatePropsSerialization: NODE_ENV !== "production" || VINEXT_PRERENDER === "1") mirrors Next.js only validating in dev/build, not prod SSR.
All 121 relevant tests pass; vp check (format + type + lint) is clean.
Concern — non-constant-time secret comparison
getPagesPreviewDataFromCookieHeader compares the bypass cookie against the revalidate secret with plain !==:
if (bypass !== getRevalidateSecret()) return false; // pages-node-compat.ts:217isr-cache.ts deliberately exposes safeEqual (constant-time) and uses it for the same secret in isOnDemandRevalidateRequest specifically to avoid a timing side-channel. Using !== here reintroduces that channel for the preview-bypass path. Recommend importing and using safeEqual for parity with the existing on-demand-revalidate check.
(For context: App Router draft mode's isDraftModeRequest also uses === on a per-request random UUID — a weaker target — but the Pages preview cookie is checked against the long-lived build-baked secret, which is the more sensitive comparison. Worth fixing here even if the App-router one is pre-existing.)
Minor
-
Shared
__prerender_bypasscookie, different secrets. App Router draft mode signs__prerender_bypasswith a per-contextdraftModeSecret(random UUID); Pages preview signs it withgetRevalidateSecret()(build-baked). Next.js uses onepreviewModeIdfor both. Since a route is either App or Pages this doesn't misfire in practice, but the two systems can't share the cookie. Pre-existing divergence — worth a note but not a blocker for this PR. -
setPreviewData(false)/ non-object payloads.decodePagesPreviewPayloadreturnsString(value)for non-objects, sosetPreviewData(false)round-trips to the string"false". Preview data is almost always an object, so low-impact, but a short comment or normalizing to{}for primitives would avoid surprise. -
Header-merge casing (
pages-page-handler.ts:717-718). Merging the lowercasedcache-controlkey fromapplyCdnResponseHeadersback intoinit.headersis safe only becauseinit.headersstarts empty in theelse if (isStaticPropsRoute)branch (gSSP null → no capitalizedCache-Control). Confirmed safe today; a one-line comment noting the empty-headers precondition would guard against future edits. -
Test coverage.
tests/pages-node-compat.test.tsnow asserts the round-trip read (good), but doesn't assert cookie attributes (HttpOnly/Secure/SameSite/Path/Max-Age), the 2KB-limit throw, or the wrong-secret rejection (bypass !== secret → false). Adding a wrong-secret case would also lock in the constant-time fix above.
Overall: the functional-parity changes (preview mode wiring, cache-control, hydration URL, props validation) are solid and match Next.js. The one thing I'd change before merge is the constant-time secret comparison.
|
Review posted to PR #2471. Review SummaryI re-reviewed against the current head commit. Key difference from the prior bonk review: the PR has since been updated — the previous review's main concern (" Verified correct:
Main concern (only pre-merge change I'd request):
Minor: shared |
|
/bigbonk review for issues |
1 similar comment
|
/bigbonk review for issues |
There was a problem hiding this comment.
/bigbonk review — PR #2471 (fix(pages): align prerender functional parity)
Re-reviewed against the current head (816050c, which added fix(pages): harden preview bypass comparison on top of b75d46b). The prior bonk review's one pre-merge concern — the non-constant-time secret comparison — is now resolved. All changes match Next.js behavior. No blocking issues found.
Concern from prior review is fixed
The previous review flagged bypass !== getRevalidateSecret() as a timing side-channel. The new commit extracts a shared isRevalidateSecret() helper in isr-cache.ts that routes through the existing constant-time safeEqual, and both getPagesPreviewDataFromCookieHeader and isOnDemandRevalidateRequest now use it. Preview-bypass and on-demand-revalidate auth are now checked identically and in constant time. tests/pages-node-compat.test.ts adds a wrong-secret rejection case that locks this in.
Verified correct
- Preview reader wired dev + prod (parity).
getPagesPreviewDatais threaded throughcreatePagesPageHandler(pages-page-handler.ts:602), andgetPagesPreviewDataFromCookieHeaderthrough the dev gSP/gSSP paths (dev-server.ts:933,1094). The prod path is the same code the Cloudflare worker and Node prod server delegate to viapages-server-entry.ts→createPagesPageHandler, so all three request paths (dev, prod, worker) inherit the reader. Confirmed no separate Pages SSR logic exists inworker-entry.tsorprod-server.ts. previewData === falseguards are consistent. Fresh-hit, stale-hit,generatedFromDataRequestreuse,isrRevalidateSecondscapture, andshouldPersistFallbackDatawrites all bypass SSG/ISR when preview is active, in bothpages-page-data.tsanddev-server.ts(incl. thestaticPropsPreviewData === falseguard on the dev_next/datapersist path atdev-server.ts:1528). Empty-object{}(bypass cookie, no payload) correctly activates preview without hitting=== false.- On-demand-revalidate disables preview.
getPagesPreviewDataFromCookieHeaderreturnsfalsewhenisOnDemandRevalidateis set (pages-node-compat.ts:184) so preview cookies can't poison a regenerated ISR entry. Well-commented, matches Next.js. - Non-fatal
_hhydration failures (shims/router.ts:2173-2174,2198-2199). Traced the flow: on early return,navigateClientresolves normally →runNavigateClientreturns"completed"→performNavigationstill runsonStateUpdate?.()atrouter.ts:3136, soisReadyis flipped and the router isn't stranded. Matches Next.js "don't hard error during query updating". - Fallback hydration
routeUrl(pages-client-entry.ts:298-303,vinext-next-data.ts). Passing server-resolvedrouteUrlasurland browser location asasmatches Next.jsclient/index.tsx, fixing data-fetch under rewrites. VINEXT_NEXT_DEPLOY_CACHE_CONTROLgate (cache-control.ts) is set only inscripts/e2e-deploy.sh:640(prod/deploy harness). The dev-server not applyingBROWSER_REVALIDATE_CACHE_CONTROLon_next/datais therefore not a dev/prod parity bug — the flag never runs in dev. Intentional E2E divergence.- Props-serialization gating (
validatePropsSerialization: NODE_ENV !== "production" || VINEXT_PRERENDER === "1") mirrors Next.js validating serializable props in dev/build only, not prod SSR.
All 162 relevant tests pass (pages-node-compat, cache-control, pages-page-data, pages-page-response, entry-templates); vp check (format + lint + type) is clean.
Minor (non-blocking)
setPreviewData(false)/ non-object payloads.decodePagesPreviewPayloadreturnsString(value)for primitives, sosetPreviewData(false)round-trips to the string"false"(which is!== false, so preview stays active rather than being disabled). Preview data is almost always an object, so low-impact, but normalizing primitives to{}— or a short comment — would avoid the surprise.- Header-merge casing (
pages-page-handler.ts:717-719). Merging the lowercasedcache-controlkey from theHeadersiterator back intoinit.headersis safe only because theelse if (isStaticPropsRoute)branch runs whengsspResis null, soinit.headersstarts empty (the gSSP header-copy block at 687-693 is skipped) — no capitalizedCache-Controlto strand. Confirmed safe today; a one-line precondition comment would guard against future edits to the branch conditions. - Shared
__prerender_bypasscookie, different secrets. App Router draft mode signs it with a per-context random UUID; Pages preview signs it withgetRevalidateSecret(). Next.js uses onepreviewModeIdfor both. A route is only ever App or Pages so it doesn't misfire, but the two systems can't interop. Pre-existing divergence — worth a note, not a blocker.
Overall: solid PR. The functional-parity changes match Next.js, dev/prod/worker parity is maintained, and the constant-time comparison from the prior review is fixed. No changes required before merge.
|
Review posted to PR #2471. Review SummaryI re-reviewed against the current head ( Prior concern resolved: The non-constant-time secret comparison flagged in the last bonk review ( Verified correct:
Verification: 162 relevant tests pass; Minor (non-blocking): No changes required before merge. |
|
Deploy-suite run 28478866791 / job 84413308650 triage: this PR appears to cover these
|
Performance benchmarksCompared 0 improved · 0 regressed · 6 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
Summary
_next/dataresponses_hhydration data updatesres.setPreviewData()cookiesFailure mapping
test/e2e/prerender.test.tscache header rows for revalidate/fallback/no-revalidate pages, fallback dynamic SSG rewrite, invalid JSON fromgetStaticPropson SSR/CST, on-demand revalidate with preview cookie, and no prerender data fetch on mount.should not supply query values to params or useRouter non-dynamic page SSR. Existing coverage comments are already present there.should not revalidate when set to false. Added a coverage note: fix(pages): align on-demand ISR regeneration semantics #2027 (comment)Validation
vp test run tests/cache-control.test.ts tests/pages-page-response.test.ts tests/pages-page-data.test.ts tests/pages-node-compat.test.ts tests/entry-templates.test.tsvp check tests/cache-control.test.ts tests/pages-page-response.test.ts tests/pages-page-data.test.ts tests/pages-node-compat.test.ts tests/entry-templates.test.tsREPO="$(pwd)" NEXTJS_DIR="/Users/jamesanderson/Developer/vinext/.nextjs-ref" NEXT_TEST_CONCURRENCY=1 ./scripts/run-targeted-nextjs-e2e.sh test/e2e/prerender.test.tsArtifacts from the local E2E/build run were removed after validation.