Commit Graph

14 Commits

Author SHA1 Message Date
Nathan Nguyen b7ef466742 fix(pages): preserve app props for GSSP requests (#1996)
* fix(pages): preserve app props for gssp requests

* docs(pages): update _next/data comment to reflect full props envelope

* fix(pages-router): preserve _app.getInitialProps during stale ISR regeneration

Stale ISR background regeneration called getStaticProps directly and rendered
HTML and __NEXT_DATA__ with only the page's pageProps, dropping app-level
props from _app.getInitialProps. The foreground path already loads
_app.getInitialProps first and merges its props, but the background
regeneration path skipped that step.

Extract a shared loadPagesAppInitialRenderProps helper from the production
foreground path and reuse it during background regeneration in both
pages-page-data.ts and dev-server.ts. This ensures regenerated pages carry the
same full props envelope as the original render.

Add a regression test in tests/pages-page-data.test.ts.

* fix(pages-router): skip _app.getInitialProps on ISR cache hits

The foreground render path ran _app.getInitialProps before consulting the
ISR cache, so fresh and stale cache hits executed userland App data code
even though the cached HTML was returned verbatim. That wasted work on the
hot path and ran side-effecting App code whose result the cached response
never reflected.

The eager App initial-props load happened up front, ahead of the HIT and
STALE short-circuits. Defer it until the request will actually render:
load it lazily inside the getServerSideProps / getStaticProps /
getInitialProps branches, after the cache short-circuits return.
Stale-while-revalidate already re-runs it inside the regeneration closure,
so regenerated HTML still carries app-level props. The dev SSR handler and
the production page-data resolver are updated in lockstep to preserve
dev/prod parity.

Tests cover the fresh-HIT skip and the stale foreground-skip vs
regeneration-run split at the page-data resolver boundary.

* refactor(pages-router): extract dev App getInitialProps loader into a tested helper

The dev SSR handler ran _app.getInitialProps inside an inline closure that
mutated captured pageProps/renderProps and read res directly, so its lazy
load-only-on-render behavior could only be covered by a heavyweight
integration test that vi.doMock'd the isr-cache and file-matcher modules —
a mock cascade with little signal.

Move the decision into loadDevAppInitialProps in pages-get-initial-props.ts.
It takes the App component, req/res, and an injected AppTree builder (keeping
React out of the module) and returns a discriminated result —
skip / response-sent / render — that the caller applies. The dev-server
closure is now thin glue: build the React tree, delegate, apply the result.

Adds focused unit tests that drive the helper through its real boundary
(genuine getInitialProps functions, plain req/res inputs) — no module mocks:
the skip path, App/page prop merging, pageProps normalization, the
response-sent short-circuit, and the router/ctx wiring passed to userland.

* fix(pages): preserve app props across data requests

* fix(pages): preserve fallback cache expiry

* fix(pages): preserve dev app context during ISR

* fix(pages): hydrate middleware rewrite fallbacks

* fix(pages): retarget middleware data rewrites

* fix(pages): preserve rewrite query on navigation

* fix(pages): finalize rewrite navigation state

* fix(pages): resolve rewrite data targets consistently

* fix(pages): await static props during regeneration

* fix(pages): preserve original query in resolved url

* Revert "fix(pages): await static props during regeneration"

This reverts commit cbdcdf65cd.

* test(app): cover rewrite params in app search

---------

Co-authored-by: James <james@eli.cx>
2026-06-14 20:17:05 +01:00
James Anderson eef22e07a8 fix(pages-router): block fallback shell for crawler UAs on prerender (#1543) (#1663)
* fix(pages-router): block fallback shell for crawler UAs on prerender (#1543)

When `getStaticPaths` returns `fallback: true` and an unlisted path is hit
by a known bot/crawler, vinext was serving the `Loading...` shell so the
crawler indexed the fallback HTML instead of the real page. Mirror Next.js
`pages-handler.ts` and flip `isFallback` to false for bot User-Agents so
the request renders synchronously with real props.

- Add `isBotUserAgent` helper (DOM + HTML bot regex) in `utils/html-limited-bots.ts`,
  matching Next.js `shared/lib/router/utils/is-bot.ts`.
- Plumb `userAgent` + `htmlLimitedBots` into `resolvePagesPageData` and
  flip the fallback bit when the UA matches.
- Apply the same flip in the dev `createSSRHandler` for parity.
- Targeted unit + integration regression tests covering Googlebot,
  Bingbot, DuckDuckBot, YandexBot, facebookexternalhit, and a normal
  Chrome UA control. Ported from `.nextjs-ref/test/e2e/prerender-crawler.test.ts`.

Refs #1543.

* fix(pages-router): preserve array user-agent bot detection
2026-06-13 00:35:28 +00:00
James Anderson f5355964aa fix(pages-router): set x-nextjs-deployment-id on SSG _next/data responses (#1863)
* fix(pages-router): set x-nextjs-deployment-id on SSG _next/data responses

Pages Router `/_next/data/<buildId>/<page>.json` responses were missing the
`x-nextjs-deployment-id` response header that Next.js sets on these routes
when a deployment ID is configured. The client router reads this header to
detect when a new deployment has been rolled out and triggers a hard
navigation for deployment-skew protection.

Root cause: the `isDataReq` short-circuit path in `pages-page-handler.ts`
that builds the JSON envelope (`buildNextDataJsonResponse`) never injected
the header. Next.js's `pages-handler.ts` sets `NEXT_NAV_DEPLOYMENT_ID_HEADER`
on every `isNextDataRequest` response (success, redirect, and notFound cases).

Fix: read `process.env.__VINEXT_DEPLOYMENT_ID || process.env.NEXT_DEPLOYMENT_ID`
(the runtime-available deployment ID, inlined at build time by Vite's define
transform) and set `x-nextjs-deployment-id` on the `_next/data` response
headers when a deployment ID is configured.

Maps to test: `pages ssg data deployment skew - hard navigate when a new
deployment occurs > header with deployment id > header is set on data routes`.

Fixes #1829

* fix(pages-router): complete x-nextjs-deployment-id parity for _next/data (bonk #1829)

Address all three ask-bonk findings on PR #1863:

1. Incomplete parity — add x-nextjs-deployment-id to redirect and notFound _next/data
   exits in pages-page-data.ts (buildPagesRedirectResponse, buildPagesDataNotFoundResponse).
   Thread deploymentId through ResolvePagesPageDataOptions and pass it from
   pages-page-handler.ts, matching the success-path pattern already in that file.

2. Dev/prod parity — patch dev-server.ts to set x-nextjs-deployment-id on all three
   _next/data exits: the writeGsspRedirect isDataReq path, the getStaticPaths/
   getServerSideProps/getStaticProps notFound isDataReq paths, and the success
   JSON envelope short-circuit (isDataReq block). Mirrors the prod handler.

3. Add regression tests asserting x-nextjs-deployment-id is present on _next/data
   success / redirect / notFound responses in both pages-page-data.test.ts (unit,
   via deploymentId option) and pages-page-handler.test.ts (integration, via
   process.env.__VINEXT_DEPLOYMENT_ID), and absent when no env var is set.

* fix(pages-router): add x-nextjs-deployment-id to wrong-buildId/route-miss _next/data 404s (#1829)

Fixes the deployment-skew gap identified in review: `buildNextDataNotFoundResponse` now
includes the `x-nextjs-deployment-id` header so all three wrong-buildId/route-miss exits
(normalizePagesDataRequest, prod-server Node path, pages-page-handler route-miss) surface
the deployment ID to the client router. Adds unit tests for the new header on all relevant
exits.

* fix(pages-router): set deployment-id header on remaining two dev-mode _next/data 404 exits (#1829)

Patches the stale-buildId 404 in the vite-dev middleware (index.ts) and
the route-miss data 404 in createSSRHandler (dev-server.ts) to set
x-nextjs-deployment-id, matching prod parity via buildNextDataNotFoundResponse().

* test(pages-router): cover dev-mode _next/data deployment-id header exits

Drive the two dev-only exits flagged in review through the real fixture
dev server: the wrong-buildId data 404 in the plugin middleware
(index.ts) and createSSRHandler's route-miss data 404 (dev-server.ts),
plus the dev success path and the no-deployment-id case.

* fix(pages-router): skip deployment-id header on /_error and /500 data success responses

Next.js pages-handler.ts guards the success-path x-nextjs-deployment-id
injection with `!isErrorPage && !is500Page`. Mirror that exclusion in
both the prod success short-circuit (pages-page-handler.ts) and the dev
one (dev-server.ts), and lock it in with a prod unit test (/_error and
/500) plus a dev integration test (/500).
2026-06-11 00:39:16 +00:00
Nathan Nguyen 494e8d2d5b fix(pages): render custom errors for notFound results (#1737)
* fix(pages): render custom errors for notFound results

Pages getStaticProps and getServerSideProps notFound HTML results returned the built-in 404 response directly. That skipped the configured pages/404 or pages/_error render path, so _error.getInitialProps never received ctx.req.url or ctx.asPath for the original request.

The page-data resolver now returns a typed notFound signal for HTML requests while preserving JSON-shaped 404 data responses. The generated Pages entry rerenders the configured not-found route with the original asPath, and Pages getInitialProps is loaded through a shared typed helper for prod and dev parity.

* fix(pages): address PR review feedback on getInitialProps binding, err context, and response-sent handling

- Call getInitialProps with component as this receiver
- Thread caught errors through to _error.getInitialProps(ctx.err)
- Check headersSent || writableEnded in dev server after getInitialProps
- Add tests: gSSP notFound, explicit 404, data JSON 404, err on throw, this binding

* refactor(pages): use isResponseSent helper in resolvePagesPageData for sent-check consistency

The prod block in resolvePagesPageData checked only res.headersSent, while
the helper's isResponseSent and the dev server check both headersSent and
writableEnded. The prod PagesReqResResponse only declares headersSent (its
getter is backed by the ended flag), so this is functionally correct in
prod, but the asymmetry is hard to read next to the helper and a future
change to the prod response shape would silently break sent-detection.

Export isResponseSent from pages-get-initial-props and use it at both
gSSP and gIP call sites so the prod resolver reuses the same
sent-detection logic as the helper.

Also adds tests for the writableEnded-only path and a focused unit test
for isResponseSent covering both prod-shaped and node-shaped res mocks.

* perf(pages): gate getInitialProps req/res allocation behind presence check

The getInitialProps prod block in resolvePagesPageData unconditionally
called createGsspReqRes() for every static page without gSP/gSSP, even
when the page had no getInitialProps either. This allocated a req/res
pair and an unresolved responsePromise that was immediately discarded.

Export hasPagesGetInitialProps from pages-get-initial-props.ts and gate
the prod block on it, keeping the hot path for plain static pages
allocation-free.
2026-06-05 21:48:31 +01:00
James Anderson f6e1525e22 fix(pages-router): throw clear error on non-serializable gSP/gSSP props (#1478) (#1642)
When `getStaticProps` or `getServerSideProps` returned props containing
non-JSON-serializable values (e.g. `Date`, `Map`, class instances), vinext
silently rendered an empty page instead of surfacing an error. This ports
Next.js's `isSerializableProps` check from `packages/next/src/lib/is-serializable-props.ts`
and invokes it from `pages-page-data.ts` and `dev-server.ts` after the
redirect/notFound short-circuits, mirroring `render.tsx` (~lines 982 and
1200). The thrown `SerializableError` is caught by the existing per-request
error boundary, which returns a 500 with a Next.js-style message naming the
offending key.
2026-05-28 15:44:13 +01:00
Nathan Nguyen 5b633b0284 fix(pages): render masked basePath error routes (#1441)
* fix(pages): render masked basePath error routes

Pages Router route misses under basePath returned generic HTML and masked client navigations to /404 or /_error could fetch the visible as-path instead of the error route. This diverged from Next.js when applications used next/router to preserve a friendly URL while rendering the error component.

The implementation assumed the browser URL and the component route could always collapse to one fetch target. Preserve the masked history URL, fetch the Pages error route as the HTML target, allow its 404 response to hydrate, and render pages/404 for production route misses with the correct status and client module metadata.

* test(pages): keep error navigation coverage out of vitest browser

Vitest integration CI does not install Playwright browsers, so launching Chromium from tests/pages-router.test.ts made the PR fail deterministically even though the upstream deploy harness passed.

Keep the browser-level verification in the upstream deploy-suite run and cover vinext locally at the lower boundaries: production 404 rendering through HTTP and Pages Router masked error-route navigation through the router shim. The generated Pages entry now also shares one SSR manifest module lookup helper between asset tag collection and client module URL resolution.

* fix(prerender): keep custom 404 out of generic page loop

* fix(pages): fall back to _error on route misses

* fix(pages): document the catch-all guard on matchRoute("/404")

The pattern check prevents a dynamic catch-all route (e.g. [...slug]) from
being used as the 404 fallback. Without it, matchRoute can return a
catch-all match and silently hijack the error rendering path.

* fix(pages): preserve error navigation rewrite order

* fix(pages): limit fallback rewrites to route misses

* fix(deploy): skip deferred error rendering for data requests

* fix(pages): preserve ISR response status

* fix(pages): keep module metadata in ISR regeneration
2026-05-27 09:36:13 +01:00
Nathan Nguyen 4b4dc8d2ef fix: handle Pages Router middleware redirects (#1439) 2026-05-26 11:35:58 +01:00
James Anderson 0e64147176 fix(pages-router): pass revalidateReason to getStaticProps; default gSSP Cache-Control (#1573)
* fix(pages-router): pass revalidateReason to getStaticProps context

Pages Router `getStaticProps` now receives `context.revalidateReason`
matching Next.js: `'build'` during build-time prerendering,
`'on-demand'` when the dispatch is signalled as an explicit revalidation,
and `'stale'` for stale-while-revalidate background regeneration and
runtime cache-miss requests.

Closes #1462

* fix(pages-router): default gSSP Cache-Control in prod + JSON data paths

Mirrors the dev-server branch added earlier in this PR. Production HTML
gSSP responses (renderPagesPageResponse) and the _next/data JSON envelope
(pages-server-entry isDataReq) now default to
"private, no-cache, no-store, max-age=0, must-revalidate" when the user
did not set Cache-Control via res.setHeader, matching Next.js's
pages-handler.ts (revalidate: 0 → getCacheControlHeader). Closes the
dev/prod parity gap flagged by bigbonk.

Also fixes the broken `gsspRes.headers` access in the isDataReq path
(should be `getHeaders()`) so user-set headers actually propagate, and
notes that isOnDemandRevalidate is forward-looking plumbing (no
caller sets it yet — res.revalidate() isn't implemented).

* test(pages-router): fix misaligned continuation comment

Drop the extra leading space on the JSDoc continuation lines flagged by
bigbonk so the block aligns with the surrounding tests.
2026-05-26 11:25:25 +01:00
James Anderson 7a6a91ef19 fix(404): align default not-found copy with Next.js (#1567)
* fix(404): align default not-found copy with Next.js

The Pages Router previously emitted `<h1>404 - Page not found</h1>` and the
App Router emitted plain-text `This page could not be found` (no period) when
no user-defined 404 boundary was present. Next.js renders
`This page could not be found.` (with trailing period) for both routers.

Closes #1454.

* fix(404): address bot review — charset attribute and h1 line-height

- Use HTML attribute name 'charset' (not the JSX 'charSet') in the raw HTML template.
- Drop stray 'line-height:49px' on the Pages Router h1 — Next.js's _error.tsx inherits 48px from the parent div.
2026-05-26 09:33:01 +00:00
Nathan Nguyen 3ebc28f0e1 fix: preserve Next.js cache headers for prerendered app pages (#1424)
* fix: preserve Next.js cache headers for prerendered app pages

* fix: address cache header review feedback
2026-05-22 10:08:08 +01:00
James Anderson 81bdd6acd3 fix(pages-router): render loading shell for unlisted fallback: true paths (#1414)
* fix(pages-router): render loading shell for unlisted `fallback: true` paths

PR #1394 fixed the post-rewrite static-vs-dynamic priority sub-problem
from #1331 and explicitly tracked `fallback: true` rewrite behaviour
separately. When `getStaticPaths` returns `fallback: true` and a request
hits a path not in the pre-rendered list, vinext was SSRing the page
fully — running `getStaticProps` and emitting `isFallback: false` in
`__NEXT_DATA__`. Next.js instead ships a loading shell with empty props
and `useRouter().isFallback === true`, then the client hydrates by
fetching the data URL. The upstream regression test
`should rewrite to fallback: true page successfully` in
`test/e2e/middleware-rewrites/test/index.test.ts` asserts "Loading..."
in the SSR HTML, which vinext could not produce.

Thread an `isFallback` signal through `resolvePagesPageData`,
`buildPagesNextDataScript`, the Pages Router SSR context, and both the
prod (`pages-server-entry.ts`) and dev (`dev-server.ts`) renderers:

- `getStaticProps` and `getServerSideProps` are skipped on the fallback
  render (matches Next.js `render.tsx`'s `if (isSSG && !isFallback)`
  gate around `getStaticProps`).
- Data requests (`/_next/data/<buildId>/<page>.json`) still resolve real
  props so the client can swap in after the shell ships.
- `useRouter().isFallback` reads from the SSR context server-side and
  from `__NEXT_DATA__.isFallback` client-side — both flip to true on
  the shell render.

The dev-server comment that claimed Next.js dev "also renders fully"
was incorrect; Next.js's `pages-handler.ts` invokes `doRender` with
`isFallback: true` for dev fallback responses, so dev now serves the
shell too.

Tests:
- Updated `tests/pages-router.test.ts` "SSR renders unlisted path with
  getStaticPaths fallback: true" — previously documented the deviation;
  now asserts the loading shell, empty props, and `isFallback: true`.
- Added a counterpart test that the data URL for the same unlisted
  path still returns real props from `getStaticProps`.
- Added a production-side regression test mirroring the upstream
  "should rewrite to fallback: true page successfully" expectation.

Refs #1331

* test(pages-page-data): include isFallback: false in normalized render assertion

The render branch of `resolvePagesPageData` now returns `isFallback`
alongside the other fields (added in the parent commit for the
`fallback: true` loading shell). Update the strict-shape `toEqual` in
the cache-miss unit test so it matches the new return shape.
2026-05-21 22:17:05 +00:00
James Anderson 0fbbe33a2e fix: pass params: null (not {}) for non-dynamic routes (#1374)
Next.js exposes `params` as `null` (effectively — via `params || null`
or `params ? await params : null` user-side) to route handlers and
`getServerSideProps`/`getStaticProps` on routes with no dynamic
segments. vinext was passing an empty object, which is truthy and
breaks user code that branches on `params`.

The fix computes a user-facing params value at each dispatch boundary
(`null` when `route.isDynamic === false`, otherwise the matched
object) while leaving internal bookkeeping — navigation context, query
merging, `useParams()` — on the empty-object shape that those
consumers expect.

References the Next.js test suite assertions:
- test/e2e/app-dir/app-routes/app-custom-routes.test.ts
  "does not provide params to routes without dynamic parameters"
- test/e2e/edge-pages-support/index.test.ts
  "should have correct query/params on index"

Closes #1350
2026-05-21 11:27:07 +01:00
Nathan Nguyen f45fce00d5 fix(isr): honor route expire ceilings (#961)
* fix(isr): honor route expire ceilings

Track expireAt alongside revalidateAt in the memory and KV cache handlers so ISR entries past their expire ceiling become blocking misses instead of stale responses.

Plumb expireTime and request cacheLife expire values through App Router, Pages Router, prerender seeding, and cache writes while keeping generated entries as thin app-shape wiring over normal server modules.

Match Next.js cache-control semantics for finite stale-while-revalidate windows when an expire value is known.

* fix(isr): address cache life review follow-ups

* chore: address latest review follow-ups

* ci: pin vite-plus setup version

* Revert "ci: pin vite-plus setup version"

This reverts commit ee1d1d4e8db7b7406734414c53fa1b79f955a4d1.

* fix: avoid blocking ISR page streams on cache metadata

* fix: preserve headers for speculative cacheLife probes

* fix: preserve prerender cacheLife metadata

* fix: preserve legacy ISR cache metadata behavior

* fix: preserve prerender seed revalidate context

* fix: harden app page cache policy metadata

* fix: resolve app router prerender conflict
2026-05-02 19:54:25 +01:00
Steve Faulkner abe0fe5392 refactor: extract Pages page data and ISR runtime (#667) 2026-03-24 20:32:26 -05:00