849 Commits

Author SHA1 Message Date
James Anderson 88b1152eb2 fix(app-router): include static-sibling info in SSR response (#1525) (#1686)
Next.js's loader-tree dynamic-segment tuple includes a `staticSiblings`
list (the static sibling segment names at the dynamic URL level) so the
client router can decide whether a cached prefetch of a dynamic route
can be reused when navigating to a static sibling URL. vinext was not
emitting this metadata, so the static-siblings e2e suite failed all four
substring assertions against the RSC payload.

This change:

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

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

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

Refs cloudflare/vinext#1328
2026-05-29 10:05:43 +01:00
Jerry Zhao 5b58cb9be9 refactor: move normalizePathSeparators to utils/path (#1606)
* refactor: move normalizePathSeparators to utils/path

* update from other pr

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Adds two production-server regression tests:

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

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

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

Production + revalidateSeconds: Infinity now writes to the ISR cache
(cacheState: "MISS", shouldWriteToCache: true). Update the existing
assertion to match, and add a dev-mode case asserting Infinity stays
as a no-write STATIC response in development.
2026-05-28 21:12:23 +01:00
James Anderson 5d9548dc92 fix(pages-router): invoke Document.getInitialProps so docProps reach SSR (#1633)
* fix(pages-router): invoke Document.getInitialProps so docProps reach SSR (#1361)

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

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

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

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

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

Refs #1361

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

Two changes from review feedback on PR #1633:

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

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

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

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

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

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

This commit:

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

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

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

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

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

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

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

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

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

* review: address bot feedback on staleTimes PR

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

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

Three small changes restore Next.js parity:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #1458
2026-05-28 20:08:37 +01:00
James Anderson 3e7d7f8f8b fix(build): inline process.env.NEXT_DEPLOYMENT_ID for client and worker bundles (#1538) (#1683)
User code such as `new Worker(new URL('./w.ts', import.meta.url))` reads
`process.env.NEXT_DEPLOYMENT_ID` from inside the worker scope, but vinext
only defined the internal `__VINEXT_DEPLOYMENT_ID` mirror — leaving the
public Next.js identifier as `undefined` in every browser-side bundle.

Mirror Next.js' webpack DefinePlugin behavior (see
packages/next/src/build/define-env.ts) and statically inline the value
into the global Vite `define`. The same map is applied to client, SSR,
RSC and Vite worker bundles, so workers transparently see the deployment
id without needing a globalThis bridge (which can't be shared across
the main-thread / worker boundary anyway).

When no deploymentId is configured, inline `false` — the same fallback
Next.js uses, so feature checks like `if (process.env.NEXT_DEPLOYMENT_ID)`
remain dead-code eliminated.
2026-05-28 20:06:23 +01:00
Nathan Nguyen 7be428dbc3 feat(app-router): support inline CSS parity (#1595)
* feat(app-router): support inline CSS parity

App Router production builds ignored Next.js experimental.inlineCss, so HTML responses kept stylesheet links, next/font CSS lived in a second style tag, and Next-style RSC requests with RSC: 1 plus _rsc could return HTML.

The missing invariant was that inlineCss changes the production CSS delivery boundary: non-RSC HTML gets inlined style resources, RSC payloads keep CSS bodies out, and client navigations must not reintroduce duplicate stylesheet resources for CSS already present inline.

Resolve the config flag into the generated RSC entry, collect emitted CSS assets into an inline manifest for Node and Workers production, rewrite matching React stylesheet links into hoistable style tags, merge next/font CSS into the first inline style with a fallback path, and recognize Next-style RSC requests.

Regression coverage now ports the upstream app-inline-css behavior at production-browser level and adds focused tests for request normalization, stream rewriting, font merging, and fallback emission.

* feat(app-router): preserve inline CSS import ordering

Inline CSS font merging could prepend generated font rules before a stylesheet import. CSS imports must stay at the beginning of their stylesheet, so that ordering caused imported styles to be ignored when experimental.inlineCss and local font CSS were combined.

Keep import-sensitive stylesheet preambles untouched and let the existing font fallback style carry the font rules instead. Add stream-helper coverage for the import case.

* feat(app-router): harden inline CSS review edges

Inline CSS rewriting only saw complete link tags in a single decoded chunk and treated rel as an exact value. That left valid stylesheet links un-inlined when rel had multiple tokens or when streaming split the tag across chunks.

The production manifest injection also assumed the RSC worker bundle was always at dist/server/index.js, so custom rscOutDir builds missed the inline CSS manifest.

Buffer incomplete link tags across stream flushes, match stylesheet as a rel token, and inject the manifest into the configured RSC output entry. Add regressions for the streaming and custom-output cases.

* feat(app-router): match inline CSS cleanup semantics

Client navigation cleanup only pruned links whose rel was exactly stylesheet and whose precedence marker used data-precedence. That diverged from the server inline CSS rewrite, so tokenized rel values or legacy precedence links could survive after navigation as duplicate stylesheet resources.

The fix moves the cleanup into a testable helper, reuses shared HTML token-list matching for server and browser paths, accepts both precedence marker forms, and only buffers split link tags when an inline CSS manifest is present.

Regression coverage now covers browser cleanup and non-inlineCSS streaming chunk granularity.

* feat(app-router): harden inline CSS HTML rewriting

Inline CSS rewriting could prepend SSR font CSS ahead of namespace-bearing stylesheets and could treat stylesheet-like text inside script bodies as real link tags. That violates CSS ordering constraints and corrupts streamed script text when experimental.inlineCss is enabled.

The stream rewrite now treats @namespace as an unsafe preamble, skips link replacement inside raw-text and comment regions, and buffers incomplete rewrite boundaries across flushes. Regression coverage pins namespace fallback, complete script text, split script text, and unterminated script text.

* test(e2e): specify junction type for fs.symlink to support Windows

* refactor(app-router): drop write-only __PAGE__ wire marker

Every outgoing App Router payload carried `__PAGE__: true` in its wire
metadata, but nothing in the runtime ever read it. Its only consumer was
an inline-css E2E assertion checking that a dynamic navigation returned an
RSC payload rather than HTML, so the marker added permanent per-response
wire overhead with no behavioural effect.

The `__route` key is already present on every payload and proves the same
thing: the response carries page metadata. Remove the marker key, its type
field, and its re-export, and point the unit and E2E assertions at
`__route` instead.

* perf(app-router): skip inline-css manifest scan on the streaming hot path

The tick-buffered SSR transform recomputed `Object.keys(manifest).length`
on every flush through `rewriteInlineCssStylesheetLinks`, even when inline
CSS was disabled and the manifest empty. Streaming flushes are a hot path,
so this ran per chunk of every response for no benefit.

Compute the manifest-present flag once at transform creation and reuse it
to gate both the split-link boundary buffering and the link rewrite, so
non-inline-css responses skip the rewrite entirely. Also document the
shared-lastIndex constraint on the module-level rewrite regexes and the
startup/build-time population contract for `__VINEXT_INLINE_CSS__`.

* fix(app-router): forward SSR script nonce to inline-css <style> tags

Sites running `Content-Security-Policy: style-src 'nonce-…'` saw the
inlined `<style>` blocks blocked at parse time and rendered unstyled
when `experimental.inlineCss` was enabled. The `<link rel="stylesheet">`
tags the feature replaces aren't subject to inline-style CSP, but the
new inline blocks are — and React Fizz typically doesn't put a nonce on
the `<link>`, so the rewrite path had no nonce to carry over.

Thread the SSR-time `scriptNonce` through `createTickBufferedTransform`
into `rewriteInlineCssStylesheetLinks`, and prefer the link's own nonce
when present (otherwise fall back to the SSR nonce). Add unit coverage
for the SSR-only, link-only, and missing-nonce cases.

Also replace `html.toLowerCase().lastIndexOf("<link")` in
`splitTrailingIncompleteLinkTag` with a forward `g+i` regex scan so the
streaming hot path no longer allocates a lowercased copy of every flush
(can be tens of KB on large responses).

* chore(check): register experimental.inlineCss as supported

So `vp check` advertises App Router CSS inlining alongside the other
experimental flags. Notes that next/font CSS is folded into the first
inline `<style>` block.

---------

Co-authored-by: James <james@eli.cx>
2026-05-28 18:17:06 +00:00
James Anderson 9e049261e1 fix(otel): inject experimental.clientTraceMetadata into SSR head (#1644)
* fix(otel): inject experimental.clientTraceMetadata into SSR head (#1494)

When `experimental.clientTraceMetadata` is configured in next.config, read
the active OpenTelemetry context via the registered propagator and emit
`<meta name="..." content="...">` tags in the SSR HTML head for each
allow-listed key. Both App Router and Pages Router are covered (dev and
prod servers). `@opentelemetry/api` is resolved as an optional peer at
runtime, so apps without OTel get a zero-cost no-op.

Mirrors Next.js: packages/next/src/server/lib/trace/utils.ts and
packages/next/src/server/app-render/make-get-server-inserted-html.tsx.

* fix(otel): drop unused getOpenTelemetryTraceData export

knip flagged the helper as unused — it is only consumed internally by
`getClientTraceMetadataHTML`. Keep it module-private.

* fix(otel): normalize clientTraceMetadata allow-list to undefined-only

Pick one convention for the optional allow-list across the App Router
pipeline (was mixing `string[] | null` and `string[] | undefined`).
Drops the redundant `?? null` / `?? undefined` coercions and aligns
every server-side type with `ResolvedNextConfig.clientTraceMetadata`,
which is `string[] | undefined`.

Also adds a resolveNextConfig test asserting that an array passed via
experimental.clientTraceMetadata is preserved verbatim, non-string
entries are filtered, and unset / non-array values resolve to
undefined.
2026-05-28 17:44:59 +01:00
James Anderson 5bcbee60e0 fix(app-router): isolate global-not-found chunk to fix CSS cascade (#1549) (#1654)
When `app/global-not-found.tsx` is defined, route-miss 404s render that
module standalone (it ships its own `<html>`/`<body>` and replaces the
root layout). Previously the entry registered global-not-found.tsx as a
static `import * as`, which co-located its CSS with the root layout's CSS
in a single chunk. Lightningcss then dropped overlapping declarations
between the two stylesheets as dead code, so any rule global-not-found
defined that the layout also defined was silently removed — breaking the
CSS cascade for 404s.

Switching the entry to `() => import("…/global-not-found.tsx")` puts the
module in its own JS chunk and lets the SSR pipeline (and `Resources`
metadata) treat its CSS independently of the rest of the app. The
fallback renderer now accepts a `loadGlobalNotFoundModule` loader (cached
per worker) instead of an eagerly-imported module.

Also adds a regression test asserting the SSR markup for `/does-not-exist`
links only global-not-found's stylesheet — never the root layout's — so
the cascade can never resolve to the wrong color.

Ported from Next.js: test/e2e/app-dir/initial-css-order/initial-css-order.test.ts
2026-05-28 16:32:19 +01:00
James Anderson 9f3ae31f49 fix(app-router): propagate unstable_rootParams to actions and route handlers (#1452) (#1652)
`setRootParams` was only called after the post-action route match, so
server actions, progressive (form) actions, and route handlers all ran
before the unified request context's `rootParams` slice was populated.
User code calling `unstable_rootParams()` from those contexts therefore
saw `undefined` instead of the matched root layout's params.

Seed `setRootParams` from a pre-action route match against the current
cleanPathname so that:
- route handlers observe rootParams during dispatch
- server actions observe rootParams while the action runs and during
  the page rerender that follows a successful action
- `"use cache"` functions invoked from any of those contexts read
  rootParams from the unified request context

The legacy post-rewrite `setRootParams` call is preserved so the value
is refreshed if `afterFiles`/`fallback` rewrites land on a different
route (and consequently a different set of root param names).
2026-05-28 16:30:07 +01:00
James Anderson b5da92e717 fix(app-router): drop space before /> in streamed error meta tags (#1491) (#1651)
When notFound() or redirect() is called from inside a Suspense boundary
the error surfaces after the shell has already streamed, so the not-found
/ redirect intent is communicated to the client via inline <meta> tags
injected into the head section of the stream.

vinext was emitting these as `<meta name="robots" content="noindex" />`
(with a space before the self-closing slash), but Next.js serializes them
via React's HTML renderer, which produces `<meta ... content="..."/>`
without the space. The Next.js navigation tests assert on that exact
substring, so this mismatch caused ~5 deploy-suite failures.

Drop the extra space so the streamed meta tags match Next.js byte-for-byte,
and add ported regression tests against the suspense fixtures.

Ref: packages/next/src/server/app-render/make-get-server-inserted-html.tsx
Ref: test/e2e/app-dir/navigation/navigation.test.ts (SEO describe block)
2026-05-28 16:29:22 +01:00
James Anderson bdc6fe0935 fix(build): forward pageExtensions to Vite resolve.extensions (#1502) (#1649)
When the user configured `pageExtensions` with values beyond Vite's
defaults (e.g. `["platform.tsx", "tsx", "mdx"]`), extensionless imports
of files with those extensions failed to resolve and the build crashed
with `Custom deploy script failed: undefined (1)`.

Mirror Next.js / Turbopack `resolveExtensions` semantics: prepend the
configured `pageExtensions` (each prefixed with `.`) to Vite's default
extension list, preserving the user's priority so e.g. `.web.tsx`
resolves before `.tsx`.

Ported from Next.js: test/e2e/app-dir/resolve-extensions/

Refs: cloudflare/vinext#1502
2026-05-28 16:04:32 +01:00
James Anderson 34c8669182 fix(config): implement compiler.define / compiler.defineServer (#1638)
* feat(config): implement compiler.define and compiler.defineServer (#1496)

Forward `compiler.define` to Vite's top-level `define` (applies to both
client and server bundles) and `compiler.defineServer` to non-client
environments only via a `configEnvironment` hook, matching Next.js
Webpack `DefinePlugin` semantics. Values are JSON-stringified at config
resolution time so they slot directly into Vite's substitution format.

Refs Next.js: packages/next/src/build/define-env.ts.

Fixes #1496

* fix(config): reject compiler.define keys that collide with built-ins

Mirrors Next.js (`packages/next/src/build/define-env.ts`) — throw when a
user-provided `compiler.define` or `compiler.defineServer` key would
silently overwrite an internal define (e.g. `process.env.NODE_ENV`,
`__NEXT_*`, `__VINEXT_*`) or when `define` and `defineServer` define the
same key. Without this, a misconfigured project would produce subtly
wrong output instead of failing fast.

Addresses /bigbonk review on #1638.
2026-05-28 15:45:55 +01:00
James Anderson f6e1525e22 fix(pages-router): throw clear error on non-serializable gSP/gSSP props (#1478) (#1642)
When `getStaticProps` or `getServerSideProps` returned props containing
non-JSON-serializable values (e.g. `Date`, `Map`, class instances), vinext
silently rendered an empty page instead of surfacing an error. This ports
Next.js's `isSerializableProps` check from `packages/next/src/lib/is-serializable-props.ts`
and invokes it from `pages-page-data.ts` and `dev-server.ts` after the
redirect/notFound short-circuits, mirroring `render.tsx` (~lines 982 and
1200). The thrown `SerializableError` is caught by the existing per-request
error boundary, which returns a 500 with a Next.js-style message naming the
offending key.
2026-05-28 15:44:13 +01:00
Nathan Nguyen 5e90f16a56 fix(app-router): preserve push autoscroll across refresh (#1602)
Calling router.push() and router.refresh() in the same transition could leave vinext on the current page instead of committing the pushed route. That diverged from Next.js and broke the default autoscroll target for the pushed page.

The refresh path dispatched a current-route RSC refresh before the nested push transition could finish scheduling, so the refresh superseded the intended navigation.

Track App Router push/replace scheduling for the current task, keep refresh cache invalidation, and skip only the overtaking current-route refresh. Add Next parity coverage for refresh, server-action refresh, focus targets, fragments, hoisted children, and metadata routes.
2026-05-28 14:20:04 +01:00
James Anderson c8b8fc3370 fix(cache): updateTag throws outside Server Action (#1485) (#1641)
Match Next.js semantics: `updateTag` from `next/cache` must throw when
called outside a Server Action context (e.g. in a Route Handler or
during render), and only the in-action invariant invokes the cache
tag invalidation.

Detect the headers access phase ("action") via the existing shim and
throw the canonical error message otherwise. Existing in-action
behavior (immediate tag expiry via the active cache handler) is
preserved.
2026-05-28 14:13:20 +01:00
James Anderson f75845ef14 fix(pages-router): emit page scripts with defer in <head> by default (#1636)
* fix(pages-router): emit page scripts with defer in <head> by default

Next.js' Pages Router emits page scripts with the `defer` attribute in
`<head>` when `experimental.disableOptimizedLoading` is false (the
default). vinext emits `<script type="module">` (which already defers
implicitly), but upstream tests assert the literal `defer` attribute
and were failing against the deploy suite.

Add `defer` to module scripts emitted by `collectAssetTags` when
`experimental.disableOptimizedLoading` is not enabled. Surface the
flag in `ResolvedNextConfig` so prod-server respects an explicit
opt-out. Browser behaviour is unchanged (module scripts already
defer), but parity tests now pass.

Fixes #1519.

* test(e2e): update hydration regex to allow defer attribute

The script tag regex in `tests/e2e/cloudflare-pages-router/hydration.spec.ts`
required `type="module"` to be immediately followed by `src="…"`, but the
optimized-loading fix inserts a `defer` attribute between them. Relax the
regex to allow any whitespace-separated attributes between `type="module"`
and `src` so future attribute additions don't break it either.
2026-05-28 14:12:15 +01:00
James Anderson 53a97e2002 fix(navigation): stabilize Pages Router useParams snapshot for SSR (#1639)
`useParams()` / `useSearchParams()` / `usePathname()` from `next/navigation`
work as cross-router compat shims under Pages Router, deriving their state
from `getPagesNavigationContext()` published by `next/router`. React's
`useSyncExternalStore` requires `getServerSnapshot` to return a stable
reference (Object.is) across calls in a single render — otherwise React
logs:

  "The result of getServerSnapshot should be cached to avoid an infinite loop"

and may re-render until the snapshots stabilize, which is wasteful and a
hydration-mismatch hazard. The SSR branch of `getPagesNavigationContext`
was returning a fresh object each call.

Cache the shape on a `WeakMap` keyed on the ALS-isolated SSR context, so
the same `{ pathname, searchParams, params }` shape (with reference-stable
`params` / `searchParams`) is returned for every hook call inside a single
request. The WeakMap key is per-request, so concurrency safety is
preserved.

Adds a Vitest unit assertion (reference equality across repeated SSR
calls) and a Playwright regression matching the Next.js deploy-suite case
(`test/e2e/app-dir/use-params/use-params.test.ts` — 'should work on pages
router'): a Pages Router dynamic page using `useParams()` inside a fixture
that also has an `app/` directory must render the matched param after
hydration.

Refs #1466
2026-05-28 14:11:36 +01:00
James Anderson a20c49ead2 fix(middleware): stop merging original query into middleware rewrite target (#1632)
* fix(middleware): stop merging original query into middleware rewrite target

Next.js middleware constructs the rewrite-target URL itself — by mutating
`request.nextUrl` or by passing a fresh URL — and whatever search params
that URL carries IS the final query. vinext was silently re-merging the
original request's query on top, which resurrected keys that middleware
had explicitly removed via `searchParams.delete(...)`.

Tests:
  - Add `mw-clear-query-params` Pages Router fixture + dev test ported from
    Next.js `test/e2e/middleware-rewrites/test/index.test.ts` ("should
    clear query parameters").
  - Add prod-server tests for the original-query-preservation paths
    (middleware now propagates query via `request.nextUrl.clone()` in the
    fixture, mirroring Next.js `app/middleware.js`).
  - Add App Router parity test for original-query-preservation.

Closes #1342

* test(middleware): address bonk review nits for #1342

- Fix misleading comment in app-router.test.ts that referenced Object.assign
  merge semantics — the PR's premise is that vinext should NOT auto-merge.
- Add prod-server regression test for searchParams.delete on rewrite target,
  mirroring the dev coverage so both code paths are exercised explicitly.
2026-05-28 14:08:54 +01:00
James Anderson 35bb1a2250 fix(pages-router): return 405 with Allow: GET, HEAD on POST to static pages (#1631)
Pages Router previously rendered HTML for any HTTP method, including
POST/PUT/DELETE, on static (no `getServerSideProps`) pages — masking
client bugs that Next.js surfaces as 405.

Mirrors Next.js's behavior in `base-server.ts` (the
`(typeof components.Component === 'string' || isSSG)` gate), which
rejects non-GET/HEAD methods on static pages with
`Allow: GET, HEAD` and a `Method Not Allowed` body. Pages with
`getServerSideProps` continue to accept any method, since user code
may legitimately read `req.method` inside gSSP.

The new `resolvePagesPageMethodResponse` helper is wired into both
the dev-server SSR handler and the prod-server pages-server-entry, and
skips `/_error`, `/404`, `/500`, data requests, and explicit status
overrides — matching the upstream carve-outs.

Refs #1463. The `res.revalidate()` portion of #1463 is left for a
follow-up.
2026-05-28 14:05:30 +01:00
James Anderson fc317081fd fix(deploy): plain-text 404 for invalid _next/static in Pages Router worker (#1630)
The Pages Router Cloudflare worker template generated by `vinext deploy`
forwarded asset-shaped misses to `renderPage`, which renders the full
HTML 404 page (with bootstrap scripts + CSS) instead of the plain-text
"Not Found" body Next.js emits. The App Router worker entry and prod
server already short-circuit these requests; only the Pages Router
worker template was missing the check.

Adds `isNextStaticPath` + `notFoundStaticAssetResponse` to
`generatePagesRouterWorkerEntry()` so invalid `_next/static/*` requests
(including those under `basePath` or `assetPrefix`) return
`text/plain; charset=utf-8` "Not Found" before the renderer runs.

Matches Next.js: packages/next/src/server/lib/router-server.ts.

Fixes #1337
2026-05-28 14:03:59 +01:00
James Anderson b463b8eb30 fix(script): translate React DOM prop names to HTML attrs in hoisted beforeInteractive scripts (#1629)
Inline `<Script strategy="beforeInteractive">` content is captured via
BeforeInteractiveContext and spliced directly into <head> as raw HTML by
the SSR pipeline. The attribute collector preserved React's camelCase prop
names verbatim, so `className="foo"` round-tripped as `classname="foo"`
(HTML attribute names are case-insensitive), making CSS selectors on `.foo`
fail to match. Same hazard for `htmlFor`, `httpEquiv`, `acceptCharset`.

Translate those four prop names to their HTML attribute equivalents at the
collection boundary, mirroring Next.js's set-attributes-from-props.ts.

Fixes #1518
2026-05-28 14:03:18 +01:00
James Anderson a1a448a49f fix(route-handler): default Path=/ on returned Set-Cookie when merging mutable cookies (#1484) (#1627)
When a route handler called `cookies().set()` alongside a returned
`Response` that carried raw `Set-Cookie` headers without a `Path`
attribute, vinext appended the raw line verbatim. The resulting comma-
joined `set-cookie` view then read like `bar=bar2, baz=baz2` with no
per-cookie `Path=/`, diverging from Next.js whose `appendMutableCookies`
re-runs every returned cookie through `ResponseCookies.set` (which
normalises a missing `path` to `/`).

`applyMutableCookieFallbacks` now ensures each returned cookie carries
an explicit `Path=` attribute, matching Next.js's behaviour in
`packages/next/src/server/web/spec-extension/adapters/request-cookies.ts`.

Ported the Next.js coverage from
`test/e2e/app-dir/actions/app-action.test.ts` ("should support setting
cookies in route handlers with the correct overrides") as
`nextjs-compat/set-cookies.test.ts`.

Fixes #1484
2026-05-28 14:02:33 +01:00
James Anderson f7ea402cd7 fix(link): forward href/onClick to child in legacyBehavior (#1623)
* fix(link): forward href/onClick to child in legacyBehavior (#1469)

When `<Link legacyBehavior>` is set, the shim previously rendered its own
wrapping `<a>` around the user's child anchor, producing nested anchors and
duplicated content (e.g. a hidden `<a id="custom-button"></a>` alongside the
user's anchor) and breaking onClick propagation.

Mirror Next.js: when `legacyBehavior` is true, forward `href`/`onClick`/
`onMouseEnter`/`onTouchStart` to the single child via `React.cloneElement`
instead of wrapping. `href` is only forwarded when `passHref` is set or the
child is an `<a>` without its own `href`. String/number children are wrapped
in a plain `<a>` first so they have an element to clone, matching Next.js.

Ported from .nextjs-ref/packages/next/src/client/link.tsx (legacyBehavior
branch) and covered by .nextjs-ref/test/e2e/legacy-link-behavior-pages.

Fixes #1469

* fix(link): address bigbonk legacyBehavior parity gaps (#1469)

- Skip <Link>'s own onClick prop when invoked via legacyBehavior. The
  child's onClick is the canonical handler; calling Link's onClick too
  caused a double-fire. Added a dev console.warn matching Next.js when
  onClick/onMouseEnter are passed to <Link legacyBehavior>.
- Use the child element's own ref in legacyBehavior (matches Next.js)
  instead of routing through Link's forwardedRef. Merged with the
  internal intersection-observer ref so prefetching still works.
- Use `'href' in child.props` (matches Next.js) instead of `!== undefined`
  so an explicit `href={undefined}` on the child <a> is treated as the
  child owning its href and is not overwritten.
- Clone the child in the dangerous-scheme branch when legacyBehavior is
  set, so blocked javascript:/data:/vbscript: URLs do not produce nested
  anchors. The dangerous href is intentionally not forwarded to the child.

Added 3 unit tests covering the new behaviors.
2026-05-28 13:56:10 +01:00
Nathan Nguyen bdbd1f833d fix(app-router): preserve source identity for intercepted renders (#1589)
* fix(app-router): preserve source identity for intercepted renders

Intercepted App Router renders used the visible target pathname as the payload route identity. That let a modal navigation such as /feed to /photos/42 replace the retained source tree with target-scoped route and page ids, so refreshes, server actions, chained modals, and history traversal could lose source page state.

The violated invariant is that an intercepted render has separate display and matched-route identities. This commits a fenced render identity helper, keeps source route and page ids stable for retained source-route payloads, leaves the target pathname in interception proof, and includes source context in RSC cache variants.

Regression coverage asserts source payload keys, cache variants, modal refresh/action/history flows, and source client/search state preservation.

* fix(app-router): normalize intercepted rsc cache source

* fix(app-router): harden intercepted cache regeneration

* refactor(app-router): share intercepted path normalization

* fixup! fix(app-router): preserve source identity for intercepted renders

fixup! fix(app-router): preserve source identity for intercepted renders
2026-05-28 12:42:01 +01:00
Nathan Nguyen 8e3438d267 fix(app-router): complete default autoscroll page target parity (#1601)
* fix(app-router): complete default autoscroll page target parity

App Router page navigations can commit loading shells, final payloads, and superseded payloads out of order. A pending scroll intent that is only keyed by navigation start can be consumed by the wrong committed tree, which makes default page target scrolling and focus timing diverge from Next.js when loading boundaries or rapid navigations are involved.

Bind scroll intents to approved render commits, consume only the matching intent from the committed scroll target, and clear hard-navigation failures only for their own intent. Extend the Next.js compat fixture coverage for skipped first children, loading boundary timing, same-page search param changes, focus behavior, horizontal scroll preservation, and stale intent races.

* test(app-router): update link navigation scroll intent assertion

* fix(app-router): address review — consume scroll intent on no-commit, tighten test

- Consume scroll intent before returning no-commit in renderNavigationPayload,
  preventing the navigateClientSide fallback from scrolling for an abandoned
  navigation.
- Replace waitForTimeout(700) with expect.poll for robust race-condition
  stability assertion (CI-flake-safe, stronger proof of stability).
- Update wrapperless targeting comment to clarify outer function / inner
  class split.
- Add three unit tests covering no-commit cleanup, stale intent rejection,
  and claim-without-commitId consumption paths.
2026-05-28 12:33:05 +01:00
Divanshu Chauhan (divkix) fdcc850638 fix(routing): serve pages/_foo.tsx when app/ also exists (#1610) 2026-05-27 22:24:02 +01:00
Divanshu Chauhan (divkix) 8b4210f38f fix(app-router): dev-only favicon.ico short-circuit to avoid expensive 404 render (#1612) 2026-05-27 19:56:38 +01:00
Divanshu Chauhan (divkix) aae4b50fe8 fix(build): implement compiler.removeConsole option (#1611)
* fix(build): implement compiler.removeConsole option

Implements Next.js parity for compiler.removeConsole, which strips
console.* calls from the client bundle during production builds.

Behavior:
- compiler.removeConsole: true removes all console.<method>() calls
- compiler.removeConsole: { exclude: ['error'] } preserves excluded
  methods (case-insensitive)
- Shadowed console bindings (local vars, params, destructured) are
  preserved per Next.js SWC behavior
- Computed access console[prop]() is preserved
- Removed calls in expression statements become ; for clean output
- Removed calls in other contexts (JSX, arguments, returns) become void 0

Files:
- packages/vinext/src/plugins/remove-console.ts (new transform)
- packages/vinext/src/config/next-config.ts (types + resolution)
- packages/vinext/src/index.ts (Vite plugin registration)
- tests/remove-console.test.ts (24 unit tests)
- tests/next-config.test.ts (update default resolved config)

Fixes #1497

* fix(knip): un-export RemoveConsoleConfig type

Remove the declare -x ABBR_TIPS_PROMPT="\\n💡 \\e[1m{{ .abbr }}\\e[0m => {{ .cmd }}"
declare -x ABBR_TIPS_REGEXES="(^(\\w+\\s+)+(-{1,2})\\w+)(\\s\\S+) (^(\\s?(\\w-?)+){3}).* (^(\\s?(\\w-?)+){2}).* (^(\\s?(\\w-?)+){1}).*"
declare -x BUN_PTY_LIB="/\$bunfs/root/librust_pty_arm64-b7hv765h.dylib"
declare -x COLORTERM="truecolor"
declare -x COMMAND_MODE="unix2003"
declare -x CPPFLAGS="-I/opt/homebrew/opt/openssl@3/include"
declare -x DEBIAN_FRONTEND="noninteractive"
declare -x DIVMEMORY_API_KEY="3b3f7180985460433de23604da244b8e4c1917f81f992199ebeb0ebc75e7a51e"
declare -x DIVMEMORY_WORKER_URL="https://divmemory.divkix.workers.dev"
declare -x DOTNET_CLI_TELEMETRY_OPTOUT="= 1"
declare -x DO_NOT_TRACK="= 1"
declare -x EDITOR="nano"
declare -x FORCE_COLOR="0"
declare -x FUNCTIONS_CORE_TOOLS_TELEMETRY_OPTOUT="= 1"
declare -x GATSBY_TELEMETRY_DISABLED="= 1"
declare -x GCM_INTERACTIVE="never"
declare -x GIT_ASKPASS=""
declare -x GIT_EDITOR="true"
declare -x GIT_SEQUENCE_EDITOR="true"
declare -x GIT_SSH_COMMAND="ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
declare -x GIT_TERMINAL_PROMPT="0"
declare -x GPG_TTY="/dev/ttys005"
declare -x HOME="/Users/divkix"
declare -x HOMEBREW_CELLAR="/opt/homebrew/Cellar"
declare -x HOMEBREW_NO_ANALYTICS="= 1"
declare -x HOMEBREW_PREFIX="/opt/homebrew"
declare -x HOMEBREW_REPOSITORY="/opt/homebrew"
declare -x INFOPATH="/opt/homebrew/share/info"
declare -x LANG="en_US.UTF-8"
declare -x LDFLAGS="-L/opt/homebrew/opt/openssl@3/lib"
declare -x LOGNAME="divkix"
declare -x LaunchInstanceID="EB1B14BF-88FC-4DF6-A808-CFA3E9E33401"
declare -x MORPH_API_KEY="sk-sKIOR_4BfuniAInrzxgMoovjMKqlBxa2Ae6EC2oVOe44hOl-"
declare -x NEXT_TELEMETRY_DISABLED="= 1"
declare -x NODE_EXTRA_CA_CERTS="/Users/divkix/.factory/cache/certs/factory-cli-certs.pem"
declare -x OLDPWD="/Users/divkix/GitHub/vinext.fix-issue-1497"
declare -x OPENCODE_ENABLE_EXA="true"
declare -x OSLogRateLimit="64"
declare -x PATH="/Users/divkix/.factory/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/Users/divkix/.antigravity-ide/antigravity-ide/bin:/Users/divkix/.antigravity/antigravity/bin:/opt/homebrew/opt/llvm/bin:/opt/homebrew/opt/imagemagick-full/bin:/opt/homebrew/opt/ffmpeg-full/bin:/opt/homebrew/opt/ruby/bin:/Users/divkix/.bun/bin:/bin:/Library/Java/JavaVirtualMachines/temurin-25.jdk/Contents/Home/bin:/opt/homebrew/opt/python/libexec/bin:/opt/homebrew/opt/python/bin:/opt/homebrew/opt/curl/bin:/opt/homebrew/opt/libpq/bin:/Users/divkix/go/bin:/Users/divkix/.local/bin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/pkg/env/global/bin:/Library/Apple/usr/bin:/usr/local/MacGPG2/bin:/Users/divkix/.lmstudio/bin:/Users/divkix/.orbstack/bin:/Applications/Warp.app/Contents/Resources/bin"
declare -x PKG_CONFIG_PATH="/opt/homebrew/opt/openssl@3/lib/pkgconfig"
declare -x POWERSHELL_TELEMETRY_OPTOUT="= 1"
declare -x PWD="/Users/divkix/GitHub/vinext.fix-issue-1497"
declare -x PYTHONUNBUFFERED="1"
declare -x SECURITYSESSIONID="186b0"
declare -x SHELL="/opt/homebrew/bin/fish"
declare -x SHLVL="2"
declare -x SSH_ASKPASS=""
declare -x SSH_AUTH_SOCK="/var/run/com.apple.launchd.VER64w7Euk/Listeners"
declare -x SSH_SOCKET_DIR="~/.ssh"
declare -x STARSHIP_CONFIG="/Users/divkix/.config/starship.toml"
declare -x STARSHIP_SESSION_KEY="5594177922001814"
declare -x STARSHIP_SHELL="fish"
declare -x SUDO_ASKPASS=""
declare -x TERM="dumb"
declare -x TERM_PROGRAM="WarpTerminal"
declare -x TERM_PROGRAM_VERSION="v0.2026.05.20.09.21.stable_03"
declare -x TMPDIR="/var/folders/l6/72dpyfhj03x4z9lvndxkdbxr0000gn/T/"
declare -x USER="divkix"
declare -x VSCODE_TELEMETRY_LEVEL="= off"
declare -x WARP_CLIENT_VERSION="v0.2026.05.20.09.21.stable_03"
declare -x WARP_CLI_AGENT_PROTOCOL_VERSION="1"
declare -x WARP_HONOR_PS1="1"
declare -x WARP_IS_LOCAL_SHELL_SESSION="1"
declare -x WARP_USE_SSH_WRAPPER="1"
declare -x XPC_FLAGS="0x0"
declare -x XPC_SERVICE_NAME="0"
declare -x __ABBR_TIPS_KEYS="l la ll lla g gcl gph gphf gpl gplr gst gcm gcam gco gca gcan gitweb gwp gwl reload c df less www untar bu oc fastdl a__cat a__ls a__rmd"
declare -x __ABBR_TIPS_VALUES="ls ls -a ls -l ls -la git git clone git push git push -f git pull git pull --rebase git status git commit -m git commit -am git commit git commit -a --amend git commit --amend -a --no-edit open (git remote get-url origin) git worktree prune -v git worktree list exec fish \"clear && printf '\\\\e[3J'\" df -h more python3 -m http.server 8000 tar -zxvf fisher update && brew update && brew upgrade opencode aria2c -x16 -s16 -k1M bat lsd --icon-theme=unicode trash"
declare -x __CFBundleIdentifier="dev.warp.Warp-Stable"
declare -x __CF_USER_TEXT_ENCODING="0x1F5:0:0" keyword from  — it is only
used internally within  and knip flagged it as an
unused export.

* ci: trigger CI re-run

Previous E2E failures (font-google, error-interactive, hooks) appear
to be flaky/unrelated to the removeConsole changes.

* fix(remove-console): address bonk review feedback

- Document client-only scoping as a known parity gap from Next.js
- Correct the node_modules skip comment (Next.js does apply to deps)
- Hoist SCOPE_ENTERING_TYPES to a module-level Set for O(1) lookup
- Push a scope for CatchClause params so `catch (console)` shadows
- Document the var-hoisting limitation in BlockStatement scope handling
- Add resolution tests covering all removeConsole config shapes

* fix(remove-console): gate to build + cover remaining shadow patterns

- Set `apply: "build"` on the plugin so console stripping no longer fires
  in `next dev`. Matches Next.js, which documents removeConsole as a
  production-only feature.
- Extend `checkBinding` to recognize AssignmentPattern, ArrayPattern, and
  RestElement so destructured defaults, array destructuring, and rest
  elements correctly shadow `console`.
- Mark FunctionDeclaration / ClassDeclaration `id` in the enclosing scope
  before pushing the function's own scope (function and class declarations
  hoist their name into the surrounding scope).
- Mark named FunctionExpression `id` in the function's own scope (named
  function expressions bind their name only inside their body).
- Add 10 tests covering all newly-supported shadow patterns plus a
  CatchClause regression test.

---------

Co-authored-by: James <james@eli.cx>
2026-05-27 19:16:34 +01:00
James Anderson a0f4e26655 fix(security): honor pages API bodyParser config (F-PROD-4) (#1617)
* fix(security): honor pages API bodyParser config (F-PROD-4)

Pages Router API routes export configuration like:

    export const config = { api: { bodyParser: false } };
    export const config = { api: { bodyParser: { sizeLimit: '4mb' } } };

vinext silently ignored this, hard-coding a 1 MB limit and always
parsing the body. That created a security downgrade for webhook
handlers (Stripe, GitHub, Slack, etc.) that depend on
'bodyParser: false' to read the raw request stream for HMAC
signature verification. By the time the user handler ran, the body
had already been consumed and parsed into 'req.body', so HMAC
verification would either fail closed (no signature match) or fail
open (handlers that fall back to an empty body for unsupported
content types).

Legitimate large uploads were also rejected with 413 even when the
route declared 'sizeLimit: "4mb"' or higher.

This change introduces a shared helper that resolves the route
module's 'config.api.bodyParser' option and patches both code paths:

  - Node/dev path:   packages/vinext/src/server/api-handler.ts
  - Workers/prod:    packages/vinext/src/server/pages-api-route.ts
                     (+ packages/vinext/src/server/pages-node-compat.ts)

Behaviour now matches Next.js
(packages/next/src/server/api-utils/node/api-resolver.ts):

  - bodyParser: false               -> skip parsing entirely. Node
                                       handlers receive the raw
                                       IncomingMessage stream;
                                       Workers handlers receive
                                       request.body as a
                                       ReadableStream<Uint8Array>
                                       on 'req.body'.
  - bodyParser: { sizeLimit: '4mb' } -> parse with the user-supplied
                                        limit instead of the
                                        hard-coded 1 MB.
  - bodyParser: true / undefined     -> existing 1 MB default behaviour.

The sizeLimit parser accepts the same suffix grammar Next.js does
('500b', '100kb', '4mb', '1gb'), implemented inline rather than
pulling the 'bytes' package.

Tests cover both Node and Workers paths plus parser-level unit
tests, including:

  - bodyParser: false leaves the stream intact for HMAC verification
  - sizeLimit '4mb' accepts a 2 MB POST that used to 413
  - sizeLimit '4mb' rejects a 5 MB POST with 413
  - sizeLimit '100kb' rejects a 200 KB POST with 413
  - No config preserves the 1 MB default

config.api.responseLimit is not in scope for this PR and remains a
follow-up.

Reference: Finding F-PROD-4 in SECURITY-AUDIT-2026-05.md

* chore(security): drop unused ResolvedBodyParserConfig export to satisfy knip
2026-05-27 16:25:32 +01:00
James Anderson 1f5fb6b42c fix(security): gate x-forwarded-proto in edge API runtime on trustProxy (F-PROD-7) (#1618)
The dev edge API bridge (`createEdgeApiRequest` in `api-handler.ts`) was
reading `X-Forwarded-Proto` without the `trustProxy` gate that the rest of
the prod server uses. A client that can reach the dev server directly
could send `X-Forwarded-Proto: https` and trick edge handlers that gate
Secure-cookie issuance on `request.url.startsWith("https")` (or any other
`request.url.protocol` check) into believing the request was
TLS-terminated.

Same issue applied to `X-Forwarded-Host` in the same function: the raw
header value was used to build the request URL, opening a host-header
poisoning vector identical to the one `prod-server.resolveHost` already
guards against.

This commit:

* Extracts `resolveRequestProtocol`, `resolveRequestHost`, `trustProxy`,
  and `trustedHosts` into a new shared module
  `packages/vinext/src/server/proxy-trust.ts`. The helpers accept both
  Node `IncomingMessage` and Web `Headers` so the same trust policy
  applies in every server flavor.
* Updates `prod-server.ts` to delegate to the shared module
  (re-exporting `resolveHost`, `trustedHosts`, and `trustProxy` to keep
  the existing public surface and the tests that mutate `trustedHosts`
  working).
* Updates `createEdgeApiRequest` to use the new helpers so dev edge API
  routes honour `X-Forwarded-Proto` / `X-Forwarded-Host` only when
  `VINEXT_TRUST_PROXY=1` / `VINEXT_TRUSTED_HOSTS` is configured.

Tests:

* New `tests/api-handler-trust-proxy.test.ts` covers the default
  (untrusted) behaviour, `VINEXT_TRUST_PROXY=1`, and the
  `VINEXT_TRUSTED_HOSTS` allow-list (including the implicit
  `trustProxy` enablement, case-insensitive matching, and
  comma-separated values).
* Updated the existing "uses the first x-forwarded-proto value" test to
  reflect the new (correct) default of ignoring forged proxy headers.

Reference: Finding F-PROD-7 in SECURITY-AUDIT-2026-05.md.
2026-05-27 16:17:16 +01:00
James Anderson 03e658f221 fix(security): bound x-vinext-mounted-slots cache key cardinality (F-PROD-1) (#1619)
* fix(security): bound x-vinext-mounted-slots cache-key cardinality (F-PROD-1)

Vinext invents two internal headers (X-Vinext-Mounted-Slots,
X-Vinext-Interception-Context) that are legitimately set by the browser's
RSC client and flow into the ISR RSC cache key (appIsrRscKey via
fnv1a64) and optimistic-routing template/source keys.

Because these headers were accepted verbatim from external requests, an
attacker who controls the browser could fan out unbounded distinct values:

  for i in $(seq 1 100000); do
    curl -s "https://target.example/blog.rsc?_rsc=0" \\
      -H "X-Vinext-Mounted-Slots: attacker-$i" \\
      -H "RSC: 1" > /dev/null &
  done

Each unique header value mapped to a unique cache-key hash, producing one
KV write per request (Cloudflare KV bills per write), cache fragmentation,
and content-split cache pollution.

We cannot strip the headers wholesale because the legitimate vinext
navigation client does set them on the user's browser-to-server RSC
fetch. Instead, this commit validates the input shape and bounds
cardinality at the boundary, before the value reaches the cache key.

Approach: input-shape validation + bounds. Specifically:

x-vinext-mounted-slots (normalizeMountedSlotsHeader):
  - Raw header value capped at 4096 bytes (oversized → null).
  - Each token must match the legitimate slot:<name>:<treePath> wire
    format produced by encodeSlotId — name is non-empty and contains no
    ':', treePath starts with '/'. Anything else (e.g. arbitrary
    'attacker-N' bytes) is dropped silently.
  - Each token capped at 256 bytes.
  - Token count capped at 16 after dedup + sort.

x-vinext-interception-context (new normalizeInterceptionContextHeader):
  - Null bytes stripped (header-injection defense, preserved from prior
    behavior).
  - Value capped at 1024 bytes.
  - Must start with '/' (legitimate values are same-origin pathnames
    like '/feed', '/photos/42' — see resolveManifestNavigationInterceptionContext).
  - Raw whitespace is rejected.

Anything that fails validation is treated as if the header were absent,
so the request is still served (just without interception or with the
no-slots cache key).

Why not strip the headers entirely (F-PROD-1 audit option (a))? The
RSC fetch IS the user's browser-to-server request — there is no
internal hop where these headers are added on the server side. Stripping
would break legitimate parallel-route and intercepting-route navigation.

Why not hash the input before using it (audit option (b))? The value
already passes through fnv1a64 in appIsrRscKey. Hashing does not fix
unbounded cardinality — it just relocates it.

Follow-up: the audit hint also suggests validating slot IDs against the
route's declared parallel-route slots and rejecting unknown slot IDs
(matching Next.js's Next-Router-State-Tree approach). That requires
route-manifest access at the cache-key construction point and is a
larger structural change tracked separately.

Tests added in tests/isr-cache.test.ts and
tests/app-rsc-request-normalization.test.ts cover:
  - 1000 distinct attacker-supplied header values collapse to a single
    cache key.
  - Tokens that do not match slot:<name>:<treePath> are rejected.
  - Token count cap (16) is enforced.
  - Raw header length cap (4096 bytes) is enforced.
  - Interception-context values that do not start with '/' are rejected.
  - Legitimate browser-emitted values continue to round-trip.

Identified in internal security audit — see SECURITY-AUDIT-2026-05.md
finding F-PROD-1.

* fix(security): keep slot-id shape validation inside app-elements codec boundary

The architectural invariant test (`app-elements.test.ts > keeps raw
AppElements wire-key construction inside the codec boundary`) failed
because the new mounted-slots header validator in
`app-mounted-slots-header.ts` reimplemented the slot-id wire-format
check (`token.startsWith("slot:")` + body parsing) outside the
codec module.

Delegate shape validation to `AppElementsWire.isSlotId` (already
exported from `app-elements-wire.ts`) so the wire-format definition
lives in exactly one place. The header normalizer retains its
security-relevant bounds (token byte cap, total header byte cap, token
count cap, sort + dedup) — only the shape check moves.

Security property is preserved: slot-id cardinality is still bounded
by MAX_SLOT_TOKENS, MAX_TOKEN_LENGTH, and MAX_RAW_HEADER_LENGTH, and
the same legitimate-shape filter (`slot:<name>:<treePath>` with an
absolute tree path) still gates which tokens contribute to the
normalized header value used in the ISR RSC cache key.
2026-05-27 15:58:54 +01:00
Nathan Nguyen 314fbea508 fix(app-router): align RSC navigation scroll targeting with Next.js (#1599)
* fix(app-router): align RSC navigation scroll targeting with Next.js

App Router RSC navigations scrolled the document after appNavigate resolved, which lost Next.js segment targeting and focus semantics. That left persistent layout offsets and horizontal scroll behaviour coarser than Next.js during normal navigation.

Next.js treats scroll as a navigation intent consumed by the newly navigated segment after commit. This adds a small intent state, wraps the page slot in a client scroll target, and keeps the existing hash-only and popstate paths separate.

Adds focused Playwright coverage ported from Next.js autoscroll and navigation focus tests.

* test(form): stop asserting app router scroll detail

* fix(app-router): prevent focus from changing scroll

* fix(app-router): share scroll intent across Vite module instances

The App Router scroll intent was stored in plain module-level variables in
app-router-scroll-state.ts. navigateClientSide stages the intent and the
committed AppRouterScrollTarget consumes it, but next/navigation and the
scroll-target module can resolve to separate Vite module instances. If the
writer and consumer hold different copies, the staged intent is invisible to
the consumer and the post-commit scroll/focus silently no-ops.

This is the same multi-instance hazard the rest of the navigation state already
guards against with Symbol.for globals (see _CLIENT_NAV_STATE_KEY in
navigation.ts and AGENTS.md "RSC and SSR Are Separate Vite Environments").

Move the single pending intent and the id counter onto a Symbol.for global so
every module instance shares one slot, matching the established navigation
state pattern. Function signatures are unchanged, so callers need no edits.

Also document why AppRouterScrollTarget must remain a class component:
findDOMNode needs a mounted class instance to locate the first rendered DOM
node without a wrapper element, mirroring Next's default scroll handler.
2026-05-27 12:25:54 +01:00
Divanshu Chauhan (divkix) 6a806ecee1 feat(instrumentation): support nextConfig.instrumentationClientInject (#1416)
* feat(instrumentation): support nextConfig.instrumentationClientInject (#1326)

Add support for the Next.js instrumentationClientInject config option that
lets users inject client bootstrap modules for side effects and an optional
onRouterTransitionStart hook ahead of the user's instrumentation-client file.

Changes:
- New generateInstrumentationClientInjectModule() generates a virtual ESM
  module with side-effect imports in array order + composed hook fan-out.
- Accept instrumentationClientInject in NextConfig / ResolvedNextConfig.
- New vinext:instrumentation-client-inject Vite plugin with resolveId/load
  hooks; virtual module string is precomputed once in config() for efficiency.
- Unit tests cover empty injects, single/multi specifiers, hook ordering,
  and empty-module fallback when no user file exists.

* test(instrumentation): fix oxfmt formatting and assertion indentation

The test expected 2-space indent for the generated code, but the
implementation produces 4-space indent within if blocks. Also fixes
oxfmt quote style and line wrapping.

* fix(ci): lint warnings and missing field in test helper

- Replace unnecessary escaped quotes inside template literals to fix
  eslint(no-useless-escape) warnings in instrumentation-client-inject.ts
- Add missing instrumentationClientInject field to makeResolved() test
  helper in next-config.test.ts to fix TS2322 type errors

* Address review feedback for instrumentationClientInject.

Make clientInjectModule self-clearing on config re-eval, filter non-string inject specifiers, document dual resolution paths, and add escaping coverage.

Signed-off-by: Divanshu Chauhan <divkix@divkix.me>

* test(instrumentation): add plugin pipeline integration tests and fix inject resolution

Add client-environment tests for instrumentationClientInject virtual module
wiring, omit the alias when injects are active so resolveId/load run, reload
next.config when root changes, and pre-resolve relative inject paths. Share
empty-module path via INSTRUMENTATION_CLIENT_EMPTY_MODULE and simplify tests.

* fix(instrumentation): resolve empty-module.js in production builds

CI E2E builds failed because INSTRUMENTATION_CLIENT_EMPTY_MODULE pointed
at empty-module.ts, which is not shipped in dist. Prefer .js when present.

* test(instrumentation): address PR review comments for client inject

- Revert nextConfigRoot guard changes in config loading to avoid buildId divergence risk
- Add cache-busting to dynamic ESM module imports in tests and document the reason
- Add integration test case for empty-inject and missing user-file fallback path

---------

Signed-off-by: Divanshu Chauhan <divkix@divkix.me>
2026-05-27 11:13:53 +01:00
Nathan Nguyen 0046e0691a fix(app-router): preserve encoded prerender params (#1597)
* fix(app-router): preserve encoded prerender params

App Router prerendered pages with encoded dynamic params could be bypassed in production and rendered again with decoded route params. This diverged from Next.js for generateStaticParams values like "sticks & stones".

The root cause was split across build and startup. The prerender render path only had the encoded URL, so normal route matching decoded the page params. After preserving encoded params for the render, production startup still seeded the ISR cache under the encoded artifact path while live requests lookup cache entries by the runtime-normalized pathname.

Carry trusted prerender params through the prerender-only request path, keep decoded params for dynamicParams validation, and seed prerendered App Router artifacts under the normalized runtime cache key while reading files from their encoded output path.

* chore(app-router): clean up prerender header internals

Reuse the shared record guard for prerender route param parsing and remove the local duplicate.

Keep Next.js INTERNAL_HEADERS as the exact upstream list, with vinext-only internal headers tracked separately but stripped by the same request pipeline. This fixes the CI unit failure while preserving the prerender header trust boundary.

Factor repeated Node-header conversion and forwarded-protocol handling in the production server to avoid local assertions and duplicate loops.

* fix(app-router): scope prerender params to route pattern

Bind the internal prerender route-param header to the route pattern that produced it so encoded render params cannot cross a rewrite boundary into a different dynamic route shape.

The App Router handler now only applies encoded prerender params when the payload routePattern matches the final matched route. Otherwise it falls back to the decoded params from normal route matching.

Tighten the header parser to validate the payload shape with Object.entries/Object.keys and add a regression for a prerendered source route rewritten to a dynamic target route with a different param name.

* fix(app-router): verify prerender params match route

Require trusted prerender route params to prove both the final route pattern and the final decoded matched params before using their encoded render values.

This prevents same-pattern rewrites from carrying stale prerender params into a different concrete route match, while keeping valid prerender renders encoded and static validation decoded.

Add handler regression coverage for same-pattern rewrites and helper coverage for catch-all array comparison.

* docs(app-router): clarify prerender route-param trust boundary

The prerender route-param readers and serializer carried implicit
coupling that read as security verification but actually delegated it.
A reader new to readTrustedPrerenderRouteParams could assume it verifies
the prerender secret, when verification happens once upstream at
prod-server's nodeToWebRequest boundary and every downstream read trusts
that. The double read-then-reattach in createAppRscHandler also depends
on the secret header surviving filterInternalHeaders, with no note of
that coupling.

Document the trust contract: secret is verified at the outer boundary,
downstream reads operate on already-trusted requests gated by
VINEXT_PRERENDER=1, and the secret header must stay out of the internal
strip list for the second read to succeed. Also document the
empty-params to null serialization contract.

No behavior change; comments only.
2026-05-27 10:55:26 +01:00
Jim Geurts 2af6298a11 fix(shims/script): guard HTMLElement and prefer contextual nonce in SSR (#1608)
Some SSR/edge runtimes polyfill `document` but not the `HTMLElement`
constructor. The previous nonce resolver hit `instanceof HTMLElement`
unguarded and crashed the render with
"ReferenceError: HTMLElement is not defined".

The resolver also fell through to DOM auto-detection whenever `window`
was defined, even when a contextual server nonce was available — DOM
auto-detection is a browser convenience and shouldn't be reached on the
server.

This change:

- Guards `HTMLElement` before `instanceof`, falling back to
  `getAttribute("nonce")` when the constructor is unavailable.
- Prefers explicit nonce, then contextual server nonce, then DOM
  fallback (only when `window` is defined).
- Adds regression coverage for the crash and the resolver ordering,
  including the exact minimal repro shape from the upstream report
  (window + document defined, HTMLElement absent).

Refs cloudflare/vinext#1607
2026-05-27 09:42:41 +01:00
Divanshu Chauhan (divkix) 2b9ff64019 fix(routing): preserve dynamic route params key order (#1609)
App Router dynamic-route params were being returned in reversed key
order (deepest segment first) because the recursive trie matcher
mutated result.params on the way back up the call stack.

Fix by collecting param entries into a shared stack during forward
traversal, then materializing the params object from that stack at
the terminal node. This preserves declaration order for Object.keys().

- Shared buildParams helper extracted to routing/utils.ts to unify
code between route-trie.ts and app-optimistic-routing.ts.
- Added tests verifying key order for [category]/[id], [a]/[b]/[c],
mixed static+dynamic, and backtracking-to-catch-all scenarios.

Fixes #1537
2026-05-27 09:41:37 +01:00