Commit Graph

1163 Commits

Author SHA1 Message Date
github-actions[bot] e7d8a159f4 chore: version packages (#1823)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
vinext@0.1.1
2026-06-09 12:12:32 +01:00
Divanshu Chauhan (divkix) bf5b09c46f refactor(pages-router): unify triplicated request pipeline into runPagesRequest (#1782) (#1860)
* feat(pages-router): add runPagesRequest pipeline owner with focused tests

Introduces server/pages-request-pipeline.ts which owns the canonical
Next.js 9-step request ordering once. Returns a PagesPipelineResult
discriminated union: type:response for prod/worker callers that supply
render/api callbacks, type:render|api|next intents for dev callers that
omit them. Mirrors createAppRscHandler's thin-closure injection pattern.

Two latent drift bugs reconciled vs the worker copy:
- middlewareStatus now uses result.status ?? result.rewriteStatus
- applyMiddlewareRequestHeaders now passes preserveCredentialHeaders

Adds 28 focused behavior tests covering all pipeline branches.
Adds ./server/pages-request-pipeline export to package.json.

* refactor(pages-router): migrate prod-server.ts to delegate to runPagesRequest

Replaces the ~400-line inlined try block in startPagesRouterServer with a
thin adapter that calls runPagesRequest(webRequest, deps). The adapter
retains only Node-specific concerns: open-redirect guard, decode/400,
prerender endpoint, static assets, image opt, basePath strip, _next/data
normalization, and compression/streaming output.

Extends renderPage callback signature with optional stagedHeaders param
so CSP nonces and other pre-render header injection continue to work.
Copies __vinextStreamedHtmlResponse marker through mergeHeaders to
preserve stream-vs-buffer decision in the adapter.

276 pages-router tests, 112 routing tests, 28 pipeline tests all pass.

* refactor(pages-router): migrate deploy.ts worker template to delegate to runPagesRequest

Collapses the ~280-line inlined fetch handler into ~65 lines. Worker adapter
keeps only: registerConfiguredCacheAdapters, open-redirect guard, static-asset
404, x-nextjs-data header capture, filterInternalHeaders+cloneRequestWithHeaders,
basePath strip with hadBasePath, and image optimization. Delegates the rest to
runPagesRequest via PagesPipelineDeps.

Removes inlined imports: matchRedirect, matchRewrite, preserveRedirectDestinationQuery,
requestContextFromRequest, applyMiddlewareRequestHeaders, isExternalUrl, proxyExternalRequest,
sanitizeDestination, applyConfigHeadersToHeaderRecord, normalizeTrailingSlash, mergeHeaders,
normalizeDefaultLocalePathname, stripI18nLocaleForApiRoute, mergeRewriteQuery.

Updates deploy.test.ts assertions to verify delegation rather than inlined
step logic. 245 deploy tests pass.

* refactor(pages-router): migrate index.ts dev to runPagesRequest, delete parallel helpers

Replaces the inlined dev Pages Router pipeline with runPagesRequest. The
adapter keeps Node-specific concerns: Vite skips, cross-origin guard, image
302 redirect, .html normalization, open-redirect guard, decode/400, basePath
strip with hadBasePath, trailing-slash, _next/data normalization, rawHeaders
snapshot, cloudflare-plugin delegation.

Creates devRunMiddlewareAdapter closure that wraps the dev runMiddleware
(runner, path, i18n, basePath, trailingSlash, isDataRequest args) and
returns a MiddlewareResult-compatible object. Sets VINEXT_MW_CTX_HEADER for
hybrid app+pages mode as a side effect before returning.

Adds writeWebResponseToNodeRes() helper to stream Web Response to Node res
preserving multi-value Set-Cookie. Adds requestHeaders field to render/api
intents so dev adapter can flush post-middleware request headers to req.headers
before calling createSSRHandler/handleApiRoute.

Deletes:
- applyRedirects (lines ~4829-4869)
- proxyExternalRewriteNode (lines ~4867-4928)
- applyRewrites (lines ~4929-4951)
- applyHeaders (lines ~4951-5015)

276 pages-router tests, 112 routing tests, 245 deploy tests, 28 pipeline
tests all pass. vp check clean.

* refactor(pages-router): format deploy.test.ts string literals consistently

* fix(pages-router): restore hasAppDir dev gate; fix external proxy body/headers; update stale after-deploy assertions

Three regressions introduced by the dev pipeline refactor:

1. Hybrid app+pages dev mode: the 'render' intent branch in handlePagesMiddleware
   unconditionally called createSSRHandler, missing the original hasAppDir fallthrough
   gate. App Router page requests in the hybrid app-basic fixture returned 404 instead
   of being deferred to the RSC plugin. Restored by matching the route before calling
   the Pages SSR handler and returning next() when there is no pages match and hasAppDir
   is true.

2. External proxy body/headers: proxyExternalRewriteNode read the body from the Node
   IncomingMessage and the post-middleware headers from req. The refactored pipeline used
   a body-less webRequest, so POST bodies were empty and middleware-modified request
   headers (x-hello-from-middleware1 etc.) were not forwarded. Fixed by adding an
   optional proxyExternal dep to PagesPipelineDeps; the dev adapter supplies it, building
   a proper Request from the pipeline's current (post-middleware) headers and the Node
   req body stream. Prod and worker adapters fall through to proxyExternalRequest as before.

3. Stale after-deploy assertions: two tests checked old inline call-site strings that
   no longer appear in the generated worker entry after the delegation refactor. ctx is
   still forwarded (deploy.ts:654,657); updated assertions to match the new call sites.

* fix(pages-router): restore public-file serving + API content-type; gate dev api flushes

Three behavioral regressions from the pipeline extraction, plus the bonk
review findings:

- prod-server (Node): restore post-middleware public-directory static file
  serving (original step 5b). The adapter now supplies a serveStaticFile dep
  that the pipeline calls after middleware (so middleware can intercept) and
  before rewrites. Without it, public/ files (favicon.ico, robots.txt, etc.)
  fell through to the renderer and 404'd. Adds a {type:"handled"} result for
  callbacks that write their own output.

- prod-server (Node): restore the application/octet-stream content-type default
  for API responses. The unified response path collapsed API and page fallbacks
  to text/html; the pipeline now tags API results with isApiResponse so the Node
  adapter picks octet-stream (arbitrary data) over text/html (content-sniffing
  hazard). [bonk finding 1]

- index.ts (dev): gate the staged-header/req-header flushes behind a pages-api
  match, mirroring the original 'if (apiMatch)' guard. The unconditional
  flushRequestHeaders() wiped req.headers (incl. VINEXT_MW_CTX_HEADER) on the
  api-miss -> next() path, dropping middleware context for app/api/* routes in
  hybrid app+pages dev mode.

- pipeline: note {type:"next"} is reserved/unused [bonk finding 2]; document
  the intentional external-proxy merge asymmetry on the bare proxyExternal
  returns [bonk finding 3].

Adds 5 pipeline tests (serveStaticFile/handled ordering, isApiResponse tag).

* fix(pages-router): forward staged headers to worker renderPage; gate data-request defer

Two divergences found in code review of the extraction:

- deploy.ts (worker): the renderPage adapter dropped the pipeline's 4th
  stagedHeaders arg, passing undefined for the SSR renderer's middlewareHeaders
  param — so middleware-set CSP nonces were never applied to rendered HTML on
  the worker, unlike the prod path. Widen the wrapper to forward stagedHeaders.

- pipeline: shouldDeferErrorPageOnMiss only gated on isDataReq, which the worker
  never sets (it doesn't normalize /_next/data paths). The pre-refactor worker
  gated on the x-nextjs-data header (isDataRequest), so a data-request miss now
  wrongly deferred + ran fallback rewrites + re-rendered. Gate on both signals;
  no-op for Node/dev where a data request already has isDataReq=true.

Updates the after-deploy assertion to the new call site and adds a pipeline
test for the data-request defer gate.

* fix(pages-router): address code-review findings (content-type, redirect query, dedup)

- prod content-type: passthrough responses (middleware short-circuits, external
  proxies) no longer get a text/html default injected. Generalize the response
  tag to defaultContentType (render -> text/html, api -> octet-stream, passthrough
  -> unset); the Node adapter sends untagged responses verbatim via sendWebResponse,
  matching pre-refactor behavior.

- redirect query: config-redirect Location now uses the raw req.url query (via a
  new rawSearch dep supplied by the Node prod + dev adapters) instead of
  new URL().search, which re-encoded chars and truncated at a literal '#'. The
  worker keeps url.search (it only ever had a Web Request). Restores the original
  prod/dev behavior.

- efficiency: matchPageRoute no longer runs twice per request on the common
  no-afterFiles-rewrite path; reuse the Step 12 match unless afterFiles changed
  the pathname.

- dedup: collapse prod-server's mergeWebResponse into the canonical mergeHeaders
  (worker-utils) via delegation, removing the duplicate implementation and the
  now-orphaned hasHeader/stripHeaders helpers. Existing mergeWebResponse tests
  validate the delegation.

Updates pipeline tests for defaultContentType and adds a passthrough-verbatim
assertion. 36 pipeline + 309 features + 276 pages-router + 245 deploy + 9
after-deploy + 112 routing pass; vp check clean.

---------

Co-authored-by: James <james@eli.cx>
2026-06-09 11:47:31 +01:00
Nathan Nguyen 3a1e56748f fix(app-router): match Pages navigation params in hybrid builds (#1741)
* fix(app-router): emit pages client entry for hybrid builds

* fix pages router navigation params readiness

* fix(pages-router): track hashed fallback client entry

* fix(pages-router): honor navigation readiness in hooks

* fix(pages-router): derive server router.isReady from SSR readiness

Address review feedback on #1741:

- getRouterSnapshot() now reads navigationIsReady from the SSR context on
  the server instead of unconditionally returning true. For pre-ready Pages
  routes (auto-export dynamic / query-string / rewrite-capable builds) this
  keeps useRouter().isReady consistent between SSR and client hydration,
  avoiding a hydration mismatch for components that read it in JSX. Mirrors
  Next.js render.tsx's server readiness rule.
- Drop the redundant guard branch in getClientParamsSnapshot.
- Add a regression test asserting server useRouter().isReady reflects the
  SSR navigationIsReady context.

* fix(pages-router): align param resolution and dev/prod readiness parity

Address review feedback (ask-bonk + self-review) on #1741:

- Align client Pages navigation params to query-first, matching the server
  snapshot and Next.js's adaptForPathParams (derives path params from
  router.query). __NEXT_DATA__.query is kept current on client navigation, so
  the two snapshots are now provably identical and avoid a latent post-
  hydration mismatch.
- Populate window.__VINEXT_PAGE_PATTERNS__ in the dev server (own script tag)
  so the next/navigation compat hooks can resolve a dynamic pattern from a
  resolved path in dev exactly as in production. Closes a dev/prod parity gap
  (prod set this via the client entry; dev never did).
- Drop the redundant pre-readiness setSSRContext in dev; the single post-
  module-load call now publishes navigationIsReady before rendering, matching
  the prod handler.
- Document that installPagesClientAssetGlobals is intentionally inert for
  App-only builds, and that getReadonlyPagesSearchParams' string-keyed cache
  is safe to share across concurrent SSR requests (immutable wrappers).
- Add a dev regression test asserting __VINEXT_PAGE_PATTERNS__ exposure.

* fix(pages-router): prefer URL params for client snapshots

* fix(app-router): gate pages asset globals

* test(app-router): add production test for hybrid basePath + assetPrefix

* fix(navigation): widen usePathname return type to string | null

* refactor(pages-router): dedupe navigation readiness modeling and cache pattern scan

Addresses code-review cleanup findings on the Pages Router navigation
readiness work:

- Extract buildPagesReadinessNextData() so the dev SSR handler and the
  production Pages page handler derive the gssp/gsp/gip/appGip/autoExport
  __NEXT_DATA__ readiness flags from one place, instead of two hand-kept
  copies that could drift and produce a dev/prod hydration mismatch.
- Extract resolvePagesNavigationPathname() shared by the client and SSR
  branches of getPagesNavigationContext, so the pathname null-ness rule
  (fallback / pre-ready auto-export dynamic) cannot diverge between them.
- Memoize resolvePagesRoutePatternForPath()'s pattern scan, which ran an
  uncached O(routes) loop on every useSyncExternalStore snapshot read.
- In getServerSearchParamsSnapshot, serialize the source only when the
  object-identity fast path misses, avoiding a toString() on every read.

* refactor(pages-router): consistent ISR readiness data + robustness cleanups

Addresses further code-review findings:

- Thread the readiness flags (gssp/gsp/gip/appGip/autoExport) through the ISR
  background-regeneration path: renderPagesIsrHtml now serializes the same
  __NEXT_DATA__ readiness state the initial render emits, so a regenerated,
  re-cached page hydrates with the identical initial router.isReady the server
  computed. Previously these flags were dropped on that path — benign today
  (ISR-eligible getStaticProps routes never trigger the flag-gated early-ready
  branches) but a latent dev/prod hydration-readiness drift. Adds a shared
  PagesNextDataExtras type so every render path stays in lockstep.
- readSsrManifest: degrade a malformed/unparseable SSR manifest to "no SSR
  manifest" with a warning instead of throwing and aborting server startup,
  restoring the previous tolerant behavior.
- pages-readiness: project PagesReadinessNextData from the canonical
  VinextNextData and accept PagesPageModule, instead of bespoke types that
  could drift from the __NEXT_DATA__ shape.
- getReadonlyPagesSearchParams: drop the redundant string-keyed cache slot; the
  per-object WeakMap is authoritative and concurrency-safe on its own.
- isPagesRouterReady: drop the redundant server-side window check.

* fix(pages-router): restore string-keyed readonly search-params cache

Reverts the cache simplification from the previous commit. The string-keyed
slot is load-bearing, not redundant: across the Pages Router pre-ready → ready
transition the navigation context swaps in a new URLSearchParams instance even
when the query string is unchanged, so the per-object WeakMap alone returns a
fresh ReadonlyURLSearchParams wrapper and useSearchParams() loses Object.is
stability — re-firing a [searchParams] effect. Caught by the rewrite-driven
pre-ready static page e2e (search-params-snapshots saw ['',''] not ['']).
Keeps the improved comment explaining why both cache levels are needed.

---------

Co-authored-by: James <james@eli.cx>
2026-06-09 11:30:38 +01:00
James Anderson 9596b72495 fix(pages-router): pass revalidateReason "on-demand" to gsp/gssp (#1462) (#1856)
* fix(pages-router): pass revalidateReason "on-demand" to getStaticProps (#1462)

Implements res.revalidate() for Pages Router API routes and wires the
on-demand revalidation trigger through to getStaticProps/getServerSideProps
context as revalidateReason: "on-demand".

res.revalidate(urlPath) issues an internal HEAD request to the target path
carrying the x-prerender-revalidate header (mirroring Next.js's api-resolver).
The Pages render path (dev-server and prod pages-page-data) detects that
header, bypasses the fresh/stale cache-hit short-circuits, and regenerates
the entry synchronously with reason "on-demand".

The build and stale reasons already worked; this finishes the on-demand case.

Closes #1462

* fix(pages-router): authenticate on-demand revalidation with a secret (#1462)

The on-demand `revalidateReason` detection treated `x-prerender-revalidate`
as an unauthenticated presence check and sent the literal value "1". Any
external client could send `x-prerender-revalidate: 1` to force synchronous
regeneration of any ISR page, bypassing the fresh/stale cache short-circuits
— a cache-stampede/DoS vector.

Mirror Next.js's `checkIsOnDemandRevalidate`: a request is only treated as
on-demand revalidation when the `x-prerender-revalidate` header value EQUALS a
secret (the vinext analog of `previewModeId`). Introduce a per-process
revalidate secret (`getRevalidateSecret`, 256-bit random hex via Web Crypto)
that `res.revalidate()` attaches to its internal loopback request, and a
constant-time `isOnDemandRevalidateRequest` equality check on the receiving
dev-server and Pages page handler. Header presence alone is never honored.

Also fix the test's HTML-reason extraction to avoid CodeQL's
incomplete-sanitization / bad-HTML-regex findings, and add a negative test
asserting forged `x-prerender-revalidate` values ("1", "", a guess) are
rejected while the real secret is accepted.

* refactor(pages-router): share res.revalidate() impl; support unstable_onlyGenerated (#1462)

Address non-blocking bonk review findings:
- Extract the two near-identical `res.revalidate()` implementations
  (`api-handler.ts`, `pages-node-compat.ts`) into a shared
  `performOnDemandRevalidate` helper so the secret wiring and success detection
  cannot drift between dev and prod.
- Align success detection with Next.js's api-resolver: accept
  `x-nextjs-cache: REVALIDATED`, status 200, or a 404 when the caller passed
  `unstable_onlyGenerated`. Add `unstable_onlyGenerated` support
  (`x-prerender-revalidate-if-generated` header).

* docs(pages-router): note x-nextjs-cache REVALIDATED branch is parity-only (#1462)

* fix(pages-router): make on-demand revalidate secret build-time (shared across isolates)

The on-demand ISR revalidation secret was a per-process random value. On
Cloudflare Workers, res.revalidate()'s loopback fetch() can land on a different
isolate than the sender, so a per-process secret mismatches across isolates and
false-rejects legitimate revalidations.

Mirror Next.js's previewModeId: generate one 256-bit secret at build time (in
the vinext build CLI, once per build, shared across plugin instances like
__VINEXT_SHARED_BUILD_ID) and bake it SERVER-ONLY into every server bundle via a
Vite define (__VINEXT_REVALIDATE_SECRET). It is byte-for-byte identical in every
isolate and never reaches the client bundle. getRevalidateSecret() reads the
baked constant at runtime and falls back to a process-shared random secret in
dev (single-process, no regression). Also persist revalidateSecret in
vinext-server.json for diagnostics/tooling parity with prerenderSecret.

Constant-time safeEqual validation and reject-arrays/empty/absent behavior are
unchanged.

Refs #1462

* test(pages-router): cover server-only revalidate secret; drop dead manifest reader

Address ask-bonk review of the build-time revalidate secret:

- Add tests pinning the security-critical invariant that the
  __VINEXT_REVALIDATE_SECRET define is baked into server environments (rsc/ssr)
  but NEVER into the client bundle (the client env returns null outright). A
  leak would ship the secret to every browser and re-open the cache-stampede
  vector the equality check prevents. Also harden the existing "no-ops when
  defineServer unset" test to explicitly clear the env var instead of relying on
  it happening to be unset.
- Drop the unused readRevalidateSecret export and the orphaned revalidateSecret
  manifest field — the runtime authenticates against the baked define (both
  Workers isolates and the Node prod-server run the bundle), so the on-disk copy
  had no consumer (knip-flagged dead code).
- Hoist the client early-return in vinext:compiler-define-server so the
  server-only guarantee is structural (removes the redundant name check), and
  reword the dev-fallback comments ("absent unless set during vinext build").

Refs #1462
2026-06-09 10:07:50 +01:00
James Anderson 062a0b9489 chore(deploy-suite): bump playwright/playwright-chromium to 1.60.0 after install (#1862) 2026-06-09 09:06:45 +00:00
James Anderson c669930f56 fix(build): correct CSS ordering for global-not-found (#1549) (#1858)
* fix(build): correct CSS ordering for global-not-found (#1549)

* test(build): assert global-not-found CSS ordering in production (#1549)

Adds a production-build regression test for #1549: builds the global-not-found-css-order fixture, serves it via the Vite preview server, and asserts the route-miss 404 document serves global-not-found's own CSS (red wins) without leaking the root layout's stylesheet (green), while matched routes still serve the layout's CSS.

Verified the test fails without the framework-chunk split in client-build-config.ts (the 404 leaks the layout's green) and passes with it.

* chore: update lockfile for global-not-found-css-order test fixture (#1549)

* refactor(build): migrate RSC framework chunk to codeSplitting; add unit tests

Address bonk review on #1858 (issue #1549):

- Replace the deprecated rolldown `advancedChunks` output config with
  the non-deprecated `codeSplitting` form for Vite 8+, mirroring the
  client build. Stops the per-prod-build deprecation warning.
- Derive both RSC_FRAMEWORK_CHUNK_TEST (regex) and isRscFrameworkModule
  (predicate) from a single FRAMEWORK_PACKAGES list so they can't drift.
- Add focused unit tests for createRscFrameworkChunkOutputConfig (both
  the Vite 7 manualChunks and Vite 8+ codeSplitting branches) and for the
  framework package matching (matches react/react-dom/scheduler/
  react-server-dom-webpack, excludes react-icons / @react-aria/*).
2026-06-09 10:06:14 +01:00
James Anderson dbd6c6ea9c feat(release): reclassify commits via SHA-named changeset overrides (#1859)
* feat(release): reclassify commits via SHA-named changeset overrides

Auto-changesets are regenerated from commit subjects every push and never
committed to main, so a mislabeled commit can't be fixed by editing them, and a
hand-authored changeset can only raise a bump (max), never lower it.

Add a per-commit override: commit `.changeset/<sha>.md` and the release tooling
treats that commit as the bump declared in its frontmatter (patch->fix,
minor->feat, major->feat!, empty->chore/dropped), driving both the semver bump
and the changelog section. The file is a real changeset, so changesets/action
consumes it for the bump and deletes it on release -- overrides never accumulate.

Both create-changeset.mts (bump) and version.mts (changelog grouping) honor it,
keyed off the SHA in the filename.

* chore: reclassify #1804 as a fix

Override `feat(interception): sibling-style interception routes (#1804)`
(b4c829d6) to release as a patch-level fix instead of a minor feature.

* feat(release): use SHA-named override changeset body as the changelog message

The override changeset's body now becomes the reclassified commit's changelog
entry -- a plain bullet replacing the commit subject's description (scope and
all) -- in addition to overriding the bump and section. Leave the body empty to
keep the original subject and only reclassify the type.

Adds changesetBodyMessage(), threads an optional message through
rewriteSubjectType() and applyOverrides(), and captures it in loadOverrides().

* chore: set #1804 override changelog message

Now that the override body drives the changelog, give it a proper Bug Fixes
entry instead of a meta rationale.
2026-06-08 22:27:29 +01:00
James Anderson 29a040dd21 fix(app-router): include route pattern in repeated-slash Link warning (#1554) (#1855)
* fix(app-router): include route pattern in repeated-slash Link warning (#1554)

The repeated forward-slashes Link warning added in #1563 fired with the
wrong "in page" segment: it used `window.location.pathname` (or `"/"`
during SSR) instead of Next.js's route pattern (`router.pathname`, e.g.
`/my/path/[name]`). The Next.js compat test
`repeated-forward-slashes-error` asserts the *full* message including that
route pattern, so it kept failing in the deploy suite despite #1563 — the
`/Invalid href/` regex matched but the `toContain` of the exact message
did not.

Source the page segment from the current render's route pattern via a new
`getCurrentRoutePathnameForWarning()` helper: the Pages Router SSR
context's route pattern on the server, `window.location.pathname` on the
client, falling back to `"/"`. Normal Link behaviour is unchanged.

Add an SSR-level compat test (app + pages router) that renders a Link with
a repeated-slash href and asserts the exact warning, plus the upstream
dynamic-route fixture `pages/my/path/[name]`.

Finishes #1554 (the warning wasn't firing with the right page name despite #1563).
Closes #1554

* fix(app-router): read route pattern via side-effect-free accessor

The previous commit imported `router.ts` into `link.tsx` to source the
route pattern for the repeated-slash warning. But `router.ts` has a
browser-only top-level side effect (`installWindowNext({ router })`), so
pulling it into the Link shim — which is in the App Router client bundle too
— clobbered `window.next.router` with the Pages Router instance, breaking
App Router client navigation (useParams/prefetch/interception E2E timeouts).
This is exactly why `navigation.ts` deliberately avoids importing
`router.ts` and reads the Pages nav context through a `Symbol.for` handle.

Mirror that pattern: add a side-effect-free `route-pattern-for-warning`
module that reads the SSR route pattern through a `Symbol.for` accessor
published by the server-only `router-state.ts`. The Link shim imports only
this tiny module, so no Pages Router side effect leaks into the App Router
bundle. Behaviour is unchanged: pages-router SSR reports
`in page: '/my/path/[name]'`, client/App Router fall back to
`window.location.pathname` then `"/"`.
2026-06-08 20:40:16 +00:00
James Anderson dd3fb05977 test(pages-router): cover javascript: URL blocking (#1576) (#1854)
* fix(pages-router): emit console.error for javascript: URL navigation (#1576)

Finishes the Pages Router half of #1576 (the App Router warnings already
land). Adds the four Pages Router scenarios from the upstream
test/e2e/app-dir/javascript-urls suite — Link `href`, Link `as`,
router.push, and router.replace — as e2e fixtures + a Playwright spec, plus
unit coverage for the shared Link shim's dangerous-click handler.

No production change is required: the Link shim already emits the canonical
"Next.js has blocked a javascript: URL as a security precaution."
console.error on click for both `href` and `as`, and the Pages Router
router.push/replace synchronous guard from #1575 already does the same. This
PR adds the missing Pages Router coverage that proves all four scenarios.

Closes #1576

* test(pages-router): suppress dev error overlay before safe navigation

The router.push/replace javascript: scenarios throw synchronously (the
#1575 behaviour that surfaces the console.error), which trips vinext's dev
error overlay. Its backdrop intercepted the follow-up safe-link click,
timing out the test even though the security block already passed. Suppress
the overlay via the existing disableDevErrorOverlay helper after asserting
the block, matching how other pages-router/app-router specs handle
intentional dev-time errors.

* test(pages-router): cover javascript: URL blocking in production build

The Pages Router javascript: URL guard (Link dangerous-click handler in
shims/link.tsx + router.push/replace synchronous guard in shims/router.ts,
both via shims/url-safety.ts) is already correct on main. The existing e2e
spec, however, only runs against the dev server (vp dev), while the Next.js
deploy suite exercises these scenarios against a production build where the
dev error overlay is absent and the client bundle is minified.

Add a pages-router-prod Playwright spec that re-runs all four upstream
scenarios (Link href, Link as, router.push, router.replace) against
vinext build + vinext start, so a prod-only regression is caught here.

Refs #1576
2026-06-08 21:38:10 +01:00
James Anderson 76a39cb467 fix(actions): return 404 for MPA action on app with no server actions (#1340) (#1853)
* fix(actions): return 404 for MPA action on app with no server actions (#1340)

A multipart form POST to an App Router page is always a server-action
attempt. When the form body decodes to no action at all (e.g. the build
has no server actions, so decodeAction returns null rather than
throwing), vinext fell through to a 200 page render instead of Next.js'
404 + x-nextjs-action-not-found. The fetch-action variant (via the
Next-Action header) already worked; only the MPA/no-JS form-POST variant
was wrong.

Gate the new 404 on the posted-to route being a page: route handlers run
after action dispatch and legitimately accept raw multipart POSTs, so
they still fall through. The page-vs-route distinction uses the static
__loadPage / __loadRouteHandler markers, available before lazy module
hydration.

Finishes the no-server-actions MPA case of #1340 (error-boundary,
content-type, and unrecognized-action handling landed in #1386/#1668).

* test(actions): add MPA no-action integration test; skip redundant route match (#1340)

Address ask-bonk review on #1853:
- Add a dev-server integration test that POSTs multipart/form-data to a
  page (/about) and asserts 404 + x-nextjs-action-not-found, locking in
  the entry-side matchRoute + __loadPage/__loadRouteHandler classification
  end-to-end (previously only unit-tested by passing hasPageRoute directly).
- Gate the progressive-action route match on isProgressiveServerActionRequest
  so matchRoute no longer runs on every App Router request — only for actual
  multipart/no-actionId POST candidates.
2026-06-08 21:30:41 +01:00
James Anderson 558ee4121e fix(app-router): scroll to top with hoisted children and loading.js (#1368) (#1857)
* fix(app-router): scroll to top with hoisted children and loading.js (#1368)

Move the committed scroll/focus target above the route loading Suspense
and handle route content whose first DOM node is hoisted into <head>, so
default-navigation autoscroll runs as a committed route effect rather than
relying on a post-navigation scrollTo fallback.

- loading.js: the scroll target stays mounted while the loading fallback
  renders (matching Next.js placing ScrollAndMaybeFocusHandler above the
  LoadingBoundary), so scroll fires against the loading boundary DOM.
- hoisted children: when findDOMNode resolves an element hoisted into
  <head> (e.g. <style precedence>), scroll the document to the top as the
  committed-effect equivalent of Next.js deferring to a higher layout-router.

Finishes the remaining scroll-to-top cases of #1368 on top of the
autoscroll parity landed in #1599-1602.

* docs(app-router): clarify prefetch shell omits scroll target (#1368)
2026-06-08 21:29:29 +01:00
James Anderson 0c7ff57710 fix(build): exclude Vite build manifests from Cloudflare asset uploads (#1850)
On Cloudflare Workers the ASSETS binding serves any uploaded file whose
path matches the request BEFORE the Worker runs. Cloudflare builds enable
`build.manifest` (and `ssrManifest` for Pages Router) so the worker entry
can compute lazy chunks, which writes Vite's manifests to
`dist/client/.vite/`. Because nothing excluded that directory from the
asset bundle, `/.vite/manifest.json` and `/.vite/ssr-manifest.json` were
publicly fetchable, leaking the full source-file -> chunk mapping
(including the paths of routes that are never linked from the UI).

The Node production server already blocks `/.vite/` explicitly
(server/static-file-cache.ts); this adds the Cloudflare-side equivalent by
writing a `.assetsignore` into `dist/client` during the cloudflare-build
closeBundle hook (right after the existing `_headers` generation, after the
manifests have been read). wrangler matches `.assetsignore` with gitignore
semantics, so the bare `.vite` entry excludes the directory and its
contents from upload, and the file is never served.

The merge logic preserves any user-authored `.assetsignore` and only
appends missing patterns, so it is idempotent across rebuilds. The writer
lives in a small typed helper (build/assets-ignore.ts) with focused unit
tests; a real Cloudflare build test asserts the wiring emits `.assetsignore`
with `.vite` while the manifest still exists on disk.
2026-06-08 19:47:57 +01:00
James Anderson cfc0f8146d fix(app-router): resolve explicit parallel slot with no page (#1535) (#1852)
* fix(app-router): resolve explicit parallel slot with no page (#1535)

When a parallel slot defines an explicit sub-page (e.g. `@slot/baz/page.tsx`)
but no corresponding children page (`baz/page.tsx`) and no `default.tsx` for
the children slot, vinext minted a synthetic `/baz` route with a null children
page. That route shadowed the sibling catch-all and rendered an empty children
prop, so the request hung (the upstream "explicit slot but no page" case timed
out).

Next.js serves such a slot-only sub-route's children from the nearest sibling
catch-all. Add `findCatchAllPage` and use it as the children fallback in
`discoverSlotSubRoutes` when no `default.tsx` exists, so `/baz` renders
`[...catchAll]/page.tsx` for children while `@slot/baz/page.tsx` fills the slot.
A children `default.tsx` still wins over the catch-all when both exist.

Finishes the explicit-slot-but-no-page case of #1535 (the children-slot
priority case landed in #1659).

Fixes #1535.

* test(app-router): document catch-all children params limitation (#1535)

Address ask-bonk review: the synthetic slot-only sub-route (e.g. /baz) has a
static URL pattern, so a catch-all children page reading params.catchAll gets
empty params (Next.js passes ["baz"]). Document the divergence inline in
discoverSlotSubRoutes and add a graph-level test locking in the current static
shape so a future param-population fix must update it.
2026-06-08 19:43:51 +01:00
James Anderson ca690db954 test(actions): cover server action POST under node-runtime middleware (#1480) (#1847)
* test(actions): cover server action POST under node-runtime middleware (#1480)

Adds regression coverage for cloudflare/vinext#1480: a server-action POST
must still be intercepted and executed when a node-runtime middleware matches
the action path and consumes the request body before falling through with
NextResponse.next().

The app-basic fixture's middleware now matches `/nextjs-compat/action-node-mw`
and reads the POST body (mirroring Next.js' `middleware-node.js`), with a bound
form server action on that page. Two tests exercise the scenario:

- tests/nextjs-compat/action-node-middleware.test.ts (dev inline-middleware path)
- tests/app-router-production-server.test.ts (production RSC entry path, the
  layer the upstream deploy suite exercises)

vinext already isolates the middleware's body branch from the downstream
request (applyAppMiddleware / createNextRequest clone the body) and treats
node-runtime middleware identically to edge middleware, so the action POST is
dispatched correctly. These tests lock that behavior in across the dev and
production request paths.

Closes #1480

* style(test): apply formatter to action-node-mw prod test

* test(actions): declare runtime:nodejs on fixture middleware for #1480

Address review: make the app-basic fixture middleware faithfully mirror
Next.js' middleware-node.js by exporting config.runtime = 'nodejs'. vinext
runs all middleware on Node regardless, so this is a behavioural no-op for the
other matcher entries, but it makes the action-node-mw regression a true guard
for the named node-runtime scenario from the issue title.

* docs(test): point #1480 fixture comment at middleware-runtime config logic

Address review nit: the config-reading logic (matcher-only, no runtime branch)
lives in server/middleware-runtime.ts, so reference that file where a future
runtime branch would land.
2026-06-08 19:42:30 +01:00
James Anderson 49d753217b fix(app-router): propagate "use cache" tags to route-handler ISR entries (#1453) (#1848)
* fix(app-router): propagate "use cache" tags to route-handler ISR entries (#1453)

* fix(app-router): bubble nested use-cache tags to parent on data cache HIT (#1453)
2026-06-08 19:42:07 +01:00
James Anderson 9b24683503 fix(build): don't crash on benign floating asset-import errors (#1510) (#1846) 2026-06-08 19:36:19 +01:00
James Anderson 600ba69b4d fix(routing): propagate middleware draft cookie to pages/api fallback (#1520) (#1845)
* fix(routing): propagate middleware draft cookie to pages/api fallback (#1520)

In a hybrid app (app/ + pages/api/*), requests with no matching App Router
route fall through to the Pages Router via renderPagesFallback. The bridge
forwarded middleware-mutated request headers and response headers, but dropped
the __prerender_bypass Set-Cookie emitted by draftMode().enable()/disable()
inside middleware — that cookie lives in a request-scoped side channel that
App Router route handlers and page renders read via getDraftModeCookieHeader(),
but the Pages fallback path never did.

Thread getDraftModeCookieHeader through the bridge and append the draft cookie
to the Pages Router fallback response, matching how App Router responses surface
it. Closes the draft-mode flow called out in #1520.

Closes #1520

* test(routing): cover draft-cookie fallback in app-pages-bridge unit test

Add getDraftModeCookieHeader to the renderPagesFallback dependency stub and
assert the draft cookie is appended (API + renderPage branches) and omitted
when middleware did not enable draft mode. Keeps the unit test in sync with
the new required dependency.

* docs(routing): document hybrid-dev draft-mode limitation in pages bridge

Address review feedback on #1845: clarify in app-pages-bridge that the
draft-cookie fallback covers production (inline RSC handler), while hybrid dev
runs middleware in a separate Vite Pages SSR runner where draftMode() inside
middleware is not yet permitted — a pre-existing limitation tracked separately
from #1520. The unit-test regression flagged by review was already fixed in
ae5978a4.
2026-06-08 19:34:28 +01:00
James Anderson 6958dfd0b4 fix(build): expand require.context into import.meta.glob map (#1501) (#1836)
* fix(build): expand require.context into import.meta.glob map (#1501)

* style: format require-context fixture page

* fix(build): strip g/y regex flags and default require.context recursive to true

Address ask-bonk review on #1836:
- Strip global/sticky flags when reconstructing the filter regexp; a
  stateful g/y RegExp.test() drops every other matching module.
- Default the omitted recursive arg to true to match Webpack's
  useSubdirectories default (was silently false).
- Add a g-flagged fixture/test case locking in the flag-stripping fix.
- Clarify the misleading descent comment in collectRequireContextCalls.

* test(require-context): cover ctx(key) retrieval and MODULE_NOT_FOUND

Address ask-bonk follow-up: lock in the runtime contract beyond .keys().
- Assert ctx('./parent/file1.js').default resolves the module namespace.
- Assert an unknown key throws with code 'MODULE_NOT_FOUND'.

* docs(require-context): document extensionless no-regex parity caveat

Address ask-bonk note: with no filter regexp, import.meta.glob only
surfaces resolvable modules, so extensionless keys Webpack would include
can be dropped. Documented as a low-risk divergence (upstream Next.js's
extensionless test is disabled/Turbopack-pending).
2026-06-08 19:33:34 +01:00
James Anderson 3f6c174c20 fix(app-router): resolve query-only Link href against current path (#1540) (#1844)
A <Link href={{ query: {...} }} /> with no `pathname` was resolved by
defaulting the base to "/", producing "/?query" instead of a query-only
href. The shallow navigation then recorded the wrong history entry (the
site root) so back/forward traversal landed on the wrong URL/page.

Mirror Next.js's formatUrl() (`pathname = urlObj.pathname || ''`): when
`pathname` is omitted, emit a relative query-only href (`?query`) so the
existing relative-href resolution applies it against the current path.

Adds a ported e2e regression (middleware-rewritten shallow link history
traversal) plus a Link unit test.

Closes #1540
2026-06-08 19:30:07 +01:00
James Anderson def033614b fix(app-router): render built-in fallback when global-error.tsx throws (#1548) (#1837)
* fix(app-router): render built-in fallback when global-error.tsx throws (#1548)

When a user's app/global-error.tsx itself throws while rendering, vinext
previously crashed the request with a raw 500. Next.js instead nests the
user's global-error inside an outer boundary whose fallback is the
built-in default global-error, so a broken boundary degrades to a usable
'This page couldn't load' document.

This change mirrors that structure:
- Client-side: wrap the user's global-error in a GlobalErrorBoundary whose
  fallback is the built-in DefaultGlobalError (matches Next.js's outer
  RootErrorBoundary errorComponent={DefaultGlobalError}).
- Server-side: React's renderToReadableStream rejects on a shell-level
  throw even with an error boundary present, so when the global-error
  boundary throws during SSR we re-render with the built-in default global
  error. Scoped to the global-error boundary (which owns the whole
  document); other boundaries propagate as before.

The original error message still reaches the boundary in dev and a digest
in prod via sanitizeErrorForClient (unchanged).

Adds nextjs-compat coverage (dev + production preview) ported from
test/e2e/app-dir/global-error/error-in-global-error.

Closes #1548

* fix(app-router): format TRACKING.md table and address review notes (#1548)

- Reflow TRACKING.md global-error table (vp check formatting).
- Document GlobalErrorBoundary's intentional lack of a resetKey.
- Add a port note explaining the SSR-via-pathname throw deviates from
  upstream's client useEffect throw (HTTP/SSR suite has no browser).

* test(app-router): unit-cover global-error server-side fallback retry (#1548)

Add a focused renderAppPageErrorBoundary unit test that asserts a throwing
global-error boundary re-renders with the built-in default global-error
(HTTP 200), locking in the try/catch retry directly rather than only via
the fixture-coupled integration test.

* fix(app-router): re-throw navigation signals from throwing global-error (#1548)

Narrow the server-side global-error fallback so it only degrades genuine
render failures to the built-in default. Navigation/HTTP-access signals
(redirect/notFound/forbidden/unauthorized) thrown from within global-error
are re-thrown so they reach their dedicated handlers instead of being
swallowed into a built-in 200. Addresses review feedback that the catch was
broader than Next.js's React error boundary.

Adds a unit test asserting a notFound() signal from global-error propagates.

* docs(app-router): clarify global-error signal re-throw comments (#1548)

Soften wording: re-thrown navigation signals propagate to the top-level
handler (same as before this change), rather than reaching dedicated
handlers from this position. No behavior change.
2026-06-08 19:27:03 +01:00
James Anderson 89a12bb203 fix(build): don't path-resolve bare specifier aliases for esmExternals (#1507) (#1843) 2026-06-08 19:21:57 +01:00
James Anderson 80acc0bbf0 fix(app-router): clear useLinkStatus pending after interrupted navigation (#1527) (#1839)
* fix(app-router): clear useLinkStatus pending after interrupting navigation (#1527)

* fix(app-router): suppress notify on Safari history replace fallback

The Safari coalescing fallback in syncCurrentHistoryStatePreviousNextUrl
called the patched window.history.replaceState, which fires
notifyLinkNavigationStart + commitClientNavigationState. On a URL-unchanged
metadata sync (refresh/traversal commit) that could clear an unrelated
<Link>'s useLinkStatus() pending state a few ms early on Safari. Route the
retry through replaceHistoryStateWithoutNotify so the browser sees the same
native replaceState without the patched-path side effects (#1527).
2026-06-08 19:20:42 +01:00
James Anderson de2ad5278f fix(app-router): respect reactMaxHeadersLength for preload Link header (#1552) (#1841)
* fix(app-router): respect reactMaxHeadersLength for preload Link header (#1552)

* test(app-router): cover reactMaxHeadersLength config resolution

* refactor(app-router): prioritize React preloads in Link header; cover buildAppPageLinkHeader

Address review feedback on #1841:
- Order React preload hints before font preloads so render-critical entries
  survive a tight reactMaxHeadersLength cap.
- Rename the buildAppPageHtmlResponse option to linkHeader (it now carries the
  combined header, not just fonts).
- Add unit coverage for buildAppPageLinkHeader (cap, 0-disables, ordering,
  whole-entry truncation).
2026-06-08 19:18:21 +01:00
James Anderson aea90aca05 fix(build): don't crash build when a PostCSS string plugin can't be resolved (#1509) (#1840)
* fix(build): don't crash build when a PostCSS string plugin can't be resolved (#1509)

resolvePostcssStringPlugins runs in the Vite config hook for every
environment (RSC/SSR/client). When a named PostCSS plugin failed to
resolve from the project root (e.g. a CSS-modules + PostCSS app whose
config lists a plugin that doesn't resolve from where vinext looks), the
thrown error was uncaught and aborted the whole build with an opaque
failure.

Wrap plugin resolution in try/catch and fall back to undefined so Vite's
own postcss-load-config resolves the config relative to the config file
instead of crashing.

Closes #1509

* docs(postcss): clarify whole-config fallback on resolution failure (#1509)
2026-06-08 19:15:50 +01:00
James Anderson c32cecdca8 fix(app-router): preserve _rsc query across redirects (#1529) (#1838)
* fix(app-router): preserve _rsc query across config redirects (#1529)

* refactor(prod-server): reuse rawQs for redirect query; clarify .rsc test comment

Address ask-bonk review on #1838:
- prod-server.ts: reuse the existing rawQs instead of re-slicing url for
  the redirect Location query (identical value, removes redundancy).
- app-rsc-handler.test.ts: tighten the .rsc-without-header test comment so
  it no longer overstates RSC re-detection — the guarantee is query
  preservation (mirroring Next.js), not re-arming RSC on the followed hop.
2026-06-08 19:14:41 +01:00
James Anderson a843ea2cfa fix(actions): enforce serverActions.bodySizeLimit on fetch actions (#1828) (#1834)
* fix(actions): enforce serverActions.bodySizeLimit on fetch actions (#1828)

Oversized fetch (client-invoked) server-action requests were rejected with
a bare 413 Payload Too Large response, which bypasses the client error
boundary and surfaces the wrong status/content-type. Next.js instead throws
the body-exceeded error before the action runs and, for fetch actions, emits
a 500 Flight response carrying the rejected action result so the nearest
error boundary catches it.

Mirror that: when an RSC fetch action body exceeds the configured limit
(content-length pre-check or stream read), render a Flight stream with
returnValue { ok: false } and no page root, returning status 500. The
no-JS progressive form path keeps its 413 (no client boundary to catch it).

Closes #1828

* fix(actions): use verbatim bodySizeLimit string in body-exceeded error (#1828)

Address bonk review: the body-exceeded error message must contain the
verbatim serverActions.bodySizeLimit config string (e.g. "2mb") to match
Next.js byte-for-byte — the upstream suites assert the log message literally
('Body exceeded 2mb limit'). The previous reconstruction from the parsed byte
count produced '2 MB', diverging on casing and spacing.

Thread serverActionsBodySizeLimitLabel (the verbatim config value, defaulting
to "1mb") through next-config -> index -> app-rsc-entry -> the RSC action
handler options, and use it directly in createBodyExceededError. Removes the
lossy formatBodySizeLimit reconstructor. Tests assert the verbatim string.

* fix(actions): default bodySizeLimit label to Next.js' '1 MB' literal (#1828)

Address bonk re-review nit: when serverActions.bodySizeLimit is unset, Next.js
emits 'Body exceeded 1 MB limit.' (its defaultBodySizeLimit = '1 MB' literal).
Default serverActionsBodySizeLimitLabel to '1 MB' (was '1mb') so the
unconfigured-case message matches upstream byte-for-byte too.
2026-06-08 19:13:16 +01:00
James Anderson 97947ce775 fix(app-router): apply trailingSlash to route handler request URL (#1827) (#1835)
* fix(app-router): apply trailingSlash to route handler request URL (#1827)

App Router route handlers received a NextRequest whose `url` was
normalized with the default `trailingSlash: false` policy, so a request
to `/runtime/edge/` (with `trailingSlash: true`) surfaced `req.url`
without the trailing slash even though `req.nextUrl.pathname` kept it.

Thread the configured `trailingSlash` through the route-handler dispatch
chain (dispatch -> execution/cache -> tracked request -> NextRequest)
so `NextURL` applies the correct policy when computing `request.url`,
matching Next.js (test/e2e/app-dir/app-routes-trailing-slash).

Closes #1827

* test(app-router): cover node runtime for trailingSlash:false route handler (#1827)

Address ask-bonk review note on PR #1835: the trailingSlash:false suite
only exercised the edge runtime. Parameterize it over edge + node to
match the trailingSlash:true suite's coverage.
2026-06-08 19:02:38 +01:00
Divanshu Chauhan (divkix) b4c829d6a2 feat(interception): sibling-style interception routes (#1364 Part C) (#1804)
* test(interception): add failing sibling-interception graph tests

* feat(routing): discover sibling-style interception markers

* feat(routing): emit sibling interception manifest facts

* feat(app-router): serialize sibling intercepts in route manifest

* feat(app-router): match sibling intercepts in route matcher

* feat(app-router): render sibling intercept as full page response

* test: add siblingIntercepts field to AppRoute test fixtures

* test(interception): add sibling interception fixtures

* fix(app-router): emit null proof for sibling intercepts to bypass slot-validation

* test(interception): add sibling interception e2e specs

* fix(interception): correct (...)→(..)(..) marker in interception-from-root fixture

* fix(interception): resolve sourceRouteIndex to slot-owner route in intercept lookup

When a route inherits a @slot from an ancestor via discoverInheritedParallelSlots,
the inherited slot's interceptingRoutes are copied from the ancestor. Previously,
createInterceptLookup used routeIndex (the inheriting descendant's index) as
sourceRouteIndex, causing resolveAppPageInterceptState to see sourceRoute ===
currentRoute → kind='current-route' instead of kind='source-route'.

Fix: build a patternToIndex map and resolve sourceRouteIndex via the intercept's
sourceMatchPattern (which names the actual slot-owner route), falling back to
routeIndex only when no match is found.

* fix(interception): use locale-prefixed href so middleware interception fires correctly

* fix(interception): address codex review issues in sibling intercept implementation

- Pass effectiveParams to resolveAppPageHead so generateMetadata/generateViewport
  sees the intercepted route's params instead of the source route's params
- Rename SIBLING_INTERCEPT_SLOT_NAME from __page to __vinext_sibling_intercept
  to prevent collision with a user @__page parallel route directory
- Restore middleware-rewrite fixture link without locale prefix so the
  interception-dynamic-segment-middleware spec exercises the intended
  middleware rewrite path

* fix(test): update slotId assertions and restore locale-prefixed fixture link

- Update test assertions from slot:__page to slot:__vinext_sibling_intercept
  following the rename in commit 2248b8fe
- Restore locale-prefixed href in interception-mw fixture so the
  interception-dynamic-segment-middleware E2E tests pass (reverts the
  accidental revert from 2248b8fe back to the working state from 02e80c26)

* fix(interception): address review findings on sibling interception PR

- Restore locale-less href in interception-mw fixture and fix the
  underlying middleware-rewrite interception gap: add a Referer-based
  fallback in app-rsc-handler so interception fires when middleware
  rewrites the URL before the server matches it (the client-side manifest
  check sees the pre-rewrite target and skips setting the interception
  context header; the Referer carries the source page the server needs)
- Apply interceptLayouts for sibling intercepts in buildPageElements so
  a layout.tsx under the interception marker dir wraps the intercepting
  page, matching Next.js segment-layout wrapping behaviour; add fixture
  layout and E2E assertion
- Add unit test proving findOwnerRouteForDir ancestor walk attaches a
  sibling intercept to the nearest ancestor route when the marker's
  parent dir has no page.tsx (not dropped silently)

* fix(interception): send current pathname as fallback interception context

The client-side manifest check compares the pre-middleware target URL
against declared interception patterns. When middleware rewrites the URL
(e.g. /foo/p/1 → /en/foo/p/1), the pre-rewrite URL has fewer segments
and doesn't match the pattern, so no X-Vinext-Interception-Context
header is sent and the server cannot fire interception.

Fix: when the manifest check yields no match and there is no prior
intercepted navigation in browser state, fall back to sending the
current page pathname as the interception context. This mirrors Next.js
sending Next-URL on every RSC navigation. The server's findIntercept is
double-gated on both source and target matching, so false positives
cannot occur. The previousNextUrl is also populated so back/forward
traversal can restore intercepted state.

Revert the Referer-based server-side fallback (unreliable across
environments) in favour of this explicit browser-side fix.

* fix(interception): gate middleware-rewrite fallback on declared source pattern

The previous fix sent window.location.pathname as fallback interception
context on every unmatched navigation, which broke prefetch cache reuse:
a prefetch stored with null context wouldn't be found when the navigation
used a non-null context key.

Refine the fallback to only fire when the current page URL matches at
least one sourcePatternParts declared in the route manifest — i.e., the
current page is a known interception source. This preserves prefetch
cache reuse for ordinary navigations while still enabling middleware-
rewrite interception for pages that declare an interception relationship.

* fix(interception): support middleware-rewrite targets in fallback context and planner

The previous fallback-context gate only checked whether the current page
is a declared interception source; it did not verify the target URL
could plausibly be a middleware-rewritten version of the declared target
pattern.  This meant the client sent interception context when it
shouldn't (pre-middleware target had no static anchors in common with the
pattern) and the planner hard-navigated when the proof target matched
only after the rewrite.

Three coordinated fixes:

1. Client fallback gate (app-browser-entry / app-browser-interception-context):
   replace the broad source-only check with matchRoutePatternWithOptionalDynamicSegments
   on the target side — the pre-middleware URL must still share every
   static anchor of the declared target pattern, in order, while allowing
   dynamic segments to be absent.

2. Planner interception proof (navigation-planner):
   findRouteManifestInterceptionForProof now accepts a proof whose target
   matched URL equals the pre-rewrite canonical pathname (shorter than the
   full pattern) when matchRoutePatternWithOptionalDynamicSegments passes.
   Exact pattern match still skips the targetRouteId guard; the relaxed
   path skips it too so the middleware-added locale segment does not
   invalidate an otherwise valid proof.

3. displayPathname threading (app-rsc-handler / app-rsc-entry / app-page-element-builder):
   the handler now passes canonicalPathname as displayPathname separately
   from cleanPathname.  buildPageElements uses cleanPathname for all
   internal routing work (slot param extraction, slot overrides) and
   displayPathname only for render identity — keeping the browser-visible
   URL in RSC metadata without affecting internal matching.

* fix(interception): guard siblingInterceptElement construction and document reserved slot name

* fix(interception): surface missing default export on sibling intercept pages

A sibling-style interception whose intercepting page module is missing
its `default` export previously fell back to the source route's page
component (`effectivePageModule?.default ?? PageComponent`), silently
rendering a *different* page than the one requested.

Resolve the effective component from the intercepting page only and
extend the existing no-default-export guard to cover sibling intercepts,
so a missing export surfaces the same explicit "Page has no default
export" error the source/slot paths already produce. For a normal
request this is identical to the previous behavior since
`effectivePageModule === pageModule`.

Addresses bonk review feedback on #1804.

* test(app-router): guard canonical pathname commit on soft-nav config rewrites

The `displayPathname = canonicalPathname` change feeds the RSC payload
identity the client planner uses to commit a navigation, so it affects
every rewritten route, not just interceptions. Existing coverage
(nextjs-compat/hooks.test.ts) only exercises the hard-nav (SSR) path.

Add an e2e that soft-navigates (client-side) to an afterFiles-rewritten
route and asserts the committed URL and `usePathname()` stay canonical
(pre-rewrite), with a marker check proving no full page reload occurred.

Addresses bonk review feedback on #1804.

* fix(interception): restore __pageLoader on slot intercept lookup entries

The main merge auto-resolved incorrectly: it moved the `__pageLoader`
field that #1738 added to the *slot* intercept push block onto the new
*sibling* push block instead (the two `interceptLookup.push({...})` calls
are textually similar). As a result slot intercept lookup entries lost
`__pageLoader`, so resolveAppPageInterceptState never loaded the lazy
intercepting page (`page` stayed null) and the modal slot fell back to
default.tsx — breaking every slot interception.

Restore `__pageLoader: intercept.__pageLoader` on the slot push block.
The sibling block already carries it (see merge commit), and both
intercept types declare the optional field.

Fixes the app-router-dev-server intercept tests and the interception
e2e suites that regressed after merging main.

---------

Co-authored-by: James Anderson <james@eli.cx>
2026-06-08 19:01:29 +01:00
Nathan Nguyen 6005541c19 fix(app-router): restore scroll snapshots on back (#1743)
* fix(app-router): restore scroll snapshots on back

* fix(app-router): invalidate scroll snapshots with bfcache epoch

* fix(app-router): guard synchronous popstate scroll restore

* fix(app-router): strip basePath for snapshot restore targets

* fix(app-router): canonicalize snapshot restore target search

* docs: document sync-over-async invariant in popstate snapshot restore

* docs: document cross-module ordering contracts per review

Adds comments for three load-bearing invariants identified in review:
- rememberHistoryStateSnapshot layout effect ordering
- synchronousPopstateScrollRestoreNavigationId single-slot latch
- restoredBfcacheIds/reuseCurrentBfcacheIds snapshot timing
2026-06-08 12:33:12 +01:00
Nathan Nguyen 5dd7a34eb6 fix(app-router): isolate page CSS chunks in production (#1738)
* fix(app-router): isolate production page CSS chunks

Production App Router builds statically imported every page module into the generated RSC manifest. That let Vite/Rolldown concatenate page-level global CSS from sibling routes into shared CSS assets, so a sibling hash page with scroll-padding-top changed native hash scroll offsets on unrelated pages.

The manifest now emits cached dynamic loaders for page modules and loads the matched page module at the route dispatch boundary. Page generateStaticParams sources use the same loader with an explicit missing-export sentinel so prerender still distinguishes absent exports from malformed results.

* test(app-router): add focused unit test for route module loader

Address the non-blocking review note from PR #1738: per AGENTS.md, behavior
moved out of a template string should get a focused helper test. Cover
createRouteModuleLoader (promise dedup, rejection propagation),
loadRouteModules (page assignment, concurrent dedup, no-op cases, missing
`page` key) and loadRouteMatch (null short-circuit, match load).
Also cover createLazyGenerateStaticParamsSource (delegation, missing
sentinel for null/empty/non-function exports).

Document the rejected-load cache intent and the per-request intercept copy
in the WeakMap so future readers don't try to "fix" them as bugs.

* test: stabilize dev overlay HMR recovery

* chore: retrigger ci

* chore: fix knip check config

* test(app-router): guard intercepted modal CSS + load lazy page before probe

Adds the positive-direction guard the bonk review asked for: navigating
/feed -> intercepted /photos/[id] must load the lazy modal page and emit
its scroll-padding-top CSS chunk. The existing test only proved the
negative (CSS absent on a direct /feed visit), so a regression where the
lazy modal page failed to load its CSS would have passed silently.

Also fixes the probePage() intercept branch, which read intercept.page
without resolving the now-lazy __pageLoader. The render path awaits the
loader (resolveAppPageInterceptState), but the probe path did not, so the
dynamic-bailout probe silently inspected an undefined component and never
observed the intercept page's searchParams/headers access. Resolve the
lazy page before probing to match the render path.

---------

Co-authored-by: James <james@eli.cx>
2026-06-08 12:16:08 +01:00
James Anderson 6fa25f49ce docs(changelog): backfill vinext release notes from GitHub releases (#1822)
* docs(changelog): backfill vinext release notes from GitHub releases

Backfill packages/vinext/CHANGELOG.md with entries for every published
GitHub release from v0.0.27 through v0.0.55. Previously the changelog
only contained the 0.1.0 entry (the first changeset-driven release);
all prior release notes lived only on GitHub Releases.

Content is taken verbatim from each GitHub Release body, with headings
normalized to fit the changelog (drop the redundant 'What's Changed'
h2, demote 'Contributors' to h3) and bullets normalized to '-'.

* docs(changelog): use h4 subgroups in 0.0.54 for consistency

The 0.0.54 entry used bold (**App Router**) pseudo-headings under
Bug Fixes, while every other release uses #### h4 sub-headings.
Convert them to h4 to match the rest of the changelog.
2026-06-08 11:10:44 +01:00
github-actions[bot] 2b2ed00f22 chore: version packages (#1759)
* chore: version packages

* add header

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: James <james@eli.cx>
@vinext/cloudflare@0.1.0 vinext@0.1.0
2026-06-08 09:45:43 +00:00
James Anderson a74f833a10 feat(deploy): honor Worker-entry cache setters for ISR deploys (#1821)
Older apps that wired a cache backend imperatively in their Worker entry
(setCacheHandler / setDataCacheHandler / setCdnCacheAdapter) were blocked
on deploy because the ISR cache-adapter check only inspected the Vite
config. Add workerEntryHasCacheHandler() so those backwards-compatible
entries are still considered configured.

Also drop the cdn adapter suggestion from formatMissingCacheAdapterError;
the message now points only to the kvDataAdapter() data cache.
2026-06-08 10:39:46 +01:00
James Anderson 6f689c7b14 chore: rebuild lockfile (#1815) 2026-06-07 22:16:13 +00:00
Nathan Nguyen cda19e1c58 fix(app-router): preserve recent segment state with Activity BFCache (#1739)
* fix(app-router): preserve recent segment state with Activity BFCache

Fresh App Router navigations were reusing the active React tree when moving between dynamic segment values. That leaked client state into a newly entered segment and made the upstream back-forward-cache suite fail before it even reached browser traversal assertions.

The root cause was that vinext had history-entry bfcacheId plumbing but no separate segment state-key cache. Next.js treats those identities separately: render state is keyed by segment identity/pathname, while useRouter().bfcacheId identifies a concrete history entry.

Add state-key maps for app router slots, render retained entries through React.Activity when cacheComponents is enabled, keep the active one-entry keyed reset path otherwise, and port focused Next.js regression coverage for the three-entry BFCache behavior.

* fix(bfcache): store stateKeyMap in retained slot entries and re-provide on render

Review feedback requested two changes:

1. Add stateKeyMap to BfcacheSlotEntry so each retained BFCache entry carries
the state-key map that belonged to it when active, rather than borrowing the
current navigation's map. This prevents nested hidden entries from being rendered
with old elements but new pathname-derived state keys — an identity corruption
bug that upstream Next.js avoids by rendering each retained entry from its own
state.

2. Add a nested dynamic regression test: /nested/[section]/item/[id] with client
counters in both the [section] layout and [id] page. The existing flat /page/[n]
tests do not exercise nested retained-entry identity, which is exactly where the
missing state-key-map bug lives.

Changes:
- BfcacheSlotEntry gains stateKeyMap?: Readonly<Record<string, string>>
- updateBfcacheSlotEntries accepts and stores an optional stateKeyMap argument
- BfcacheSlotBoundary stores the current stateKeyMap alongside elements when
caching the active entry
- Both the Activity visible/hidden path and the non-cacheComponents single-entry
path now wrap rendered entries in BfcacheStateKeyMapContext.Provider using the
entry's own stateKeyMap (falling back to current map for forward compatibility)
- New e2e test verifies nested layout + page counter preservation across
segment navigations

* refactor(bfcache): extract shared provider stack into BfcacheEntryProviders

BfcacheSlotBoundary rendered the BfcacheStateKeyMapContext / ElementsContext /
SegmentContext provider stack in two places: the one-entry keyed reset path and
the per-entry Activity retention list. The two stacks were identical except for
where the React key sat, so the retained-entry bookkeeping and its render shape
were tangled together and harder to audit than needed.

Extract BfcacheEntryProviders as a pure presentational helper and render it from
both paths. The keyed-reset path keeps its remount semantics: the key now sits on
the BfcacheEntryProviders element, so a fresh active stateKey still remounts the
whole provider subtree.

Hook ordering is unchanged. All hooks stay above the early returns in
BfcacheSlotBoundary, so the boundary still satisfies the Rules of Hooks across the
activeStateKey undefined-to-defined transition. Covered by the slot BFCache
entry-state suite in tests/shims.test.ts.

* test(bfcache): isolate cacheComponents BFCache coverage in a dedicated fixture

The back/forward-cache e2e suite ran against the shared app-basic fixture with
cacheComponents: true. app-basic backs the single app-router Playwright server
that every app-router spec uses, so enabling cacheComponents globally changed a
core DOM invariant for unrelated tests: inactive route trees stay mounted as
hidden Activity DOM. The contamination already leaked a :visible workaround into
the interception-modal bfcacheId spec, which had nothing to do with Activity.

cacheComponents is an opt-in semantic mode, not default app-router behaviour, so
it does not belong on the shared baseline fixture. Move the BFCache routes into a
dedicated tests/fixtures/app-bfcache fixture with cacheComponents: true, served by
a new app-router-bfcache Playwright project on port 4183, and run the
back-forward-cache spec there against the project baseURL. The :visible locators
stay in that spec, where duplicate hidden DOM is the expected contract.

Remove cacheComponents and the BFCache routes from app-basic so the shared fixture
models default behaviour again, and revert the :visible workaround in
use-router-bfcache-id.spec.ts now that the modal route no longer retains hidden
DOM. Fix the nested item page import while moving it: nested/[section]/item/[id]/
page.tsx imported ../counter, but the counter lives two levels up at
nested/[section]/counter.tsx, so it must be ../../counter.

Validation: PLAYWRIGHT_PROJECT=app-router-bfcache (5 passed) and the full
use-router-bfcache-id spec under app-router (11 passed), plus the slot BFCache
unit suite (1172 passed) and vp check (formatting + lint + types clean).

* ci: run the app-router-bfcache e2e project in the matrix

The dedicated app-router-bfcache Playwright project was added to
playwright.config.ts but not to the CI E2E matrix, so the back/forward-cache
specs never ran in CI. Add it to the matrix project list so the BFCache suite
runs on every push. It uses the default chromium browser, so the existing setup
and browser-cache steps apply unchanged.

* fix(bfcache): keep persisted slots mounted when cacheComponents is off

Without cacheComponents there is no Activity retention, yet BfcacheSlotBoundary
still keyed the active entry by its segment stateKey. That stateKey tracks the
pathname (createBfcacheSegmentIdentity), so any slot whose identity moves with
the URL (shared layouts, interception source slots) was remounted on navigation,
discarding client state that survives a normal route change. The baseline router
reconciles these slots in place.

This regressed seven existing app-router e2e tests once cacheComponents was no
longer masking it on the shared fixture: interception source-context and modal
preservation in advanced.spec.ts, and layout-persistence.spec.ts dynamic-segment
layout counter survival. Fresh-entry reset is driven by userland bfcacheId keying
in the fixtures, not by remounting the slot subtree, so the keying was pure
overreach in the non-cacheComponents path.

Render the single active entry unkeyed when cacheComponents is off so the boundary
reconciles in place like the baseline. The keyed Activity retention path is
unchanged for cacheComponents.

Validation: full app-router project 436 passed / 0 failed (the seven regressions
now pass); use-router-bfcache-id 11 passed (fresh-push reset intact); app-bfcache
5 passed (Activity retention intact); shims unit suite 1024 passed.

* docs(test): point bfcacheId spec comment at the app-router-bfcache project

The header noted that Activity-backed preservation is covered in
back-forward-cache.spec.ts, but that spec moved to the dedicated app-bfcache
fixture under the app-router-bfcache project. Name the project and path so the
cross-reference stays accurate.

* docs: align cacheComponents comment with unkeyed active-segment behavior

* test(bfcache): assert fresh bfcacheId after Activity-restored return

* style: collapse locator chains per oxfmt

* refactor(bfcache): split slot entry order from snapshots

* fix(bfcache): keep non-cacheComponents slot path baseline

* fix(bfcache): use string cacheComponents define

* test: remove external fetch from revalidate tag fixture

* fix(bfcache): harden Activity state key retention

* docs(bfcache): document cacheComponents Activity caveats

* fix(bfcache): preserve navigation context for fallback SSR

Fallback boundary rendering can outlive the original request-scoped navigation context. That made app SSR fail fast with a missing navigation context when loading-boundary digest routes rendered their HTTP fallback shell.

The SSR boundary contract still requires a navigation context for real request renders, but boundary renderers now synthesize the same typed shape from the request URL and matched params when request scope has already been cleared.

* refactor(bfcache): normalize pathname in state-key map, document invariant callers

Moves hydration-pathname normalization into createBfcacheSegmentStateKeyMap so SSR and client always produce byte-identical Activity keys. Documents the requireNavigationContext guaranteeing callers per review feedback.

* fix(bfcache): tolerate malformed state-key pathnames

* docs(bfcache): document known Next.js divergence and dead-branch contract

* chore(deps): update lockfile for vite 0.1.24

* fix(app-router): preserve encoded delimiters in bfcache keys

* docs(bfcache): track router-owned retention follow-up

* chore: retrigger ci

* fix(app-router): preserve redirect bridge fallback
2026-06-07 23:07:25 +01:00
Nathan Nguyen 1c1744e0ea feat(ppr): add PPR fallback-shell render lifecycle tests (#1715)
* feat(ppr): add fallback shell render lifecycle

* fix(ppr): address review feedback on PR #1715

- Make await params resolvable during fallback-shell prerendering by keeping
the base promise as Promise.resolve(plain) and only throwing the hanging
promise on sync fallback-key access.

- Defer createPprFallbackShellSuspensePromise to first fallback-key access so
hasDynamicBoundary and cache-task ignoring only fire when a fallback param is
actually read, matching the suspend only on access contract.

- Store and clear pendingAbortCleanup in preparePprFallbackShellFinalRender
to avoid leaking uncancellable abort timers.

- Add tests for await params resolving known params and for lazy dynamic-boundary
marking when only non-fallback params are accessed.

* fix(ppr): make awaited params preserve fallback-key suspension and getOwnPropertyDescriptor fully lazy

- Promise now resolves to a Proxy (resolvedParams) instead of raw plain.
  The inner Proxy throws a hanging promise when a fallback param is accessed
  after await, so `const resolved = await params; resolved.slug` suspends.

- Both outer and inner Proxy getOwnPropertyDescriptor traps return a getter
  that creates the hanging promise lazily. Object.keys(params) no longer
  triggers side effects merely by enumerating keys.

- Added tests for awaited-params suspension and for enumeration not marking
  hasDynamicBoundary eagerly.

* fix(ppr): restructure thenable-params with typed Proxy handlers and conditional observer semantics

- Extract createResolvedParamsProxy with ProxyHandler<T> (no cast needed)
- Extract createThenableParamsProxy with single as ThenableParams<T> boundary cast
- Remove all as unknown as double casts
- In fallback-shell mode, Promise continuations no longer observe all keys;
  let the resolved params proxy report actual property access lazily
- Fix getOwnPropertyDescriptor to return throwing getter instead of
  throwing synchronously, preventing Object.keys from triggering suspension
- Restructure awaited-params test to wrap entire async flow in ALS scope
- Add test: awaiting params during fallback-shell observes only accessed known params

* test(ppr): assert hasDynamicBoundary stays false after known-param access in fallback-shell observer test

* fix(ppr): suspend escaped fallback params and settle warmup cache-ready without in-scope task

Addresses bonk review on #1715:

- Capture the originating fallback-shell state at params construction and
  create the suspense promise against it (via the new
  createPprFallbackShellSuspensePromiseForState), instead of re-reading the
  ALS store lazily. A params object that escapes the shell scope now still
  suspends on fallback-key access rather than leaking the raw `[slug]`
  placeholder, and the `get` / `getOwnPropertyDescriptor` traps no longer
  diverge on the out-of-scope path.
- During warmup, always call scheduleCacheReadyIfSettled when a dynamic
  boundary is hit (not only in the final phase), so a bare headers()/cookies()
  access with an empty cache-task stack still re-schedules the warmup
  cache-ready settle. The call is a no-op while cache tasks are pending.

Adds focused tests for both paths.

* fix(ppr): keep cache-task counter and fallback suspension correct across phase transition

Addresses follow-up bonk review on #1715:

- Add a per-state cache epoch, bumped on the warmup->final transition. Cache
  tasks capture the epoch at creation; a task that settles in a later epoch no
  longer decrements the freshly-reset pendingCacheTasks counter below zero
  (which would permanently block resolveCacheReadyIfSettled).
- Re-derive the memoized fallback-shell hanging promise when the live
  AbortController changes (preparePprFallbackShellFinalRender swaps it on the
  phase transition), so suspension always tracks the controller the lifecycle
  will actually abort instead of a stale warmup signal.

Adds tests for the late warmup-task settle and the post-transition re-derive.

---------

Co-authored-by: James Anderson <james@eli.cx>
2026-06-07 22:55:26 +01:00
Nathan Nguyen 7d280edfbe refactor(server): replace prerender HTML buffering with metadataReady contract (#1809)
* refactor(server): replace prerender HTML buffering with metadataReady contract

* fix(server): wait for allReady before piping through tick-buffered transform when waitForAllReady is true

* refactor(server): clean up AppSsrRenderResult normalization and metadataReady promise resolution

* test(server): cover ISR revalidation waitForAllReady; address review nits

- Add focused test asserting stale HTML cache regeneration passes
  waitForAllReady: true to handleSsr so Suspense fallbacks never leak
  into the cache entry
- Document why metadataReady resolves eagerly (allReady is already
  awaited for the prerender path) in app-ssr-entry.ts
- Remove stray blank line left by the readStreamAsText import removal
- Type capturedOnError in the prerender special-error test instead of any

* fix(pages): export PagesPageModule type consumed by pages-page-handler

The dts bundle build (vp pack) failed because pages-page-handler.ts
imports PagesPageModule from pages-page-data.js, but the type was not
exported, so rolldown-plugin-dts could not resolve it.

---------

Co-authored-by: James <james@eli.cx>
2026-06-07 21:27:23 +00:00
Divanshu Chauhan (divkix) d9a5e63c32 refactor(pages): extract render orchestrator from codegen string (#1784) (#1805)
* refactor(pages): extract render orchestrator from codegen string (#1784)

Move `_renderPage` (~260 lines) and `collectAssetTags` (~85 lines) out
of the generated template string in `entries/pages-server-entry.ts` into
typed, unit-testable server modules:

- `server/pages-page-handler.ts` — `createPagesPageHandler` factory that
  owns the full Pages Router render lifecycle (i18n, route matching,
  404/500 fallback recursion, _next/data envelope, 405 method check,
  ISR cache-key derivation, SSR-context setup). Mirrors how
  `server/app-rsc-handler.ts` is structured relative to
  `entries/app-rsc-entry.ts`.

- `server/pages-asset-tags.ts` — `collectAssetTags` + manifest helpers,
  extracted from the template so they can be unit-tested directly.

- `server/pages-data-route.ts` — adds `normalizePagesDataRequest` so both
  `renderPage` and `runMiddleware` share one implementation instead of
  duplicating the buildId-check logic in the template string.

`entries/pages-server-entry.ts` is now thin wiring: route-table codegen,
embedded config/i18n/buildId, and closures that delegate to the handler.
`renderPage` keeps its exact exported signature; all callers are unaffected.

No behavior change — pure code motion. Fixed a latent bug where the
`isrCacheKey` wrapper in the handler was missing the `buildId` argument on
non-i18n paths (causing a cache-key mismatch); caught by the existing
ISR CSP-nonce test.

* refactor(pages): tighten handler types and improve tests

- Use ComponentType from react for AppComponent, DocumentComponent, and
  PageComponent closures instead of unknown — matches pages-page-response.ts
  which already uses ComponentType. Eliminates the DocumentComponent cast.

- Widen isrCacheKey router param from literal union to string — the literal
  union forced as-casts everywhere it was used indirectly. All call sites
  pass 'pages' or 'app' string literals so the widening is safe.

- Replace the shallow i18n redirect test (which just checked a route miss
  gave 404) with a test that actually exercises the 307 short-circuit:
  visiting / with Accept-Language: fr triggers resolvePagesI18nRequest to
  return redirectUrl and the handler returns 307 to /fr.

- Add second i18n test: locale prefix already present → no redirect.

- Tighten 'allows POST with gSSP' test: assert no Allow header (confirms
  the 405 guard was bypassed, not just that some non-405 came back).
2026-06-07 22:18:12 +01:00
Nathan Nguyen 340da2dd19 refactor(server): extract pages fallback bridge into typed helper (#1808)
* refactor(server): extract pages fallback bridge into typed helper

* test(app-router): update pages fallback codegen assertion

* refactor(server): tighten buildRequestHeaders type to Headers | null

The injected buildRequestHeaders dependency wires in
buildRequestHeadersFromMiddlewareResponse, which returns Headers | null,
but the dependency type declared a non-nullable Headers return. Tighten
the type to match reality and add a test exercising the null path where
the original request is forwarded unchanged.

---------

Co-authored-by: James <james@eli.cx>
2026-06-07 22:04:54 +01:00
James Anderson 465e6c76c1 fix(build): share one RSC compatibility ID across all plugin instances (#1814)
Follow-up to #1810. createRscCompatibilityId() falls back to a fresh
randomUUID() per plugin instance when no deploymentId is pinned, so a hybrid
app+pages build baked two different RSC-compat tokens — the App Router
buildApp() instance and the separate Pages Router vite.build() instance each
minted their own. The token gates client-side RSC payload acceptance (the
X-Vinext-RSC-Compatibility-Id header), so cross-instance divergence is a
latent deploy-skew hazard.

Coordinate it the same way as the build ID: relocate createRscCompatibilityId
to config/next-config.ts (single source of truth, beside resolveBuildId /
resolveDeploymentId), have the CLI resolve it once and publish it via
__VINEXT_SHARED_RSC_COMPATIBILITY_ID, and have the plugin always adopt it when
set. Reuses deploymentId when configured (already stable). The env var is only
ever set by the build CLI, so dev and standalone resolution are unchanged.

Adds a coordination test asserting the shared token lands in both the server
and client output.
2026-06-07 22:02:39 +01:00
Nathan Nguyen 9bab92f012 test(deploy): cover generated worker package exports (#1807)
Generated worker entries can import vinext subpaths that must resolve from the published package. Missing package exports break consumers even when the generated template itself looks correct.

Add deploy coverage that extracts vinext subpath imports from generated App and Pages worker entries and verifies each one is covered by packages/vinext/package.json exports.

Tighten the prefer-shared-utils lint rule so copied shared helpers in repo tests are reported too. Test files mask template literal fixture payloads, while source files still scan generated-source templates.
2026-06-07 21:58:41 +01:00
Nathan Nguyen aab9240e10 fix(app-router): hard navigate streamed redirects (#1742)
* fix(app-router): hard navigate streamed redirects

* fix(navigation): guard page redirect marker checks

* test(app-router): count redirect document loads
2026-06-07 21:55:56 +01:00
James Anderson e7757e48b7 ci(bigbonk): reduce job timeout to 10 minutes (#1813) 2026-06-07 20:53:20 +00:00
Nathan Nguyen 9a721ca287 chore(deps): bump knip and node types (#1806)
* chore(deps): bump knip and node types

Bump Knip to 6.16.0 and @types/node to 25.9.2.

Knip 6.16 reports previously unreported unused exported types, stale ignore entries, and external CLI binaries. Remove unnecessary type exports, refine the script entry coverage, and keep only narrow binary ignores for system or user-project commands.

Validation: vp run knip; vp run check.

* chore: retrigger ci
2026-06-07 21:45:14 +01:00
James Anderson cc246f5db6 fix(build): write BUILD_ID via writeBundle so App Router builds emit it (#1810)
* fix(build): write BUILD_ID via writeBundle so App Router builds emit it

The vinext:build-id plugin used closeBundle, which does not fire during
the multi-environment createBuilder().buildApp() pipeline used for App
Router production builds. As a result dist/server/BUILD_ID was silently
never written for pure App Router apps — only hybrid apps (which run a
second vite.build() pass) and Pages Router apps got the file.

This was masked until server bundles started being minified by default
(#1777): the e2e deploy harness (scripts/e2e-deploy.sh) falls back to
regex-parsing the buildId out of dist/server/index.js when BUILD_ID is
absent, and minification mangles the `get buildId()` / `buildId = "..."`
patterns the regex relies on. The result was ~700 App Router deploy-suite
tests failing at setup with "Failed to extract build ID from
dist/server/index.js".

Switch the plugin to writeBundle (mirroring the working vinext:image-config
plugin), which fires for every emitted bundle; the existing one-time write
guard ensures the file is written exactly once. Add a regression assertion
to the App Router production build test.

* fix(build): share one build ID across all plugin instances in a build

A single `vinext build` can instantiate vinext() more than once: the App
Router multi-environment build (createBuilder().buildApp()) and the separate
Pages Router SSR build for hybrid app+pages apps are distinct plugin
instances. With no user generateBuildId, each instance resolved its own
random UUID, so the App Router runtime, the Pages Router runtime, the
prerender manifest, and dist/server/BUILD_ID could each get a different
build ID — risking ISR/seed-cache key mismatches for hybrid apps.

The CLI now resolves the build ID once (honoring the user's generateBuildId)
and publishes it via __VINEXT_SHARED_BUILD_ID. The plugin adopts it unless
the user supplied their own generateBuildId (which is authoritative and
already shared because every instance calls it). resolveBuildId()'s
standalone semantics are unchanged, so the build-only coordination never
leaks into dev or tests.

Verified on the hybrid app-router-cloudflare example: the App Router RSC/SSR
runtime, Pages entry, prerender manifest, and BUILD_ID file now all carry the
same build ID. Adds a coordination test alongside the BUILD_ID emission guard.

* fix(build): always adopt shared build ID, even with generateBuildId

Addresses review on #1810. The previous `!rawConfig?.generateBuildId` guard
reintroduced the divergence it was meant to fix: when generateBuildId returns
null (documented Next.js behavior) or is non-deterministic, resolveBuildId()
mints a fresh random UUID per plugin instance, so a hybrid app+pages build
still got divergent IDs across the buildApp() and Pages vite.build() instances.

The CLI's resolvedNextConfig.buildId is already the fully-resolved authoritative
value (it ran resolveBuildId honoring generateBuildId, including the null→UUID
fallback), so the plugin now always adopts the shared ID when set. Add a
regression test using generateBuildId: () => null + a shared ID, and document
that the CLI intentionally does not clear the env var (the build process exits).
2026-06-07 11:27:26 +01:00
James Anderson fe731b4827 test(e2e): mark flaky dev-overlay build-error HMR test as fixme (#1812)
The app-router dev error overlay test "server component HMR surfaces
Vite build errors after a clean load" is flaky in CI: after writing the
clean file back, the build-error overlay intermittently stays visible
past the 10s timeout. Mark it test.fixme to unblock CI while the
recovery path is investigated.

Tracked in #1811.
2026-06-07 10:14:55 +00:00
James Anderson 5db85cb6eb perf(og): dedupe resvg/yoga wasm in server bundle (#1801)
* fix(og): dedupe resvg/yoga wasm in server bundle

vinext shipped each @vercel/og WASM module twice — once per loader
strategy — even though only one strategy runs at runtime, wasting
~1.4 MiB per build.

resvg.wasm was emitted as a hashed asset by the `import("./resvg.wasm?module")`
path (used by workerd) AND copied as a second byte-identical root file
purely for the Node.js disk-read fallback. yoga.wasm was emitted as a
hashed asset AND inlined as a ~95 KiB base64 blob in index.edge for the
Node.js fallback.

This change makes each WASM ship exactly once, with every loader path
resolving to the single emitted file:

- og-font-patch (index.ts): drop the yoga base64 preamble and switch the
  Node.js yoga fallback to a disk read (node:fs + new URL), mirroring resvg.
- og-assets.ts: in generateBundle, when the bundler already emitted the
  WASM asset, rewrite the `new URL("./x.wasm", import.meta.url)` fallback
  reference to point at the emitted sibling asset; only copy a root file in
  writeBundle when no asset was emitted.

The dedup decision keys off whether the bundler emitted the asset, never
the deploy target, so it benefits Node/self-hosted builds too. No platform
detection.

* fix(og): keep chunk sourcemap in sync on wasm fallback rewrite

Use magic-string (already a dependency) for the generateBundle fallback-URL
rewrite so the chunk's sourcemap stays valid after the edit, instead of a
blind string replace that invalidated chunk.map.

Also extract copyMissingOgWasm() as a dependency-injected helper and make the
copy-fallback test hermetic: yoga.wasm is not shipped by @vercel/og (it is
synthesized from base64 by the og-font-patch transform), so the prior test
only passed when a previous build had left yoga.wasm in node_modules.

* test(og): cover cross-directory fallback rewrite; document cross-hook state

Address bonk review observations: add a test asserting the path.posix.relative
rewrite produces a directory-relative ref when the chunk and emitted asset live
in different dirs (the main behavioral win), and document the generateBundle->
writeBundle dedupedBases dependency.
2026-06-07 00:42:51 +00:00
James Anderson ddc529af5e docs(agents): forbid installing with --no-frozen-lockfile (#1802)
* docs(agents): forbid installing with --no-frozen-lockfile

* docs(agents): clarify --no-frozen-lockfile rule rationale and ask user

* docs(agents): correct --no-frozen-lockfile guidance
2026-06-07 00:34:07 +00:00
Nathan Nguyen dab28c4b0f fix(app-router): refetch same-page search navigations (#1744)
* fix(app-router): refetch same-page search navigations

Same-page App Router navigations with a changed search string could replay a prefetched RSC payload instead of requesting the target payload. That diverges from Next.js, which refetches page segments for same-page navigations because search params are page inputs.

Bypass visited and prefetched RSC caches for navigate operations whose pathname is unchanged but search params differ. Add a prerendered production browser regression that ports the Next.js hash navigation request assertion.

* fix(app-router): hide rsc cache query from middleware

* test(app-router): mirror upstream hash scroll offsets

* fix(app-router): address review feedback on rsc query navigation

- Reuse fixture-scoped node_modules linking in the hash RSC spec instead of
  symlinking repo-root node_modules, matching sibling production specs.
- Add an explicit assertion that no request leaked `_rsc` to the fixture
  middleware (HTTP 599) for a clear failure mode.
- Document that the optimistic shell is intentionally not gated by
  shouldBypassNavigationCache (detached commit, always superseded).
- Detect the RSC cache-busting param via the encoding-aware stripper so the
  guard and stripper can never diverge on encoded-key edge cases.

* fix(app-router): share encoding-aware matcher for _rsc guard

Replace the before/after url.search comparison in
requestWithoutRscCacheBustingSearchParam with a dedicated
hasRscCacheBustingSearchParam helper that reuses the stripper's
isRscCacheBustingSearchPair matcher. This makes the guard and stripper share a
single source of truth and avoids spuriously rebuilding/normalizing requests
whose only difference is degenerate empty query pairs (e.g. ?a=1&&b=2).

* fix(app-router): preserve cf metadata when hiding _rsc from middleware

requestWithoutRscCacheBustingSearchParam reconstructed the userland request
with a bare new Request(url, source), which drops the Workers-specific cf
property and omits duplex: "half" for streaming bodies (throwing on
Node/undici). Add a cloneRequestWithUrl helper mirroring cloneRequestWithHeaders
that overrides the URL while preserving headers/cf and setting duplex, and route
the _rsc strip through it.

* fix(app-router): stop _rsc leaking to external rewrite proxies

The beforeFiles/afterFiles/fallback applyRewrite calls forwarded the original
request (with _rsc) to proxyExternalRequest, which reconstructs the upstream URL
from request.url and merges its query params — leaking the internal RSC
transport query to external rewrite destinations. Pass the _rsc-stripped
userlandRequest to all three calls, consistent with the middleware invariant.

Add a regression test that points an external rewrite at a loopback server and
asserts the forwarded URL drops _rsc while preserving userland query params.

* test(app-router): wait for idle network before tracking RSC requests

The query-param link is in the viewport and gets prefetched on load. On slower
runtimes (WebKit), that prefetch could land after rscRequestUrls.clear(), so it
was misattributed to the hash-only navigations and tripped the
"no with-query-param request" assertion. Wait for networkidle before clearing,
mirroring upstream's waitForIdleNetwork() in the ported Next.js test.

---------

Co-authored-by: James <james@eli.cx>
2026-06-07 00:20:27 +00:00
James Anderson d3870efe57 refactor(cache): deprecate imperative cache handler setters and exports (#1800)
Steer consumers toward the declarative `cache` option on the vinext()
plugin (with the @vinext/cloudflare builders kvDataAdapter/cdnAdapter)
instead of imperatively wiring cache handlers from a worker entry.

- Mark setDataCacheHandler, setCacheHandler, and setCdnCacheAdapter as
  @deprecated with config-based migration examples. They remain functional
  as the internal registration target used by virtual:vinext-cache-adapters.
- Mark the manually-instantiated handler exports MemoryCacheHandler and
  KVCacheHandler as @deprecated for consumers.
- Update the next/cache type declarations in next-shims.d.ts to match.
- Fix stale AGENTS.md claim: the default data cache is in-memory
  (MemoryCacheHandler) in all runtimes; KV is opt-in, not the default.
2026-06-07 00:05:49 +00:00