Commit Graph

1503 Commits

Author SHA1 Message Date
Nathan Nguyen 49d2f0966d feat(skip): plumb client reuse manifests through the app request path (#1717)
* feat(skip): plumb client reuse manifests through the app request path

The browser never sends a client reuse manifest and the server has no path
to receive one, so the static-layout skip planner (#1674) and observation
foundations (#1672) have nothing to act on. Skip transport stays fully off,
but the transport wiring those slices depend on is missing.

Add a bounded browser producer that derives a client reuse manifest from the
visible AppElements state and attaches it as X-Vinext-Client-Reuse-Manifest
only on soft navigations. Parse the header into a render/send-by-default
disposition and thread the parsed result through the RSC handler, the
generated app entry, page dispatch, and the render lifecycle. Wire the
per-layout observation tracker and segment-config classification into dispatch
so the enable-transport slice can read them.

No RSC payload is omitted and no response cache-control changes: the parsed
manifest is carried but never authorizes a skip yet. Unproven and absent-
manifest requests render and send exactly as before.

Slice 3 of 4 from #1437; enable-transport follows.

Refs #726

* fix(skip): address review feedback on manifest plumbing

- app-page-execution: defer layout debug classification until after the
  observation override so the emitted reason agrees with the final flag;
  emit a runtime-probe reason when an observation flips a build-time static
  layout to dynamic.
- app-page-dispatch: document classifyLayoutSegmentConfigFromModule as the
  request-time counterpart to the build-time classifier, noting the
  intentional revalidate===false divergence and the dev-mode classification.
- app-render-dependency: mark registerAppElementRenderDependencies as
  write-only until the enable slice's consumer lands.

* docs(skip): clarify layout classification comments per review

- app-page-dispatch: correct the classifyLayoutSegmentConfigFromModule
  docblock — the build-time classifier already normalizes revalidate=false
  to Infinity, so both agree on that case; the real difference is scope
  (request-time classifies dev-mode layouts not captured at build time).
- app-page-dispatch: document the merge precedence in
  createEffectiveLayoutClassifications — module segment config is
  authoritative over the build-time map.

* test(skip): pin observation override flipping build-time static layout to dynamic

Adds a parity assertion for the request-time behavioral change the review
flagged: isLayoutObservationDynamic downgrades a build-time `static` layout
to `d` (and emits a runtime-probe reason) when the observed render is unsafe
for static reuse, while a safe observation keeps it static. Complements the
existing children-subtree probe assertion in app-page-route-wiring.test.ts.

* fix(skip): fold probe-scoped dynamic usage into layout observation

The probe rewrite runs layout probes inside runWithIsolatedProbeDependencies,
which opens a child unified scope and resets dynamicUsageDetected. A
markDynamicUsage() during the probe that leaves no other observable trace
(e.g. "use cache: private") was therefore discarded on scope exit, so the
parent's consumeDynamicUsage() in the Layer-3 path returned false and the
layout could classify static where the pre-PR direct probe classified dynamic.

recordProbeDependencies now peeks the child scope's dynamic-usage flag before
it is reset and folds it into the per-layout observation
(dynamicUsageObserved), which isAppLayoutObservationUnsafeForStaticReuse
treats as unsafe. This keeps the observation signal a superset of the
markDynamicUsage() triggers the Layer-3 dynamicDetected path covered, so the
two signals can no longer diverge into a false-static. Adds peekDynamicUsage()
(non-resetting read) and a regression test.

* perf(skip): defer client reuse manifest production to the cache-miss path

The manifest header is excluded from VINEXT_RSC_VARY_HEADER, so it never
affects the cache-busting URL or the visited-response cache lookup. Producing
it up front ran createClientReuseManifestHeaderFromVisibleAppState (iterates
visible layout ids, builds a cache-variant proof per static layout, then
binary-searches the byte budget) on every soft navigate — including ones that
resolve entirely from the visited-response cache or a prefetch and never send
a request, where the work is pure waste.

Move production to just before the network fetch, gated on the same
`navigationKind === "navigate"` condition and computed from the captured
nav-start router state, so the emitted header is unchanged on the request path
while the cache-hit/prefetch-hit hot paths skip it entirely.

---------

Co-authored-by: James <james@eli.cx>
2026-06-02 20:25:25 +01:00
Nathan Nguyen 8498e4a37e fix(app-router): emit per-page dynamic stale time metadata (#1711)
* fix(app-router): emit per-page dynamic stale time metadata

App Router pages that export unstable_dynamicStaleTime currently behave as if no page-level stale window exists. The server segment config resolver discards the export, so RSC responses cannot carry the per-page metadata that the client cache needs for independently configured pages.

The violated invariant is that route segment config resolution must preserve page-only stale-time metadata across active parallel pages before response construction. Parse the page export, reduce active page slots by shortest stale time, thread the value through generated App page dispatch, and emit it on RSC responses for the client cache to consume.

* fix(app-router): gate dynamic stale time header to dynamic-only renders

Only emit X-Vinext-Dynamic-Stale-Time when the render is truly dynamic
(not prerender or force-static), matching Next.js behavior where the
response `d` field is only set for non-static-generation renders.

The client treats the headers presence as authoritative; leaking it onto
static/prerendered RSC responses would cause the client cache to apply
dynamic stale time semantics to a statically-cached entry.

* test(app-router): add behavioral tests for dynamic stale time gating

* docs(app-page-render): add line-numbered link to Next.js source

* refactor(app-router): drop redundant Number.isFinite check

Number.isInteger already returns false for Infinity/NaN, so the
preceding Number.isFinite guard is redundant in both the dynamic
stale-time header check and resolveDynamicStaleTimeSeconds.

* fix(app-router): gate dynamic stale time on cache-capture predicate

The previous gating (isPrerender || isForceStatic) was too narrow for
production: isPrerender is only true during the build-time prerender
pass, so a production ISR render (revalidate > 0) still emitted
X-Vinext-Dynamic-Stale-Time even though that render is captured into the
ISR cache. Next.js gates on !workStore.isStaticGeneration; the runtime
analog here is shouldCaptureRscForCacheMetadata, so add it to the guard.

Adds production ISR coverage to lock down the gating.

* docs(app-router): document dynamic stale time over-gating boundary

Add a comment explaining that shouldCaptureRscForCacheMetadata is
computed from config before the stream runs, so a production
default-config route that turns dynamic via a late dynamic API loses the
advisory stale-time hint even though Next.js (!isStaticGeneration) would
emit it. The value is unknowable until after headers are built.

Adds a complementary test pinning down this boundary behavior.

---------

Co-authored-by: James <james@eli.cx>
2026-06-02 16:23:46 +01:00
Nathan Nguyen 4688fa9c16 fix(app-router): prerender cacheComponents root-param fallback shells (#1702)
* fix(app-router): prerender cacheComponents root-param fallback shells

PR 1 of 4: core model + build integration.
Model: app-ppr-fallback-shell, pregenerated-concrete-paths, prerender-manifest
Build: prerender/run-prerender fallback shell artifact generation
Tests: createAppPprFallbackShells, pregenerated-concrete-paths core

* test(prerender-route-params): add unit tests for fallbackParamNames and validation

Adds focused unit tests for the new prerender-route-params logic introduced
in this PR:

- encodePrerenderRouteParams round-trips fallbackParamNames and omits
  them when empty.
- prerenderRouteParamsPayloadMatchesRoute rejects payloads whose
  fallbackParamNames contain unknown params or duplicates.
- prerenderRouteParamsPayloadMatchesRoute returns false for valid
  fallback-shell matches (only exact matches are accepted today).

Also adds a comment in app-ppr-fallback-shell.ts noting that placeholder
brackets in shell pathnames are percent-encoded by new URL() at fetch
time, so the render path must supply params via the prerender-params
header rather than URL matching.

* docs(prerender): clarify BUILD_ID reuse and fallback-shell param-resolution seams

Address review feedback on PR #1702:
- Explain why run-prerender reuses the built BUILD_ID instead of
  re-resolving one (config.buildId feeds prerendered-output identity).
- Note that the fallback-shell prerender-params header path described in
  app-ppr-fallback-shell.ts is honored by the #1715 follow-up; this
  generation-only PR still resolves params from the URL placeholder.

---------

Co-authored-by: James <james@eli.cx>
2026-06-02 16:11:48 +01:00
한만욱 82b2720701 feat(app-router): support useRouter bfcacheId semantics (#1588)
* feat(app-router): implement useRouter bfcacheId semantics

* fix(app-router): refine bfcacheId navigation behavior

* test(app-router): align bfcache history state expectations

* fix(app-router): handle catch-all bfcache identities

* fix(app-router): align bfcache context semantics

* fix(app-router): avoid hidden bfcache DOM retention

* fix(app-router): hydrate bfcache ids from history state

* fix(app-router): handle bfcacheId redirects and slots

* test(app-router): expect active slot route ids

* Fix bfcache id history restoration

* Clarify bfcache id sentinel formatting

* Address bfcacheId review feedback

* Tie bfcacheId sentinels to shared constants

* Address bfcacheId review clarity

* Tighten bfcache slot identity check

* Deduplicate bfcache history sync check

* Handle empty bfcache segment ids

* Fix intercepted active slot route ids

* Clarify bfcacheId form state coverage

* Address bfcacheId review clarity

* fix(app-router): skip invisible tree-path segments in bfcache identity

getVisibleTreePathSegments only stripped route groups, not parallel
@slots or "." default segments. Tree paths contain raw filesystem
segments, so a slot preceding visible URL segments over-counted the
consumed pathname segments and produced a wrong identity prefix. That
re-minted the bfcacheId for layouts/slots that actually persisted across
parallel-route navigations (e.g. @modal), diverging from Next.js
bfcacheId semantics.

Delegate to the canonical isInvisibleSegment helper so the filter stays
in sync with the route graph, and add a parallel-slot layout regression
test (fails without the fix).

Also tighten the hydration-map unit test to actually exercise the
"no seeding from minted ids" property instead of asserting a local
literal, and correct the SSR bfcache context comment.

* fix(app-router): move isInvisibleSegment to browser-safe utils

The previous commit imported isInvisibleSegment from app-route-graph.ts
into app-browser-state.ts, but app-route-graph.ts imports node:path,
node:fs and node:crypto. Pulling it into the browser bundle broke client
hydration (app-with-src E2E: instrumentation-client and
dev-overlay-recovery timed out waiting for hydration / the navigation
runtime).

Move the pure isInvisibleSegment helper into the browser-safe
routing/utils.ts module (already in the client bundle), re-export it
from app-route-graph.ts so Node-side import sites keep working, and have
app-browser-state.ts import it from utils.js. The app-route-graph type
import is now type-only.

* refactor(app-router): address bfcacheId review feedback

- Reject history entries with a missing bfcache version in the
  document-scoped version gate (stop coercing null -> 0) so stale ids
  from older builds / external pushState can't be restored into a fresh
  document on the version-0 path.
- Parse AppElements metadata once per element map per commit and resolve
  slot bindings via a precomputed Map (was repeated readMetadata parses
  plus an O(slots^2) find on every navigation commit).
- Read single history-state keys without cloning the whole state object;
  the write path still clones before mutating.
- Dedupe the two identical non-negative-safe-integer validators and share
  path-segment / catch-all helpers via browser-safe routing/utils.
- Move tree-path consumed-segment counting into routing/utils and fix
  optional-catch-all over-counting.
- Drop the unused bfcacheIds param from createHistoryStateWithPreviousNextUrl.

* refactor(routing): make catch-all segment predicates module-private

isCatchAllSegment/isOptionalCatchAllSegment are only used by
countConsumedPathnameSegments in the same module; drop the exports to
clear knip's unused-export warning.

* test(app-router): lock in bfcache version-gate rejection of missing versions

Extract the version-gate decision into a pure, exported
isHistoryStateBfcacheVersionCurrent(state, currentVersion) in
app-history-state (re-exported via app-browser-state); the browser
entry's isCurrentBfcacheVersion now delegates to it. Add a direct
regression test asserting a history entry with bfcache ids but no
version key is NOT treated as current even when the current version is
0 — the exact cross-document stale-id hole that the earlier `?? 0`
coercion left open.

---------

Co-authored-by: James <james@eli.cx>
2026-06-02 13:53:27 +01:00
James Anderson 1047cccd8a fix(metadata): omit unused parent arg for cached generateMetadata (#1719)
* fix(metadata): omit unused parent arg for cached generateMetadata

A module-level `'use cache'` directive wraps `generateMetadata` in the cache
runtime. vinext always called it as `generateMetadata(props, parent)`, where
`parent` resolves to the merged ancestor metadata. When an ancestor sets
`metadataBase: new URL(...)`, the cache-key encoder (`encodeReply`) awaited the
parent promise and tried to serialize the `URL` instance, logging React's
"Only plain objects can be passed to Server Functions from the Client. URL
objects are not supported." warning.

Match Next.js, which omits the `parent` argument for `generateMetadata`
functions that don't use it (resolve-metadata.ts `getResult` /
`useCacheFunctionInfo.usedArgs[1]`):

- `resolveModuleMetadata` now only passes `parent` when `generateMetadata`
  declares it (`fn.length >= 2`).
- `registerCachedFunction` preserves the original function's arity on the
  wrapper so the arity check works for cached functions.

Adds regression tests for the arity gating, metadataBase preservation, and
arity-preserving wrapper.

* docs(metadata): note arity approximates parent usage; drop dead try/catch

Address /bigbonk review feedback:
- Document that fn.length approximates Next.js's usedArgs[1] analysis and
  diverges on default-parameter signatures.
- Remove the dead try/catch around Object.defineProperty (function length is
  always configurable per spec).
2026-06-02 11:50:17 +01:00
Nathan Nguyen 560f270ee6 fix: align app router navigation parity (#1705)
* fix: align app router navigation parity

* fix(app-router): preserve canonical RSC and hard-nav semantics

App Router RSC requests now keep the route pathname shape when adding _rsc. Stripping a trailing slash made trailingSlash routes fetch a non-canonical URL and let the server redirect the client-side RSC request back to the slash form.

The MPA navigation scheduler also keys pending hard navigations by both href and history mode so a later replace does not reuse an earlier push closure. Redirect handling keeps the global browser bridge out of passive server rendering while retaining client-side redirect recovery.

Adds focused regression coverage for trailingSlash RSC requests, same-href push-to-replace hard navigation, and client redirect guard loops. Updates existing navigation observers to the canonical route URL plus _rsc transport.

* fix(app-router): stabilize action and prefetch parity

* fix(app-router): reset MPA dedupe after bfcache restore

A bfcache-restored App Router document can keep the scheduler's last pending hard-navigation marker after the external navigation already left the page. Reissuing the same href and history mode then entered the suspended MPA path without scheduling another location change.

Expose a scheduler reset for the browser lifecycle boundary and call it on persisted pageshow events. Cover the scheduler contract so the same external navigation can run again after reset.

* fix(app-router): isolate redirect bridge from passive boundaries

Unhandled client redirect recovery currently lives inside every RedirectBoundary, while Pages Router params fallback can keep stale App Router params after an App-to-Pages transition. That makes passive boundaries own browser runtime wiring and lets stale app params win over live Pages Router params.

Move the unhandled redirect bridge into BrowserRoot under AppRouterContext, keep RedirectBoundary passive, and prefer live Pages Router params when that compat context exists. Add an App-to-Pages navigation regression fixture.

* fix: extract shared decodeRedirectError helper, rename stale test

- Add decodeRedirectError() to shims/navigation.ts as the single
  source of truth for parsing redirect error digests
- Replace three private helpers in error-boundary.tsx with shared
  decodeRedirectError() call
- Replace inline digest parsing in AppRouterRedirectBridge with
  shared decodeRedirectError() call (fixes unsafe decodeURIComponent)
- Rename test that claimed to test redirect() but actually tests
  <Link> navigation

* refactor: eliminate unnecessary type assertions

- isRedirectError: use in-narrowing instead of as-casts
- AppRouterRedirectBridge: remove as after guard already narrows
- RedirectErrorBoundary: inline digest access, use in for handled check

* ci: retrigger checks

* fix: emit noindex for Forbidden/Unauthorized boundaries, extract retryScrollTo helper

* docs: clarify decodeRedirectError comment about defensive join
v0.0.55
2026-06-01 12:37:27 +01:00
James Anderson f15267c8a1 fix(pages-router): run _document.getInitialProps with renderPage enhancers (#1682)
* fix(pages-router): run _document.getInitialProps with renderPage enhancers

Refs #1468.

Custom `pages/_document.tsx` files that override `getInitialProps` and
call `ctx.renderPage({ enhanceApp, enhanceComponent })` to wrap the
App/Component tree — the standard styled-components / emotion pattern
for SSR style collection — silently no-op'd. The shim type advertised
`renderPage`, but the SSR pipeline never invoked `getInitialProps` with
a real `renderPage`, so the enhancers never ran and `<Document>` never
saw the wrapped tree.

This wires the Pages Router `_document.getInitialProps` contract
through both the prod (`renderPagesPageResponse`) and dev
(`streamPageToResponse`) pipelines:

- A new `enhancePageElement(opts)` option exposes the App/Component
  separation needed to apply `enhanceApp` / `enhanceComponent` from
  the user's renderPage call. `pages-server-entry.ts` and the dev
  server both wire it up to wrap `App({ Component, pageProps })`.
- When `DocumentComponent.getInitialProps` is defined, we invoke it
  with a minimal `DocumentContext` shim that includes `renderPage` and
  `defaultGetInitialProps`. `renderPage` rebuilds the React tree via
  `enhancePageElement`, drains the React stream to a string, and
  returns `{ html, head }`.
- If renderPage was called, the streaming body fallback is replaced
  with the captured `html` so `_document` sees a complete body (matches
  Next.js's `loadDocumentInitialProps` behaviour). Any `styles` element
  returned by getInitialProps is rendered to HTML and appended to the
  document head.
- If `getInitialProps` throws, returns a non-string `html`, or never
  invokes renderPage, we fall through to the existing streaming path —
  no user-visible regression for the common case where `_document` has
  no `getInitialProps`.

Adds a focused regression in `tests/pages-page-response.test.ts` that
confirms `enhanceApp` and `enhanceComponent` both run and that the
enhanced tree appears in the rendered body. Mirrors
`.nextjs-ref/test/development/basic/styled-components/pages/_document.js`
and `.nextjs-ref/packages/next/src/server/render.tsx`
(`loadDocumentInitialProps` / `enhanceComponents`).

Test plan:
- `pnpm test tests/pages-page-response.test.ts`
- `pnpm test tests/document.test.ts tests/head.test.ts tests/shims.test.ts`
- `pnpm test tests/pages-router.test.ts`
- `pnpm test tests/entry-templates.test.ts`
- `pnpm run check`

* refactor(pages-router): consolidate _document renderPage onto shared helper

Address review feedback on the _document.getInitialProps + renderPage
contract:

- Extract a single shared runDocumentRenderPage() helper in
  pages-document-initial-props.ts, used by both the prod
  (pages-page-response.ts) and dev (dev-server.ts) SSR pipelines. The
  duplicated inline implementation in dev-server.ts is removed (-86/+38).
- withScriptNonce now lives in one place (the shared helper); both
  callers' enhancePageElement return the bare enhanced tree.
- Remove dead headFromDocument (returned but never consumed).
- Drop the redundant React.Fragment wrapper around enhancedElement.
- Reconcile with #1677's loadUserDocumentInitialProps: getInitialProps is
  invoked at most once. A discriminated result (skipped/consumed/rendered)
  signals when the fast path must be skipped so getInitialProps is not
  called twice (which would re-throw a user error as a 500).
- Add edge-case tests: styles returned, getInitialProps throws (clean
  fallback), renderPage never called.

* refactor(pages-router): address re-review nits on shared renderPage helper

- runDocumentRenderPage now skips the base-shim getInitialProps via the
  BASE_GET_INITIAL_PROPS identity check (mirrors loadUserDocumentInitialProps)
  so an unoverridden _document stays on the cheap fast path.
- Dev now forwards { pathname, query, asPath } as the DocumentContext, matching
  the prod pipeline's parity.
- Add a test for renderPage being called but the underlying stream render
  throwing (falls back cleanly to the streaming render).

* fix(pages-router): drop unused RunDocumentRenderPageResult export, document pageProps capture

- Remove the `export` on the internal `RunDocumentRenderPageResult` type
  (knip flagged it as an unused export; it is only used as the helper's
  return type within the same module).
- Document why `enhancePageElement` captures `pageProps` from the closure
  rather than receiving it as a parameter (it is invoked once per request
  with a single `pageProps`; only the enhancers vary).

* refactor(pages-router): address bonk re-review nits

- dev-server enhancePageElement: document the pageProps closure capture to
  mirror the prod entry's comment
- prod renderStylesToString: render the collected styles fragment with the
  plain stream renderer instead of the full <Document> shell renderer
- lazify pageElement construction so it is only built on the streaming path
  (never used when renderPage already produced the body)
2026-06-01 12:22:35 +01:00
James Anderson a98c8ae595 fix(shims/head): keep charset/viewport defaults on client hydration (#1713)
* fix(shims/head): keep charset/viewport defaults on client hydration

The Pages Router next/head client sync (syncClientHead) removed every
[data-next-head] element and rebuilt the projection from only the user
<Head> children, omitting defaultHead() (charset + viewport). After the
first <Head> mounted on the client, the server-rendered charset and
viewport meta tags were stripped and never re-added.

PR #1677 seeded defaultHead() into the SSR collector (getSSRHeadHTML)
but not the client path. Next.js's reduceComponents() always concatenates
defaultHead() on both server and client, so the defaults must survive
hydration here too.

Seed defaultHead() into the client reduce, mirroring the SSR path. Users
can still override via key="charset" / key="viewport" through the same
dedupe pipeline.

Adds a regression test exercising the client projection with a minimal
DOM double (the repo has no jsdom).

* fix(shims/head): reconcile client head in place to preserve SSR order

The client sync still wiped every [data-next-head] node and re-appended
the projection, which moved the SSR-emitted charset/viewport/title tags to
the end of <head> (after the modulepreload/script links) and caused DOM
churn/flicker on every <Head> update.

Mirror Next.js's head-manager.ts updateElements(): diff the desired tags
against existing ones via isEqualNode(), leave matches untouched in their
original DOM position, append only genuinely new tags, remove stale ones,
and prepend a newly-created <meta charset> so it stays first.

Extends the regression tests with a richer DOM double asserting node reuse,
order preservation, insertion, removal, and charset-first behavior.

* test(shims/head): client-side override coverage; document divergences

Address non-blocking review notes from /bigbonk:
- Add a client-side test asserting a user key="viewport" overrides the
  default viewport on _syncClientHead (mirrors the SSR override test),
  locking in the dedupe-precedence parity claim.
- Document the deliberate charset prepend if/else divergence from Next.js's
  head-manager.ts (which prepends then unconditionally appendChilds).
- Document the <title> reconciliation divergence (Next.js sets document.title
  directly; we reconcile title like any tag — cosmetic only).
- Reconcile issue references to #1569 / #1677 in the client describe block.
2026-06-01 12:20:19 +01:00
James Anderson 26d1cc79bc fix(pages-router): dedupe in-flight _next/data fetches by URL (#1464) (#1684)
Concurrent `Router.push` calls (or back-to-back `<Link>` clicks) to the
same gSSP route each fired their own `_next/data/<id>/<page>.json` fetch.
The Next.js parity test asserts `hit: 1` after 4 rapid navigations to a
slow page; vinext was producing `hit: N`.

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

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

Wired into both `navigateClientData()` (the JSON fast path) and
`resolveMiddlewareDataRedirect()` (the soft-redirect probe).
2026-06-01 11:14:00 +01:00
James Anderson ffc8e68c4a fix(shims/head): match Next.js charset/viewport order and merge _document.getInitialProps head (#1677)
* fix(shims/head): match Next.js charset/viewport order and merge _document.getInitialProps head (#1569)

Pages Router head emission did not match Next.js's canonical order
(`<meta charset>` then `<meta viewport>` then user tags, all with
`data-next-head=""`) and ignored head tags returned from a user
`_document.getInitialProps()`.

- `next/head` shim now seeds `defaultHead()` (charset + viewport) into
  the SSR collector before user tags, mirroring `defaultHead()` in
  `next/src/shared/lib/head.tsx`. They flow through the same dedupe
  pipeline so users can still override via `key="charset"` /
  `key="viewport"`.
- Serialised attribute names follow HTML conventions
  (`charSet` -> `charset`, `httpEquiv` -> `http-equiv`, etc.), matching
  Next.js's `test/e2e/next-head/index.test.ts` exact-string assertion.
- `next/document` shim no longer hardcodes charset/viewport in `<Head>`
  (those defaults now flow from `next/head`); fallback HTML shells in
  `dev-server.ts` and `pages-page-response.ts` likewise drop the
  duplicates.
- New `setDocumentInitialHead()` accessor and ALS-backed state lets the
  SSR pipeline forward head tags returned by user
  `_document.getInitialProps()` into the same dedupe pipeline as
  `next/head` children. Both the dev server and the prod
  `renderPagesPageResponse` invoke `_document.getInitialProps()` when
  present and pass the result through.

Adds focused regression coverage in `tests/head.test.ts` for ordering,
default attribute serialisation, key-based overrides, and the
`_document.getInitialProps()` merge path.

Refs #1569.

* fix(pages-router): address review feedback on _document head merge

- Extract shared `callDocumentGetInitialProps` helper under
  `server/document-initial-head.ts` so both `dev-server.ts` and
  `pages-page-response.ts` go through the same code path. The previous
  duplicated try/catch blocks drifted independently and were the kind of
  runtime-behavior that AGENTS.md says should live in a helper.

- Skip the call when `DocumentComponent.getInitialProps` is still the
  unmodified default inherited from vinext's `next/document` shim.
  Comparing against the captured base reference (`DEFAULT_GET_INITIAL_PROPS`)
  distinguishes a user override from the default — extending `Document`
  without overriding the method inherits the same static function, so
  invoking it on every render is wasted work.

- Use `jsxAttrToHtml` on the client-side `_applyHeadPropsToElement` path
  so `httpEquiv` is set as `http-equiv` rather than `httpequiv`
  (lowercased by `setAttribute`). Matches the SSR output and prevents a
  hydration mismatch when SSR emits the hyphenated form.

- Update stale `tests/document.test.ts` Head assertions: the shim's
  `<Head>` no longer hardcodes charset/viewport — those flow through
  `next/head`'s `defaultHead()` so the dedupe pipeline handles them.
2026-06-01 11:06:28 +01:00
James Anderson 60dfb11c62 chore(deps): bump @vitejs/plugin-rsc to 0.5.27 (#1709)
* chore(deps): bump `@vitejs/plugin-rsc` to 0.5.27

* test(rsc): cover `export * from` in 'use client' modules (#1352)

Ports the build fixture from #1373 to verify the `@vitejs/plugin-rsc`
0.5.27 bump resolves the upstream "unsupported ExportAllDeclaration"
error. Covers bare `export *`, nested/renamed re-exports,
`export * as Ns from`, and destructured re-exported bindings.

The version bump fixes this natively, so the vinext-side workaround
plugin from #1373 is no longer needed.

Refs #1352
2026-06-01 11:01:28 +01:00
James Anderson 49f3905b7b fix(config): keep packages externalized when loading TypeScript next.config (#1710)
PR #1701 added `resolve.noExternal: true` while loading a TypeScript
next.config so a baseUrl-local file could shadow an installed package of the
same name. That de-externalizes every installed package and forces it through
Vite's module runner, which breaks CJS config plugins that call `require` /
`require.resolve` at runtime — e.g. `@next/mdx`, which failed the
app-router-playground production build with "require is not defined".

This was not caught pre-merge because deploy-examples is skipped for fork PRs.

Drop the blanket `noExternal: true`. Installed packages stay externalized (so
CJS config plugins keep working), and the native Vite 8 `resolve.tsconfigPaths`
resolver still resolves baseUrl-local bare imports that have no installed
package of the same name (the real Next.js tsconfig-extends fixture). The only
behavior we give up is shadowing an installed package with a baseUrl-local file
of the same name, which no Next.js fixture exercises.

- Update the shadowing test to assert the installed package wins.
- Add a regression test for an @next/mdx-style CJS plugin that calls
  `require.resolve` at runtime (red before this change).
- Document the package-precedence behavior in the README limitations entry.
2026-06-01 09:58:09 +00:00
Nathan Nguyen 03845f52b2 ci(bonk): bump models to Opus 4.8 high and GPT-5.5 high, opencode 1.15.13 (#1708)
* ci(bonk): switch reviews to GPT-5.5 xhigh

Bonk and BigBonk still routed review runs to Claude Opus 4.6 with the max variant. That leaves review commands and local opencode review agents on the old Anthropic model instead of the intended OpenAI xhigh setup.

The workflows now route through Cloudflare AI Gateway to openai/gpt-5.5, request the xhigh variant, and pin opencode to 1.14.40 so the cf-ai-gateway reasoning_effort propagation fix from anomalyco/opencode#25573 is present. The viguy and reviewer agent frontmatter plus contributor docs now describe the same GPT-5.5 review setup.

* ci(bonk): keep regular Bonk on Opus 4.7

The review model update should split regular Bonk and BigBonk. Regular Bonk is meant to use Claude Opus 4.7, while BigBonk should use GPT-5.5 with xhigh reasoning.

Point the /bonk workflow back to the Anthropic Opus 4.7 model through Cloudflare AI Gateway and keep its max variant. Leave /bigbonk on GPT-5.5 xhigh, and update contributor docs to describe the two review paths.

* ci(bonk): flip Bonk and BigBonk models

The requested review model split is regular Bonk on GPT-5.5 xhigh and BigBonk on Claude Opus 4.6 max.

Update only the Bonk workflow model and variant inputs. Leave the local opencode agent frontmatter and contributor docs unchanged from the existing PR branch.

* ci(bonk): align agents with review workflows

Bonk should run GPT-5.5 xhigh through the reviewer agent, while BigBonk keeps the viguy agent on Claude Opus 4.6 max.

Route the Bonk workflow to the reviewer agent, set viguy's default model to Claude Opus 4.6, and use the same 0.2 temperature for both opencode agent definitions.

* ci(bonk): use direct model ids in agent defaults

OpenCode agent frontmatter should use the normal provider/model ids. The workflow inputs still need Cloudflare AI Gateway-prefixed model ids because the GitHub action routes through AI Gateway credentials.

Set viguy back to anthropic/claude-opus-4-6 and reviewer to openai/gpt-5.5 while leaving the Bonk and BigBonk workflow model overrides unchanged.

* Update CONTRIBUTING.md

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>

* Update CONTRIBUTING.md

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>

* ci(bonk): bump to Opus 4.8 high, GPT-5.5 high, opencode 1.15.13

* ci(bonk): bump models to Opus 4.8 high and GPT-5.5 high, opencode 1.15.13

* ci(bonk): bump models to Opus 4.8 high and GPT-5.5 high, opencode 1.15.13

---------

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
2026-06-01 09:45:02 +00:00
Nathan Nguyen 5991406324 fix(config): resolve next.config baseUrl imports from tsconfig (#1701)
* fix(config): resolve next.config baseUrl imports from tsconfig

next.config.ts could resolve tsconfig paths aliases but not bare imports covered by compilerOptions.baseUrl. This broke configs that inherit baseUrl through tsconfig extends and import local modules such as "bar" from next.config.ts.

Carry the effective baseUrl out of the tsconfig reader and add a narrow Vite runner resolver for bare specifiers so local files resolve before normal package fallback. Add a regression ported from the Next.js tsconfig-extends deploy fixture.

* fix(config): preserve baseUrl priority over packages

next.config.ts imports that matched both a local tsconfig baseUrl target and an installed package resolved to the package first. That violated the TypeScript and Next lookup order where baseUrl local resolution wins before package fallback.

Vite's module runner externalized installed bare packages before resolveId hooks ran, so the baseUrl resolver only handled missing packages. Keep config imports inside the resolver pipeline, run the baseUrl resolver before package resolution, and scope it to project-owned importers so package dependencies are not rewritten through the app baseUrl.

Adds focused regression coverage for local-over-package shadowing, package fallback, node_modules importer isolation, and existing CJS/ESM package imports from next.config.ts.

* fix(config): keep baseUrl lookup scoped to TypeScript configs

next.config.mjs could inherit TypeScript baseUrl package shadowing because the config runner noExternal override and baseUrl resolver were enabled for every config file type. That leaked Next's TypeScript config transpilation semantics into non-TypeScript config loading.

Gate the baseUrl resolver and noExternal runner override behind TypeScript config extensions while keeping existing alias loading behavior unchanged. Add a regression for mjs package shadowing and update the tsconfig helper comment to name baseUrl explicitly.

* refactor(config): resolve next.config baseUrl via native Vite 8 tsconfigPaths

Replace the hand-rolled tsconfigBaseUrlResolverPlugin with Vite 8's native
resolve.tsconfigPaths (oxc-resolver tsconfig: 'auto'), which already mirrors
Next.js's SWC paths + baseUrl handling: follows extends, resolves baseUrl-local
bare imports before package fallback, and scopes the app baseUrl to
project-owned importers via per-importer tsconfig discovery.

Gate it behind the existing Vite-major check (matching index.ts). Vite 7 has no
native equivalent, so baseUrl bare imports in next.config.ts are a documented
Vite 7/8 capability gap; paths aliases still work on both via resolve.alias.

Extract getViteMajorVersion into utils/vite-version.ts so next-config.ts and
index.ts share one detection path without a circular import.

---------

Co-authored-by: James <james@eli.cx>
2026-06-01 10:34:00 +01:00
James Anderson 044ccf7c4c fix: allow server-only imports from 'use server' modules (#1707)
PR #1697 added a client-environment guard that throws when a module
imports "server-only", to match Next.js rejecting server-only from
client-reachable code. But Server Actions modules ('use server') live in
the server/action layer, not the client layer, and Next.js explicitly
allows them to `import 'server-only'` (see
test/e2e/app-dir/actions/app/client/actions.js). Because the client
bundle holds references used to invoke the actions, the action module
enters the client graph and the guard threw, failing the entire client
build.

This false positive broke 136 deploy-suite tests across the actions and
app-basepath suites. Exempt modules with a top-level "use server"
directive so genuine client modules are still rejected.

Adds a regression test that builds an App Router 'use server' module
importing server-only, alongside the existing negative test proving
client modules are still rejected.
2026-06-01 08:47:03 +00:00
Steve Faulkner c4e64c908b fix: make fnv1a64 output fixed-width (#1706) 2026-05-31 20:09:20 -05:00
Nathan Nguyen 8ae5abd56c fix(app-router): preserve forwarded action redirect wrappers (#1582) 2026-05-31 22:30:57 +01:00
Nathan Nguyen 66519412c3 fix(app-router): reuse pending prefetched RSC payloads (#1704) 2026-05-31 20:08:13 +01:00
Divanshu Chauhan (divkix) 19dfd5e009 feat(config): add experimental.appShells config plumbing and validation (#1692) 2026-05-31 18:22:15 +01:00
Nathan Nguyen f36959dea6 fix(app-router): preserve middleware headers on metadata routes (#1700)
App Router metadata routes returned their generated response before the middleware response context was merged. That let static metadata defaults such as Cache-Control: public, max-age=0, must-revalidate overwrite a middleware Cache-Control header for app/favicon.ico.

Apply the same middleware response-header and status merge used by other App Router response paths before returning metadata route responses, and cover the upstream Next.js no-duplicate-headers fixture.
2026-05-31 11:40:37 +01:00
Nathan Nguyen a35e43cef3 feat(skip): add layout safety observation foundations (#1672)
* feat(skip): add layout safety observation foundations

Introduce the per-layout observation primitives that later slices need
to decide whether retained static layouts can be reused. This slice
adds the machinery only; nothing reads the observations yet, so
behavior is unchanged.

- app-layout-param-observation: per-layout completeness, param keys,
  structural scope, finite revalidate, request API, cacheLife, cache
  tag, cacheable fetch, dynamic fetch, and unstable_cache observation
  tracker.
- app-page-probe: bounded React server subtree probe that walks
  layout-returned children including memo, forwardRef, and React.lazy
  wrappers, enforces depth and node limits, and refuses single-use
  iterable children.
- shims: thenable params observer for sync, await, destructure, and
  enumeration. Cache, fetch, and unified request context record
  unstable_cache, cacheable fetch, and request-scoped observation
  state.
- app-page-params: structural segment-param scope so empty optional
  catch-all targets are not treated as param-free downstream.

No skip transport behavior. Later slices wire the planner, manifest,
and encoder onto these observations.

* fix(skip): address review feedback on observation foundations

- Add currentFetchSoftTags reset in runWithIsolatedProbeDependencies to prevent soft tag leak from parent scope (matches runWithFetchCache isolation)
- Replace unnecessary JSON.stringify in cacheLife check with direct null comparison
- Add comment documenting intentional omission of markProbeComplete on probe errors (keeps completeness as unknown, safe fallback to render-and-send)

* fix(skip): record cacheable-fetch observation synchronously before first await

The patched fetch deferred observation and tag collection past
await buildFetchCacheKey(). If a layout probe started a cacheable
fetch and returned synchronously without awaiting it, the probe
snapshot would see cacheableFetchCount: 0 -- a false proof that
could let the skip planner reuse a stale retained layout.

Move observation and tag recording before the first await in the
cacheable branch. If key generation later fails and the fetch falls
back to dynamic, recording both is conservative (false unsafe costs
performance, not correctness).

Add an integration test that exercises the real patched fetch shim
without mocks to verify synchronous observation capture.

* fix(test): remove polluting fetch restore and catch background promise

Do not restore globalThis.fetch in test cleanup. ensureFetchPatch
sets a global _PATCH_KEY guard; restoring fetch without clearing
that marker makes later calls skip reinstallation silently.

Catch the background fetch promise inside the probe so the patched
fetch continuation (cache lookup, network fetch) cannot produce an
unhandled rejection after the synchronous assertions complete.

* chore: empty commit to trigger CI

* chore: empty commit to trigger CI
2026-05-31 11:39:47 +01:00
Nathan Nguyen b1f2e57bc4 fix(pages): preserve import.meta.url source paths (#1598)
* fix(pages): preserve import.meta.url source paths

Pages Router production bundles previously left direct import.meta.url expressions for the bundler to evaluate from generated output. Server renders could expose dist/server/entry.js, and prerendered Pages output could miss the client entry needed for hydration to replace the server value with the Turbopack-style browser source URL.

The fix rewrites direct import.meta.url reads in user modules to source-file URLs at transform time, keeps new URL asset bases intact, and loads the Pages client entry from the build manifest for Node production and prerender servers.

Regression coverage ports the Next.js import-meta deploy test, including the prerender and hydrated browser path.

* fix(tests): run import.meta browser coverage in e2e

The import-meta regression test launched Playwright from a Vitest integration shard. That shard does not install Playwright browser binaries in CI, so it failed before exercising the runtime behavior.

Move the browser assertion into the existing pages-router-prod Playwright project, keep the lower-boundary server and client chunk assertions in Vitest, and cache the import-meta plugin root paths after an early source-text bailout to address review feedback.

* refactor(import-meta-url): canonicalize each module once per transform

The transform path canonicalized the same module id twice: once in the
eligibility check and again when computing the replacement value, so every
rewritten module paid for two fs.realpathSync.native() syscalls.

The eligibility check now returns the canonical id and threads it through to
the value computation, leaving a single realpath per module.

Also widens the extension match to .cts/.mts/.cjs and documents that the
new URL() skip only covers the bare URL identifier, matching Vite's own
asset-detection scope.

* test(pages): drop unrelated prerender from top-level-await setup

The top-level-await production test ran runPrerender in its setup, but the
assertions pass identically whether the routes are prerendered or rendered on
demand. The step added unrelated build work to a test that is not about
prerendering or client-entry injection.

* fix(pages): share client entry fallback

* fix(import-meta-url): address PR review feedback

- Handle ChainExpression (import.meta?.url) in new URL() asset-detection
  skip so the optional-chain variant preserves Vite asset resolution
- Add explicit ChainExpression check in visit() to avoid fragile
  reliance on coincident inner-node start/end ranges
- Add test for new URL('./file', import.meta?.url) preservation
- Fix decodeHtmlText entity ordering (&amp; before &quot;) in both
  unit and E2E test helpers
- Add doc comments: excluded prefixes rationale, node_modules
  early-exit optimization intent

* fix(pages): select client entry by marker in build manifest

findClientEntryFileFromManifest returned the first chunk flagged
isEntry, but a client build can emit several entry chunks (client
entry plus instrumentation/middleware) and manifest order is not
guaranteed. The on-disk fallback already disambiguates by the
vinext-client-entry / vinext-app-browser-entry marker; apply the
same check to the manifest path so both lookups agree, falling back
to the first entry only when nothing is marked.

* refactor(pages): address review feedback on client-entry/import-meta-url

- prod-server: hoist findClientEntryFile out of the manifest if/else
  since it already handles a missing manifest via the on-disk fallback
- import-meta-url: document the two known limitations (destructured
  access, aliased new URL() base) that match Vite's own behavior
- import-meta-url: drop the dead visit(value.callee) for new URL() —
  the callee is always the bare URL identifier

---------

Co-authored-by: James <james@eli.cx>
2026-05-31 11:37:09 +01:00
James Anderson 7bb669c0e9 test(routing): regression for parallel slot page-over-default priority (#1339) (#1689)
Issue #1339 reported parallel route slots falling back to default.tsx
instead of the matched page. That gap was already closed on main by
#1389 (resolve nested slot pages over default.tsx) and #1659/#1535
(treat @children as transparent). This adds a regression test mirroring
the Next.js parallel-routes-layouts fixture — the canonical
"Hello from Nested" vs "default page" case — asserting that:

- the children slot at /nested resolves nested/page.tsx, not
  nested/default.tsx;
- sibling @foo/@bar slots that each own both a page and a default pick
  their page; and
- at /nested/subroute (only @bar matches) the children slot falls back
  to default, @bar mirrors its subroute page, and @foo keeps its default.

Locks in page-over-default priority across sibling slots so the
behavior cannot silently regress.
2026-05-31 09:29:30 +01:00
Steve Faulkner 87479df147 fix: bound default memory cache size with LRU eviction (#1699)
MemoryCacheHandler used an unbounded Map for default ISR/fetch/cache
storage. Add a Next-style memory cap with approximate byte sizing and
least-recently-used eviction.

Behavior:
- default max size is 50 MiB, matching Next.js cacheMaxMemorySize default
- cacheMaxMemorySize: 0 disables default memory storage
- entries larger than the configured cap are not stored
- get() refreshes LRU order
- tag invalidation metadata is capped to 10k entries

Wire next.config.js cacheMaxMemorySize into both App and Pages generated
entries via configureMemoryCacheHandler(). Add focused unit coverage for
LRU eviction, get() recency refresh, and disabled storage.
2026-05-30 21:37:42 -05:00
Steve Faulkner 0c1193fd76 fix: include i18n context in Pages ISR cache keys (#1698)
Pages Router ISR cache entries were keyed only by pathname. With i18n
domain routing, the same pathname can render different content depending
on the matched domain and default locale. A cache entry created for one
domain could be reused for another domain.

Add an i18n cache variant to the Pages ISR key when i18n is configured:
- domain:<domain> for matched domain-locales
- locale:<locale> for locale-prefix/default-locale routing

Adds a Pages i18n production fixture page with getStaticProps +
revalidate and verifies example.fr and example.com maintain separate
ISR entries for the same pathname.
2026-05-30 15:33:17 -05:00
Steve Faulkner 534487a5bf fix: reject server-only imports from client-reachable modules (#1697)
Match Next.js handling for the server-only marker: imports are allowed
from App Router Server Components and middleware/server targets, but
client-reachable modules must fail instead of silently resolving the
empty shim.

Add an AST-based client-environment transform guard so comments or
string literals do not false-positive. The existing middleware test
continues to prove server-only remains allowed from middleware graphs,
and the new Pages Router fixture mirrors Next.js's compiler error test
for server-only imported from a Pages client graph.
2026-05-30 14:46:47 -05:00
Nathan Nguyen e8c188ce13 feat(skip): add static layout reuse proof model (#1674)
* feat(skip): add static layout reuse proof model

Add the proof/planner vocabulary that decides whether a verified
static layout entry from a client reuse manifest is reusable.
Nothing renders this decision yet, so no payload is omitted.

- skip-cache-proof: static layout reuse planner with per-entry budget,
  exact artifact-compatibility check, payload hash, route id, and
  invalidation proof. Preserves rejection traces. Returns the existing
  disabled disposition when not authorized.
- static-layout-client-reuse-proof: static layout payload hash, route
  id, and artifact compatibility helpers used by the planner.
- client-reuse-manifest: extends the rejection code union with the
  layout-safety rejection codes the planner emits, and adds the
  enabled skip disposition variant so future slices can carry an
  authorized result without retyping the union.

This slice is the proof vocabulary only. The planner is callable but
not invoked from the render path; encoder behavior is unchanged.

* fix(skip): address review — budget counts all wire entries, default enforcement, triple-walk justification, hash stability comment, forward declarations comment, absent-manifest test

* fix(skip): replace as const with explicit return type annotation

* fix(skip): replace as const with ProofFieldPair type alias

* fix(skip): address copilot review — pairs serialization, budget disambiguation, skip-ineligible tracking, compatibility-bridged test

* fix(skip): remove routeId from renderEpoch, bind verifier entryId, add sibling-route test

* fix(skip): exclude routeId from payload hash, add end-to-end sibling-route proof test

* chore(skip): move proof fields to artifact-compatibility, rename to maxWireEntriesToVerify, short-circuit exact check

* chore(skip): address review comments — fix hash comment, remove redundant guard, drop budget alias, dedupe exact-compatibility check
2026-05-29 11:31:20 +01:00
Divanshu Chauhan (divkix) 00f585969d feat: recognize Sparkle experimental flags (varyParams, optimisticRouting, cachedNavigations) (#1655)
* feat(check): recognize Sparkle experimental flags in CONFIG_SUPPORT

* test(check): add tests for Sparkle experimental flags

* feat(config): warn when cachedNavigations is set without cacheComponents

* test: address ask-bonk review on cachedNavigations tests

- Remove redundant mockRestore() calls (afterEach handles it)
- Add negative test cases for cachedNavigations not set / false
- Match existing swcEnvOptions test style (warn.mock.calls.find())
2026-05-29 11:27:42 +01:00
James Anderson 88b1152eb2 fix(app-router): include static-sibling info in SSR response (#1525) (#1686)
Next.js's loader-tree dynamic-segment tuple includes a `staticSiblings`
list (the static sibling segment names at the dynamic URL level) so the
client router can decide whether a cached prefetch of a dynamic route
can be reused when navigating to a static sibling URL. vinext was not
emitting this metadata, so the static-siblings e2e suite failed all four
substring assertions against the RSC payload.

This change:

- Adds `computeAppRouteStaticSiblings(allRoutes, matchedRoute)` to the
  app route graph. Ported from Next.js's `getStaticSiblingSegments` in
  the next-app-loader. For each dynamic URL level in the matched route,
  it collects the names of routes whose corresponding segment is
  static and whose preceding URL prefix matches literally.
- Pre-computes the result per route at entry-generator time
  (`buildRouteEntries` in app-rsc-manifest) so the runtime carries a
  static list instead of recomputing for every request.
- Threads it through `AppPageRouteWiringRoute` and emits it on the
  wire as a new `__staticSiblings` metadata key when non-empty. The
  Flight serializer already spreads the elements record into the
  outgoing payload, so the key lands on the RSC response unchanged.

The new `tests/app-static-siblings.test.ts` covers the helper's
sibling-collection rules (cross-route-group, same-directory, deeply-
nested non-leak, dedup) and asserts `buildAppPageElements` emits
`__staticSiblings` on dynamic routes and omits it when empty — matching
the four sub-cases of Next.js's static-siblings e2e suite.

Ported from Next.js:
test/e2e/app-dir/static-siblings/static-siblings.test.ts

Refs cloudflare/vinext#1328
v0.0.54
2026-05-29 10:05:43 +01:00
James Anderson 6d4dd949f6 test(link): cover Pages Router onClick/preventDefault semantics (#1470) (#1685)
Adds regression coverage mirroring Next.js's
`test/e2e/new-link-behavior/index.test.ts` so the Pages Router `<Link>`
shim is guarded against ever regressing on:

  1. `onClick` firing on click with the synthetic event
  2. `event.preventDefault()` inside `onClick` cancelling the Link's
     own client-side navigation (no router.push, no history mutation,
     no popstate dispatch)

The unit-level invariants in the current Link shim match Next.js's
`linkClicked` ordering — call user onClick, check defaultPrevented,
only navigate if not prevented — so these tests pass against the
current implementation and lock that behaviour in place.
2026-05-29 09:56:12 +01:00
James Anderson 0859b07b96 test(pages-router): regression test for /api/* dispatch when middleware runs (#1477) (#1687)
The Next.js Deploy Suite flagged `test/e2e/proxy-request-with-middleware`
as a Pages Router failure where API routes appeared to return 500 when a
middleware was present. The reported failure cannot be reproduced
against the prod-server: GET, POST, query strings, custom request
headers, the bare `/api` index handler, and middleware-injected request
headers all return 200 today.

The existing `pages-basic` fixture explicitly excludes `/api` from its
middleware matcher, so the existing E2E tests never drive this
combination directly. Add a focused regression test that builds a
tmpdir Pages Router fixture whose middleware DOES match `/api/*`,
boots the prod-server, and asserts the full matrix above.

The Next.js fixture's actual API handler proxies via `req.pipe(...)`,
which depends on Node `IncomingMessage` stream methods that vinext's
prod-server does not yet expose on the `req` object created by
`createPagesReqRes`. That's a separate Node-compat gap (closer to
#1338) and is out of scope for this regression coverage — leaving a
test that locks down the middleware/API handoff so the deploy suite's
underlying claim is no longer ambiguous.
2026-05-29 09:55:57 +01:00
Jerry Zhao 5b58cb9be9 refactor: move normalizePathSeparators to utils/path (#1606)
* refactor: move normalizePathSeparators to utils/path

* update from other pr

---------

Co-authored-by: James <james@eli.cx>
2026-05-29 08:37:32 +00:00
Jerry Zhao 879698b0bc refactor(static-file-cache): reuse normalizePathSeparators for path handling (#1604) 2026-05-29 09:29:36 +01:00
James Anderson 1d26b2b229 fix(shims/script): emit <link rel="stylesheet"> for next/script stylesheets prop (#1646)
Refs #1517.

The vinext `next/script` shim ignored the `stylesheets` prop, so an
`<Script src="/x.js" stylesheets={['/x.css']} />` element emitted only the
`<script>`/preload and silently dropped the associated stylesheet (and
worse, leaked the prop onto the rendered `<script>` as a `stylesheets="..."`
attribute on the beforeInteractive branch).

Mirror the App-Router branch of Next.js's component
(`.nextjs-ref/packages/next/src/client/script.tsx:309-313` and
`:48-59`):

- SSR: call `ReactDOM.preinit(href, { as: 'style' })` for each entry —
  React Float hoists `<link rel="stylesheet">` into `<head>`.
- Client load path (`loadClientScript` + `handleClientScriptLoad`): same
  preinit preferred; fall back to direct `document.head.appendChild` of
  `<link rel="stylesheet">` when `ReactDOM.preinit` is unavailable.
- Destructure `stylesheets` out of `rest` everywhere so it never lands
  as an attribute on the emitted `<script>`.
- Add `stylesheets` to the `RESERVED` set used by the hoisted-script
  attribute collector for inline beforeInteractive scripts.

Scope: stylesheets prop only. Nonce propagation (the other half of #1517)
is already handled across SSR (`buildBeforeInteractiveScriptProps`,
`ReactDOM.preload`) and client (`loadClientScript`) paths — see the
existing "Script nonce resolution" suite. Bootstrap-script preinit is a
separate, larger change tracked under #1328.

Tests: extend `tests/script.test.ts` with a `Script stylesheets prop`
suite covering SSR emission, multi-entry lists, prop leakage on the
rendered `<script>`, omitted/empty lists, and the imperative
`handleClientScriptLoad` path.
2026-05-28 21:18:17 +01:00
James Anderson a6a279509c fix(shims): throw Pages Router parity error from unstable_retry (#1448) (#1670)
Closes the Pages Router parity gap noted as a follow-up in #1206. When
`unstable_catchError`'s boundary is rendered under the Pages Router,
`unstable_retry()` now throws Next.js's verbatim error message
("`unstable_retry()` can only be used in the App Router. Use `reset()`
in the Pages Router.") instead of calling App Router's `refresh()`
(which is a no-op there and leaves the user stuck on the error).

Detection mirrors Next.js's own implementation in
`packages/next/src/client/components/catch-error.tsx`: the inner class
component reads `RouterContext` via `static contextType`. The wrapper
stays a pure JSX factory so existing introspection-style unit tests
that invoke it as a bare function continue to work.

Regression test ported from
`test/e2e/app-dir/catch-error/catch-error.test.ts` —
"should throw when unstable_retry is called on Pages Router".
2026-05-28 21:16:51 +01:00
James Anderson f4687b3537 fix(app-router): treat module-only Vite ref errors as action-not-found (#1340) (#1668)
When a server action id resolved from request headers carries the
`#<exportName>` suffix (e.g. `/app/foo.ts#bar`), `loadServerAction()`
splits the id at `#` and calls `requireModule(file)` with the module
path only. `@vitejs/plugin-rsc`'s reference-validation virtual module
therefore emits `[vite-rsc] invalid server reference '/app/foo.ts'`,
omitting the `#<exportName>` portion. The previous match in
`isServerActionNotFoundError` compared the full actionId against the
Vite error string, so missing/stale references surfaced as a generic
500 instead of the Next.js-compatible 404 with the
`x-nextjs-action-not-found` header.

Also accept the pre-`#` module-path form for both the dev-time
`[vite-rsc] invalid server reference '<id>'` and prod-time
`server reference not found '<id>'` error shapes.

Adds a focused unit test reproducing the dev-mode case and integration
tests asserting that throwing `notFound()` from a server action returns
404 (both fetch and progressive form paths), porting fixture from
Next.js: test/e2e/app-dir/actions/app/server/form.js.
2026-05-28 21:12:59 +01:00
James Anderson c2f9f5b53f fix(app-router): cache pages with revalidate=Infinity/false (#1487) (#1666)
* fix(app-router): cache pages with revalidate=Infinity/false (#1487)

App Router pages exporting `revalidate = Infinity` (or `revalidate = false`,
which resolves to Infinity in segment config) were re-rendered on every
request: the HTML response policy returned `shouldWriteToCache: false`, and
the cache-write policy rejected non-finite revalidate values outright. The
downstream Cache-Control header was correct (1y s-maxage), but vinext itself
never served identical bytes for repeat requests inside the same process.

This change treats `revalidate = Infinity` as "cache indefinitely" rather
than "never write to cache":

- `resolveAppPageHtmlResponsePolicy` now writes the cached HTML/RSC bundle
  in production and reports `MISS` on first render, matching the standard
  ISR write flow. Dev mode continues to emit `STATIC` with no cache write.
- `resolveAppPageCacheWritePolicy` accepts `Infinity` as a valid revalidate
  interval; only `null`, `NaN`, and non-positive values reject the write.

The MemoryCacheHandler (used by tests and as the default) already handles
Infinity correctly: `revalidateAt = now + Infinity` keeps the entry fresh
forever until `revalidatePath`/`revalidateTag` invalidates it.

Adds two production-server regression tests:

- `export const revalidate = 60` returns HIT with identical timestamp on
  the second request (covers the standard finite case).
- `export const revalidate = Infinity` returns HIT with identical timestamp
  on the second request (covers the previously broken indefinite case).

Refs #1487. Other symptoms listed in the issue (`unstable_cache`, fetch
cache directives, force-static, force-no-store, on-demand revalidate) are
out of scope here and tracked separately.

* test(app-page-response): update Infinity revalidate assertions

Production + revalidateSeconds: Infinity now writes to the ISR cache
(cacheState: "MISS", shouldWriteToCache: true). Update the existing
assertion to match, and add a dev-mode case asserting Infinity stays
as a no-write STATIC response in development.
2026-05-28 21:12:23 +01:00
James Anderson 2354ca6e60 test(trailing-slash): pin core enforcement contract for App Router (#1332) (#1635)
* test(trailing-slash): pin core enforcement contract for App Router (#1332)

Adds focused integration tests against a dev-server fixture built from
app-basic with a minimal `next.config.ts` overriding `trailingSlash`.

The core enforcement contract documented in Next.js
test/e2e/trailing-slashes and test/e2e/app-dir/trailingslash was already
implemented across the dev server, prod-server, app-rsc-handler, and the
deploy worker entry by #1395 and #1413, but no end-to-end test asserted
the full contract end-to-end through the dev server. These regression
tests close that gap so future refactors of the trailing-slash redirect
or `<Link>` normalisation paths fail loudly.

Coverage:
- trailingSlash: true
  - GET /about → 308 Location: /about/
  - GET /about/ → 200
  - <Link href="/about"> renders href="/about/"
- trailingSlash: false (default)
  - GET /about/ → 308 Location: /about
  - GET /about → 200
  - <Link href="/about"> renders href="/about" (no trailing slash)

Refs #1332.

* test(trailing-slash): address bonk review nits

- Rename tests/trailing-slash-1332.test.ts → tests/trailing-slash.test.ts
  to match repo naming convention (the issue ref lives in the header
  comment and describe titles).
- Switch to the shared `startFixtureServer` helper from tests/helpers.ts;
  drop the local `startServer` and the now-unused `createServer` / `Server`
  imports.
- Tighten the second describe title to "trailingSlash: false" — the
  fixture explicitly sets `false`, not omits the field. The default-vs-
  explicit behaviour is identical today, so the merged-name was misleading.
- Document why we keep `fs.cpSync` rather than `createIsolatedFixture`:
  app-basic's node_modules carries fixture-local symlinks
  (fake-context-lib, fake-css-lib, …) that `createIsolatedFixture` would
  break by replacing node_modules with a workspace symlink, and the
  isolated copy also gets its own `.vite` optimizer cache (preventing
  races against the workspace cache used by other tests). Verified
  empirically: the bot-suggested `createIsolatedFixture` swap reproduces
  500s on every request, mirroring the rationale already captured in
  tests/favicon-short-circuit.test.ts.

Refs #1332.

* test(trailing-slash): bump 200-serve timeouts to 30s for dev-mode compile

CI showed both 200-serve cases hitting the 5s default while the dev server
was compiling the /about route on first hit. Match the headroom we already
use elsewhere (e.g. tests/app-router.test.ts).

* style: apply biome formatter to trailing-slash.test.ts
2026-05-28 21:07:58 +01:00
James Anderson 5d9548dc92 fix(pages-router): invoke Document.getInitialProps so docProps reach SSR (#1633)
* fix(pages-router): invoke Document.getInitialProps so docProps reach SSR (#1361)

`pages/_document.tsx` files that override `static async getInitialProps`
to inject extra props were rendered with an empty `this.props`, because
both the dev-server and the production pages response builder created
the Document element with `React.createElement(DocumentComponent)` and
never called the hook.

The Next.js async-modules e2e fixture
(`test/e2e/async-modules/pages/_document.jsx`) uses exactly that pattern
to surface a `docValue` from `await Document.getInitialProps(ctx)` plus
a top-level `await` constant. PR #1381 (issue #1361) fixed the class
construction crash, but the deploy-suite continued to fail
"csr async page modules" because `<div id="doc-value">` rendered empty.

This change adds a small `loadUserDocumentInitialProps` helper used by
both the Pages Router dev-server (`streamPageToResponse` shell and the
custom-error-page shell) and the production response builder
(`buildPagesShellHtml`). It invokes the override (if any) and spreads
the resolved props on the Document element, mirroring Next.js's
`render.tsx` `<Document {...htmlProps} {...docProps} />` call. The
helper short-circuits when the user did not override the base shim, so
the existing fast path keeps the same number of awaits.

Reference: vercel/next.js packages/next/src/server/render.tsx
(`loadDocumentInitialProps`, `documentElement`).

Tests:
- Adds a new pages-router production e2e case that builds a
  `_document.tsx` with `static async getInitialProps` + top-level
  `await`, plus a custom `404.tsx` that uses top-level `await`, and
  asserts the resolved values reach the rendered HTML.
- Unit-tests `loadUserDocumentInitialProps` directly for the override,
  no-override, and throwing-override cases.

Refs #1361

* fix(pages-router): address bonk review on Document.getInitialProps helper

Two changes from review feedback on PR #1633:

1. Cache the base getInitialProps reference at module level. The previous
   helper did `await import("vinext/shims/document")` on every request to
   compare identity — unnecessary per-request work and an extra await on
   the fast path where the user has no override. Switched to a static
   import + module-scoped constant so the identity check is a synchronous
   reference compare. No-override callers now resolve with one less await.

2. Let user `getInitialProps` errors propagate. Next.js's `loadGetInitialProps`
   does not catch — a throw surfaces as a 500 to the caller. The previous
   version logged-and-swallowed the error and returned null, which
   silently erased docProps on every failing render and made user bugs
   harder to find. vinext now matches Next.js's contract.

Updated the corresponding unit test to assert the throw propagates rather
than that the helper resolves to null.

Refs #1361, #1633
2026-05-28 21:06:28 +01:00
James Anderson 809218f6c4 fix(pages-router): mark app routes as detected on prefetch (#1526) (#1653)
* fix(pages-router): mark app routes as detected on prefetch (#1526)

In a hybrid Pages + App Router app, when the Pages Router prefetches a
`<Link href>` whose target is actually an App Router route, Next.js marks
the route on the Pages Router singleton with
`router.components[urlPathname] = { __appRouter: true }` (driven by the
`_bfl` bloom filter at packages/next/src/shared/lib/router/router.ts:2525).
The Next.js deploy test `test/e2e/app-dir/app/index.test.ts` ("should
successfully detect app route during prefetch") asserts this via
`window.next.router.components["/dashboard"]`.

Vinext previously did not expose `components` and did not record App Router
prefetch targets, so the assertion never resolved.

This commit:

- adds a shared `components` map (via `Symbol.for` for module-split safety)
  on the Pages Router singleton, mirrored onto `window.next.router`,
- threads the App Router prefetch manifest into the Pages Router client
  entry so the same matcher Link uses for App Router auto-prefetch can run
  in Pages-only-entry pages,
- marks the target on `components` with `{ __appRouter: true }` from both
  `Router.prefetch` and `<Link>` Pages-mode prefetch when the target
  matches the App Router manifest.

* fix(app-route-detection): drop unused exports flagged by knip

Both `matchesAppRoute` and `PagesRouterComponentsMap` are only used
inside this module — knip rightly flagged them as unused exports.
Internalize both.
2026-05-28 21:03:25 +01:00
James Anderson 3ff06784a0 fix(app-router): forbidden()/unauthorized() escalate past intermediate layouts (#1547) (#1647)
When a deep page called `forbidden()`/`unauthorized()`/`notFound()` and the
intermediate layout segments had no matching boundary file, vinext's
page-error fast path wrapped the resolved root boundary in *all* of the
route's layouts. That left the intermediate layout's chrome ("Dynamic with
Layout") rendered next to the boundary fallback, diverging from Next.js
where the nearest ancestor boundary owns the entire fallback subtree.

`renderPageSpecialError` now resolves the deepest layout-level boundary
via `resolveAppPageParentHttpAccessBoundary` and slices the layouts to
`[0..boundaryLayoutIndex]`, so layouts beneath the boundary owner stop
rendering. When the route-level boundary file lives next to the page
(not aligned to a layout), all layouts are kept as before.

Ported the Next.js regression cases from
`test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts` and
`test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts`.
2026-05-28 21:00:56 +01:00
James Anderson cd62cb2ebb fix(app-router): honor experimental.staleTimes in client prefetch cache (#1490) (#1660)
* fix(app-router): honor experimental.staleTimes in client prefetch cache (#1490)

The App Router's client-side prefetch cache previously used a hard-coded
30s TTL and ignored `experimental.staleTimes` from next.config. As a
result, users could not extend (or shorten) how long prefetched route
segments were considered fresh, breaking parity with Next.js'
`test/e2e/app-dir/app-client-cache/client-cache.experimental.test.ts`.

Surface `experimental.staleTimes.{dynamic,static}` through
`ResolvedNextConfig` and inject it into the client bundle as
`process.env.__NEXT_CLIENT_ROUTER_{DYNAMIC,STATIC}_STALETIME` (matching
the Next.js variable names plumbed by `build/define-env.ts`). The
prefetch cache TTL in `shims/navigation.ts` now reads the static value
at module load time, converting seconds to ms.

Defaults mirror Next.js (`dynamic: 0`, `static: 300`). When the env var
is absent — primarily in unit tests that import the module outside the
plugin's `define` pipeline — we fall back to vinext's historical 30s
default rather than the Next.js 300s so existing tests keep passing.

Refs: .nextjs-ref/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts
Refs: .nextjs-ref/packages/next/src/build/define-env.ts

* review: address bot feedback on staleTimes PR

- Remove invokeLoaderSideEffects scope creep (belongs to #1500 / PR #1657)
- Reject negative staleTimes at resolution time alongside Number.isFinite,
  matching the seconds < 0 guard in resolvePrefetchCacheTtl
- Drop unused __NEXT_CLIENT_ROUTER_DYNAMIC_STALETIME define; vinext does
  not yet distinguish prefetch kinds, so the value was never consumed
2026-05-28 20:59:44 +01:00
James Anderson 90a09df893 fix(build): invoke webpack loader side effects to land process.env mutations (#1500) (#1657)
Some Next.js webpack loaders mutate `process.env.X = ...` at compile
time, expecting the value to be visible to other modules during the
same build. vinext doesn't run the webpack loader pipeline, so those
env mutations never happened and downstream code (defines,
`NEXT_PUBLIC_*`, server-side reads) saw `undefined`.

Walk the rules collected from the user's `webpack(config)` function
and invoke each referenced loader once with a dummy source. Loaders
that only mutate `process.env` get a chance to land their changes in
the shared Node process. Failures are swallowed since vinext doesn't
use the loader's transform output. Well-known framework loaders
(next-babel-loader, @next/mdx, next/dist/build/webpack/*) are skipped
to avoid pulling in webpack-runtime-dependent code at probe time.
2026-05-28 20:57:21 +01:00
James Anderson c1a6adc19a fix(routing): treat @children as transparent so explicit page wins over catchall (#1535) (#1659)
In Next.js, the `@children` directory is special: it provides the
layout's `children` prop rather than acting as a parallel slot.
`app/foo/@children/page.tsx` should therefore register a real page
route at `/foo` and take priority over a sibling `[...catchAll]`
catch-all. Previously vinext excluded all `@`-prefixed directories
from the page scan, so `@children/page.tsx` was hidden and `/` (or
`/nested`) was either served by the catch-all or failed to resolve
when no `default.tsx` existed.

Three small changes restore Next.js parity:

- Let `@children` directories through the page-scan exclusion so the
  page file at `@children/page.tsx` registers as a route.
- Strip a trailing `@children` segment in `fileToAppRoute` so the
  route is anchored at the parent directory — sibling `@slot`
  directories remain owned (not inherited) and layout/boundary
  discovery is sourced from the parent.
- Skip `@children` in `discoverParallelSlots` and
  `hasParallelSlotDirectory` so it never appears in
  `parallelSlots` (matching Next.js' types-plugin behaviour).

Ported from Next.js: test/e2e/app-dir/parallel-routes-catchall-children-slot/
https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-catchall-children-slot/parallel-routes-catchall-children-slot.test.ts

References Next.js' `normalizeAppPath` and `normalize-catchall-routes`
which both treat `@children` as a non-matchable slot:
- packages/next/src/shared/lib/router/utils/app-paths.ts
- packages/next/src/build/normalize-catchall-routes.ts
- packages/next/src/build/webpack/plugins/next-types-plugin/index.ts

Fixes #1535.
2026-05-28 20:54:32 +01:00
James Anderson 1671a46c35 fix(app-router): dedupe action Set-Cookie headers by name (#1481) (#1665)
Multiple `cookies().set(name, ...)` calls on the same name inside a server
action emitted multiple Set-Cookie response headers, diverging from
Next.js's `MutableRequestCookiesAdapter`, whose underlying `ResponseCookies`
Map collapses repeated sets to the final value per name.

Apply `dedupePendingCookies` at every cookie-emitting action exit (form
action redirects, fetch action redirects, fetch action responses) so the
last write per name wins and insertion order is preserved.

Reference: packages/next/src/server/web/spec-extension/adapters/request-cookies.ts
Refs #1481.
2026-05-28 20:53:04 +01:00
James Anderson 2d9f3ffc36 fix(app-router): accept icons.other as single descriptor (#1492) (#1667)
Next.js's `metadata.icons.other` accepts either a single
`IconDescriptor` or an array of them, but `MetadataHead` iterated
it directly and threw `TypeError: metadata.icons.other is not iterable`
when given a single object. That crash bubbled up into the page
render so no `<link>` tags (favicon, shortcut, apple, custom) reached
the head at all for any page declaring `icons.other` as an object.

Normalize `metadata.icons.other` to an array before iterating, widen
its type to `OtherIconDescriptor | OtherIconDescriptor[]`, and also
forward the optional `type` attribute so descriptors like
`{ rel: "mask-icon", url, type: "image/svg+xml" }` round-trip
correctly. Ported coverage from
`test/e2e/app-dir/metadata-icons/metadata-icons.test.ts`.

Refs cloudflare/vinext#1492
2026-05-28 20:13:04 +01:00
James Anderson 33be785d43 fix(app-router): allow draftMode() reads inside cache scopes (#1489) (#1669)
Calling `await draftMode()` and reading `.isEnabled` inside a `"use cache"`
or `unstable_cache()` callback now works the same as Next.js: the
non-mutating reads succeed and reflect the `__prerender_bypass` cookie,
while only the side-effecting `enable()` / `disable()` calls throw when
invoked inside a cache scope.

Previously vinext threw eagerly on `draftMode()` itself, which caused
fixtures such as `app-static`'s `unstable-cache/dynamic` page (which
reads `(await draftMode()).isEnabled` inside the cached function) to
surface as empty/undefined values in the Next.js deploy suite.

Ported from `.nextjs-ref/packages/next/src/server/request/draft-mode.ts`
(`getDraftModeProviderForCacheScope` + `trackDynamicDraftMode`).
2026-05-28 20:12:13 +01:00
James Anderson d61c5cbafe fix(app-router): propagate cookies/headers from no-JS server actions (#1483) (#1673)
When a progressive (no-JS) server action POST runs without redirecting, the
pending `cookies().set(...)` / `draftMode().enable()` mutations made during
the action were dropped on the floor — the form-state result returned from
`handleProgressiveServerActionRequest` never read them out of the
request-scoped headers context, and the subsequent page rerender never
flushed them onto the response.

Mirror Next.js' `res.setHeader('set-cookie', ...)` flush in
`action-handler.ts` / `app-render.tsx`: capture pending cookies, the draft
cookie, and the resolved revalidation kind at the end of the non-redirect
action path, expose them on the form-state result, then append them to the
rerender response (and the `x-action-revalidated` marker) in
`app-rsc-handler`. Falls back to rebuilding the response when its headers are
immutable so cookies still ride a `redirect()` issued during the rerender.

Fixes #1483.
2026-05-28 20:11:34 +01:00
James Anderson 51a63131b5 test(pages-router): regression coverage for URL-encoded CSS paths (#1472) (#1675)
Adds focused tests verifying that the static-asset layer serves CSS
files when the browser percent-encodes special characters in the path:

* `tests/serve-static.test.ts` — three cases against `tryServeStatic`:
  the cache fast path with an encoded URL, the slow path with an
  encoded URL, and the literal-character pathname that the request
  pipeline produces after `normalizePathnameForRouteMatchStrict` runs.

* `tests/pages-css-url-encoding.test.ts` — full build-and-serve test
  for the Pages Router that imports `my@style.css` and `with space.css`
  from `pages/index.tsx`, then asserts every emitted stylesheet link
  resolves to a 200 after `encodeURI` is applied (matching browser
  behaviour).

All three tests pass against the current implementation: `tryServeStatic`
decodes percent-encoded pathnames before cache lookup, and the cache
indexes files by their literal on-disk names. These tests document the
contract and guard against future regressions.

Refs cloudflare/vinext#1472
2026-05-28 20:09:27 +01:00
James Anderson 76fe5d8b05 fix(pages-router): render custom pages/500 on SSR throw (#1676)
When getServerSideProps (or any other render-time code) threw in the
production server entry, vinext collapsed the response into a plain
"Internal Server Error" text body. Next.js instead renders the user's
custom pages/500.tsx (with pages/_error.tsx as a fallback). Match that
behaviour so frameworks that rely on custom error pages — and the
Next.js getserversideprops deploy suite — see the expected output.

The catch block in `_renderPage` now re-renders through the same entry
using `pages/500.tsx` (or the `_error` route) with status 500. A new
`__isInternalErrorRender` guard prevents infinite recursion if the error
page itself throws; data (`/_next/data/...`) requests still short-circuit
to the plain text body since they can't return HTML.

Mirrors Next.js base-server.ts `renderToResponseImpl` 500 fallback. The
dev server already renders custom 500/error pages via `renderErrorPage()`
— this aligns prod with that behaviour and adds explicit regression
coverage for both paths via a new gssp-throw fixture.

Refs #1458
2026-05-28 20:08:37 +01:00