Commit Graph

65 Commits

Author SHA1 Message Date
Boy Steven 0a9eb33a50 docs(nextjs-compat): refresh resolved OpenNext tracking (#2403) 2026-06-29 15:37:57 +01:00
James Anderson e4f5a0e56b fix(app-router): honor global not found opt-in (#2340)
* fix(app-router): honor global not found opt-in

* fix(app-router): render default global 404 standalone

* test(app-router): avoid fixture workspace lockfile entry

* test(app-router): keep fixture esm outside workspace

* fix(app-router): add default global 404 shell

* test(app-router): cover client not found boundary

* fix(app-router): gate global not found discovery
2026-06-26 21:34:25 +01:00
James Anderson 7b21c429c0 fix(app-router): preserve global error details (#2249)
* fix(app-router): preserve global error details

* fix(app-router): preserve default global error semantics
2026-06-22 23:29:40 +01:00
James Anderson fbf68d588e fix(app-router): restore cache and parallel route parity (#2261)
* fix(app-router): preserve searchParams cache variants

* test(app-router): cover RSC metadata cache variants

* fix(app-router): isolate metadata searchParams observation

* fix(app-router): preserve static ISR cache proofs

* fix(app-router): limit queryless cache probes

* fix(app-router): skip redundant cache proof probes

* fix(app-router): harden incomplete cache proofs

* fix(app-router): observe loading page execution

* fix(app-router): preserve exotic page exports

* fix(app-router): preserve client page ISR

* fix(app-router): avoid duplicate queryless page execution

* fix(app-router): observe viewport search params

* fix(app-router): clear force-static search params

* fix(app-router): isolate intercept static context

* fix(app-router): preserve intercept request context

* fix(app-router): isolate action static context

* fix(app-router): match static request API semantics

* fix(app-router): preserve dynamic segment constraints

* fix(app-router): apply slot-only segment config

* fix(app-router): preserve draft mode reads in static errors

* fix(app-router): include nested slot constraints

* fix(app-router): refresh slot layout constraints

* fix(app-router): render nested slot layouts

* fix(app-router): preserve nested slot params

* fix(app-router): preserve intercepted slot head

* fix(app-router): scope parallel layout params

* fix(app-router): preserve intercept layout parity

* fix(app-router): preserve intercept ancestor params

* fix(app-router): scope intercept source params

* fix(app-router): preserve intercept source route params

* test(app-router): stabilize parity validation
2026-06-22 23:17:41 +01:00
Nathan Nguyen 301418a3f1 perf(app-router): defer layout, template and boundary modules to first request (#2145)
* perf(app-router): defer layout, template and boundary modules to first request

The generated RSC entry statically imported every route's layout, template,
loading/error/not-found/forbidden/unauthorized boundary and parallel-slot
module at the top level. On a cold isolate — and on the dev server's first
request — that eagerly evaluated the whole app's segment tree, including
routes never visited in that lifetime. Page and route-handler modules were
already lazy; the surrounding segment modules were not.

Emit those modules as lazy `() => import()` loaders paired with `null`
placeholders in the manifest, and hydrate them onto the route's synchronous
fields in `ensureAppRouteModulesLoaded` before any read (extended from
page/handler to layouts, templates, boundaries and parallel-slot modules).
Intercept layouts hydrate via the dedicated `loadAppInterceptLayouts` because
they live on the match object, not the route. Every reader already runs behind
`ensureAppRouteModulesLoaded` (handler dispatch and `buildPageElements`) or the
intercept resolvers, so no synchronous module field is read before hydration.

The shared intercept `interceptLayouts` arrays keep their `readonly` public
types; the loader is their sole writer and fills slots through one
boundary-local cast, so route metadata stays immutable to callers.

Dev cold start is unchanged on page-heavy apps — pages were already lazy — but
on a boundary-heavy app (80 independent sections, each with
layout+template+error+loading+not-found) first-request module compile drops
704ms -> 534ms (~24%) and time-to-first-200 1917ms -> 1665ms. The saving scales
with the number of unrelated segments deferred.

* test(app-router): localize react max header font fixture

* fix(app-router): dedupe lazy intercept module loads

* fix(app-router): share intercept load state across matches

* docs(app-router): explain root layout loader reuse

---------

Co-authored-by: James <james@eli.cx>
2026-06-19 14:49:29 +01:00
Nathan Nguyen 47b38a91f3 fix(app-router): recover SSR shell render errors via __next_error__ document (#1908)
* fix(plugin): resolve vinext/shims/* package subpaths to local shim files

Runtime helper modules embedded into generated entries import vinext's
own shims by package subpath (e.g. `vinext/shims/headers`), while source
checkouts alias userland `next/*` imports to the local shim files. The
two specifiers resolved to different module instances, so request-scoped
singleton state (navigation context, headers) split between the source
shim copy and the package export copy.

The violated invariant is that every shim module must be a per-request
singleton regardless of import specifier. Resolve `vinext/shims/*`
through the same plugin path as the `next/*` aliases so both forms land
on the local shim files.

Exercised by the SSR shell-error recovery browser spec, whose fixture
imports vinext from the source checkout and depends on shared
navigation state across both import forms.

* fix(app-router): recover SSR shell render errors via __next_error__ document

When the HTML (Fizz) render rejects during SSR, vinext re-rendered a
server-side global-error page whose flight payload encodes the error
tree. For an app without a custom global-error.tsx that meant the
default error card with no path back to the real page: an SSR-phase-only
throw (e.g. a client component using the "throw to opt out of server
rendering" pattern) left the browser stuck on the card even though the
client render would succeed.

The violated expectation is Next.js's shell-error semantics: a failed
HTML shell is served as the default `__next_error__` error document
carrying the ORIGINAL flight payload and the bootstrap module, and the
browser re-renders the real tree from that payload with createRoot
instead of hydrating. Local error.tsx boundaries still win — they ship
inside the flight payload and catch the re-thrown error client-side.

handleSsr now resolves to that recovery document instead of rejecting,
but only when both hold:
- the error did not originate in the RSC render (no string `digest`),
  so flight errors, redirect()/notFound(), and server-component throws
  keep driving the existing rejection-based boundary machinery, and
- the app has no custom global-error.tsx (the generated entry knows at
  build time and threads hasCustomGlobalError through dispatch/render
  options); apps with one keep the server-rendered boundary re-render.

The browser entry switches from hydrateRoot to createRoot when the
document root carries id="__next_error__", dropping the error-shell
styles first.

Covered by the new ssr-error-shell-recovery browser spec (recovery to
real content, local error.tsx for SSR-only and unconditional client
throws) and the existing tests/nextjs-compat/global-error.test.ts
boundary-semantics suite.

* refactor(review): address PR 1908 non-blocking notes

- Add static prerender no-boundary recovery regression test to
  ssr-error-shell-recovery.browser.spec.ts
- Document broad __next_error__ browser marker in app-browser-entry.ts
- Extract stripJsExtension to utils/path.ts and wire into all shim
  resolution sites (vinext/shims/* + react-server shims)

Non-functional: targeted regression coverage + code documentation
+ minor resolver hardening per reviewer feedback.

* refactor(review): clarify shell recovery assumptions

* fix(ssr): cancel abandoned prerender streams

* refactor(ssr): clarify error shell root options

* fix(isr): recover shell errors during regeneration

* fix(ssr): scope client recovery to marked shells

* fix(plugin): explicitly filter vinext shim subpaths

* fix(plugin): retain null-prefixed shim resolution

* test(nextjs-compat): cover no-boundary shell recovery fallback

An unconditional client throw during SSR shell recovery without a local error boundary must still land on the default global-error card. Without a regression test, a future change could tear down the recovery shell and leave a blank document.\n\nAdd a production browser case that exercises the no-boundary route and asserts the default error UI after the client re-render throws again.

* fix(app-router): preserve shell recovery error semantics

* test(app-router): harden shell recovery cache semantics

* fix(app-router): mark global error responses uncacheable

* test(app-router): align error response status expectations

* fix(build): preserve null-prefixed og resolution

* fix(cache): delegate cache header cleanup to adapters

---------

Co-authored-by: James <james@eli.cx>
2026-06-14 22:28:25 +00:00
James Anderson 70ee909adf fix(app-router): support extensionless variable imports (#1958)
* fix(app-router): support extensionless variable imports

* fix(build): harden extensionless dynamic imports

* fix(build): align variable import resolution

* fix(build): cover dynamic import contexts

* fix(build): scope variable import contexts

* fix(build): avoid comment parser backtracking
2026-06-13 00:22:22 +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 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 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 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 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 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 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 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 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 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 b7f7ad649d fix(basepath): enforce scoping on rewrites/redirects/routes (#1397)
* fix(basepath): enforce scoping on rewrites/redirects/routes

Sub-issues addressed under #1333:

1. Rewrites/redirects/headers were firing on requests outside the
   configured `basePath`. Threaded a `BasePathMatchState` through the
   matchers and prod-server / deploy worker / dev plugin so default
   rules (no `basePath: false` opt-out) only evaluate when the request
   was under basePath, and opt-out rules only evaluate outside it.

5. Pages Router requests that landed outside basePath fell through to
   internal route matching. After redirects and beforeFiles rewrites
   run, the request now 404s when it was outside basePath and no
   `basePath: false` rule rewrote it — matching Next.js's
   `resolve-routes.ts:304-309`.

4. With rewrites no longer firing on out-of-basepath requests, the
   downstream code that prepends basePath to destinations no longer
   sees a stripped-but-already-internal pathname, eliminating the
   `/docs/docs/...` doubling for the rewrite-driven cases.

App Router uses the same gating; `basePath: false` rules don't yet
fire there because `normalizeRscRequest` 404s out-of-basepath requests
before the matchers see them — flagged as follow-up in the rule
gating site. External rewrite proxying (sub-issue #2) and static
asset doubling (sub-issue #3, partly addressed by #1337/#1383) are
deferred.

Refs #1333

* test: update deploy.test signature assertions and format basepath tests

* review: address bonk nits — add inverse basePath:false tests and TODO comment
2026-05-21 13:49:25 +00:00
James Anderson 0fbbe33a2e fix: pass params: null (not {}) for non-dynamic routes (#1374)
Next.js exposes `params` as `null` (effectively — via `params || null`
or `params ? await params : null` user-side) to route handlers and
`getServerSideProps`/`getStaticProps` on routes with no dynamic
segments. vinext was passing an empty object, which is truthy and
breaks user code that branches on `params`.

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

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

Closes #1350
2026-05-21 11:27:07 +01:00
James Anderson 912369840c fix: usePathname returns canonical URL after middleware rewrite (#1379)
When middleware or a next.config.js rewrite redirects an external URL
to an internal route target, `usePathname()` (and the navigation
context that backs it) was returning the internal rewrite destination
instead of the canonical/external path the user requested.

Preserve the pre-rewrite pathname as `canonicalPathname` and use it
when populating the App Router navigation context, while leaving
`cleanPathname` to drive internal route matching. This matches Next.js
behavior: test/e2e/app-dir/hooks/hooks.test.ts —
"should have the canonical url pathname on rewrite".

Closes #1348
2026-05-21 09:43:02 +01:00
Nathan Nguyen 0d8d3e3c60 refactor(router): quarantine navigation runtime topology (#1322)
* refactor(router): quarantine navigation runtime topology

* fix(router): restore manifest-backed interception runtime

App Router browser navigations lost first-hop intercepted route context after snapshot topology stopped acting as fallback proof. That made /feed to /photos/:id request the direct page payload instead of the declared modal payload.

The violated invariant was that route topology must come from the manifest, while snapshots only carry runtime proof. Resolve first-hop interception from the manifest, move RSC bootstrap streams onto the symbol-backed runtime, teach prerender extraction that script shape, and harden the runtime contract around route manifest and RSC data.

Coverage includes runtime seam tests, prerender extraction tests, planner/browser navigation tests, package rebuild, and the app-router intercepted routes E2E slice.

* fix(router): narrow prerender RSC script parsing

The prerender extractor accepted any script starting with the symbol-runtime bootstrap expression, then parsed it as though it had the exact RSC chunk-push prefix. That was harmless at the current head, but it made future runtime scripts a foot-gun.

Only the exact runtime chunk prefix and exact done marker are now accepted. A focused extractor test covers non-chunk runtime scripts that share the bootstrap expression prefix.

* fix(router): address navigation runtime review

* fix(router): resolve navigation runtime review threads
2026-05-20 10:29:13 +01:00
James Anderson 558cec0328 fix(shims/script): preload <script> sources during SSR via ReactDOM.preload (#1309)
Mirror Next.js's App Router behavior at
.nextjs-ref/packages/next/src/client/script.tsx:298-376: when SSR-rendering
`<Script src={...} />` with `strategy="afterInteractive"` (the default)
or `strategy="beforeInteractive"`, call `ReactDOM.preload(src, { as: 'script', ... })`
so React Float hoists `<link rel="preload" as="script" href={src} />` into
`<head>`. The script is then fetched in parallel with the streamed HTML.

Before this change, the SSR branch of the Script shim only emitted a
`<script>` tag for `beforeInteractive` and returned `null` for every other
strategy, with no preload. The matching Next.js fixture test
test/e2e/app-dir/app-esm-js/index.test.ts ("should be able to render
nextjs api in app router") asserts:

  expect($('head link[href="/test-ext.js"]').length).toBe(1)
  expect($('head link[href="/test.js"]').length).toBe(1)

…which fails today because no preload links are emitted.

The existing Image shim already uses this exact React Float pattern
(packages/vinext/src/shims/image.tsx:285-286), so the SSR pipeline
already supports Float hoisting.

Tests:
- Updated existing Script SSR tests in tests/script.test.ts and
  tests/shims.test.ts that asserted afterInteractive/default rendered
  empty — they now assert the preload <link> is emitted (no <script>
  tag), matching Next.js.
- Added tests/nextjs-compat/script-preload.test.ts that ports the
  app-esm-js fixture's preload assertions against the app-basic
  /script-nonce page (which already exercises afterInteractive and
  beforeInteractive strategies via the existing fixture).
- crossOrigin handling matches React's type contract
  ("anonymous" | "use-credentials" | undefined). React normalises
  "anonymous" to `crossorigin=""` in HTML; tests accept both forms.

All shim / script / page / document / nonce tests pass (1151 tests).
2026-05-18 15:29:07 +01:00
James Anderson 14ec0a1e62 fix(shims): treat draftMode() as non-dynamic; mark dynamic only on enable/disable (#1308)
Calling `draftMode()` and reading `isEnabled` are plain reads and must not
opt the route out of static prerendering. Only `enable()` / `disable()`
mutate state and need to be tracked as dynamic, matching Next.js's
`trackDynamicDraftMode` (see
.nextjs-ref/packages/next/src/server/request/draft-mode.ts:152-165).

Before this change, any App Router page that called `draftMode()` — even
just to check `isEnabled` — set `dynamicUsageDetected = true`, which:
- caused build-time speculative prerender to skip the route
- caused the request-time ISR HTML write to bail out

Tests for the matching Next.js fixture
(test/e2e/app-dir/draft-mode/draft-mode.test.ts, "should not generate
rand when draft mode disabled during next start") fail today because
vinext returns a fresh random value on every request instead of the
cached prerender. After this change, the page is prerendered/cached
correctly, and `enable()`/`disable()` still bail out of caching as
expected.

The existing test that enforced the old (wrong) behavior is updated to
match Next.js, and dedicated assertions are added that `enable()` and
`disable()` each mark dynamic usage.
2026-05-18 15:27:55 +01:00
James Anderson c6e8bda55e feat(app-router): support app/global-not-found.tsx (Next.js 16 parity) (#1301)
When users define app/global-not-found.tsx, route-miss 404s should render
that module standalone — it provides its own <html>/<body> and replaces
the root layout, matching the behavior of Next.js 16's
experimental.globalNotFound feature.

  https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-not-found
  https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/app-render.tsx#L495-L520

Implementation:

- Discover app/global-not-found.{tsx,ts,jsx,js} alongside the existing
  global-error scan (src/index.ts).
- Thread the path through generateRscEntry via AppRouterConfig and
  buildAppRscManifestCode so the generated entry imports it and binds
  it as globalNotFoundModule.
- Pass the module into createAppFallbackRenderer; renderHttpAccessFallback
  switches to the global-not-found document when statusCode === 404, the
  route is null (route-miss), and no boundary override was supplied.
- Add a skipLayoutWrapping flag to renderAppPageHttpAccessFallback so
  the standalone document isn't wrapped in the root layout, and so the
  RSC payload omits layout-id entries that would otherwise refer to a
  layout tree the markup doesn't contain.

Page-triggered notFound() calls keep the existing path: they still
resolve through the regular not-found.tsx boundary inside layouts —
global-not-found.tsx only takes over for route-miss 404s.

Tests:

- tests/app-fallback-renderer.test.ts adds four unit tests covering
  the new branch (route-miss with global-not-found, page-call with
  global-not-found, absent global-not-found, 403/401 unaffected).
- tests/entry-templates.test.ts adds four codegen assertions covering
  the manifest import and the generated-entry wiring in both directions
  (present and absent).
- tests/nextjs-compat/global-not-found.test.ts ports the basic Next.js
  fixture as an integration test against a dev server, asserting that
  the route-miss 404 renders global-not-found's <html> standalone with
  exactly one <html>/<body>, and that page-call notFound() does not
  use the global-not-found document.
2026-05-18 11:48:04 +01:00
James Anderson f526d59dff fix(basepath): thread basePath through metadata, manifest, and server-action redirects (#1300)
App Router file-based metadata URLs (icon, opengraph-image, twitter-image,
apple-icon, favicon, manifest) and server-action redirect targets did not
include the configured next.config basePath. Next.js prefixes basePath into
both surfaces — bakes it into static metadata route URLs at webpack build
time and threads it through the dynamic image loader's pathnamePrefix, and
applies addPathPrefix() to redirect URLs in app-render and action-handler.

Fixes for two of the five Next.js parity gaps surfaced by
test/e2e/app-dir/app-basepath/index.test.ts:

  - Metadata image URLs: opengraph-image, icon, apple-icon, twitter-image,
    favicon now emit '/<basePath>/<route>/<file>?<hash>' in the rendered
    <head>. Applies to both static (build-time resolved) and dynamic
    (per-request) metadata file routes via a shared post-resolution helper
    in applyFileBasedMetadata. Idempotent: hrefs that already start with the
    configured basePath are left untouched. External URLs (http://, https://,
    //, data:) are never prefixed.

  - Manifest URL: the <link rel="manifest"> href is prefixed with basePath
    via the same helper, so an app with basePath='/base' emits
    '/base/manifest.webmanifest' instead of '/manifest.webmanifest'.

  - Server-action redirect (progressive form POST): the Location header
    is computed from the basePath-prefixed redirect target so
    redirect('/foo') from a server action mounted at /base/... lands the
    browser at /base/foo.

  - Server-action redirect (RSC): the ACTION_REDIRECT_HEADER value is
    prefixed with basePath. The client-side handler reads this header and
    calls window.location.assign/replace verbatim, so the value must
    already be a fully basePath-prefixed pathname.

Adds a shared applyActionRedirectBasePath() helper exported from
app-server-action-execution and a prefixMetadataHrefWithBasePath() helper
in file-based-metadata. Both are idempotent, external-aware, and no-op
when basePath is unset.

Surfaces still pending follow-up (tracked separately):
  - Server-side redirect() from page renders ALREADY works via
    applyAppPageRedirectBasePath in app-page-execution. No change needed.
  - RSC fetch URLs ALREADY work because Link, router.push, and
    useRouter all funnel through toBrowserNavigationHref() which
    prepends basePath before createRscRequestUrl().
  - Pages Router redirect basePath (separate code path in
    pages-server-entry) — not covered here.

Ported test cases live in tests/nextjs-compat/basepath.test.ts.

See:
  https://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/loaders/metadata/discover.ts
  https://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/loaders/next-metadata-image-loader.ts
  https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/action-handler.ts
  https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-basepath/index.test.ts
2026-05-18 11:44:29 +01:00
James Anderson d9906b94d6 chore: add basic vinext site (#1236)
* chore: scaffold new next app with vinext

* basic app

* add to deploy workflow

* fmt
2026-05-15 13:41:10 +00:00
Divanshu Chauhan (divkix) d4e7d83f86 fix: stream Suspense fallbacks during draft mode in App Router (#1158)
## Summary
- Skip static generation context setup for draft mode requests in app-page-dispatch.ts, matching Next.js behavior where draft mode opts into dynamic HTML rendering.
- Port test fixture and streaming test from Next.js PR #93417 to verify draft-mode pages stream their loading.tsx / Suspense fallbacks.

## Test plan
- Added streaming test to tests/nextjs-compat/draft-mode.test.ts
- Added fixture pages at tests/fixtures/app-basic/app/nextjs-compat/draftmode/streaming/
- Verified draft-mode requests show Suspense fallback text in HTML response

Fixes #1009
2026-05-11 08:14:51 +01:00
Nathan Nguyen 7b3fc49b92 fix(app-router): honor route group error boundaries (#1124) 2026-05-07 11:22:30 +01:00
Nathan Nguyen 9d5d1ce50d fix(app-router): validate RSC cache-busting params (#991)
* fix(app-router): validate RSC cache-busting params

App Router RSC responses could vary by request headers while client fetches and server validation still relied on Vary-aware caches. CDNs that key only by URL could reuse a mounted-slot or interception-context RSC payload for a different request.

Next.js validates a cache-busting _rsc search param against the RSC request headers and redirects malformed requests to the canonical URL. Vinext now applies the same invariant to its .rsc request model, centralizes the RSC Vary header set in normal server modules, and de-duplicates middleware Vary additions.

Tests cover missing and stale _rsc values, variant-header hashes, generated-entry delegation, browser RSC request markers, response Vary propagation, and App Router RSC integration.

* fix(app-router): hide RSC cache tokens from navigation URLs

* fix(app-router): hide RSC cache token from searchParams

* fix(app-router): address RSC cache-busting review

* fix(app-router): preserve HTML redirect destination for direct .rsc navigations

Config redirects from next.config.js were unconditionally converted to
.rsc destinations for any request whose URL ended in .rsc. This broke
direct browser navigations to .rsc URLs (e.g. a bookmark or external
link) because the browser was redirected to an RSC payload it could not
render.

Client-side RSC fetches always send the RSC: 1 header, so restrict the
.rsc destination conversion to requests that carry that header. Direct
browser hits without the header continue to redirect to the canonical
HTML URL, matching the pre-PR behaviour for config redirects.
2026-05-04 18:55:43 +01:00
Nathan Nguyen f367f301dc fix(headers): align draft mode cookie attributes with Next.js (#992)
Draft mode cookies currently use SameSite=Lax in production and clear with Max-Age=0. That diverges from Next.js and can prevent production preview flows from receiving the bypass cookie in cross-site contexts.

Centralize draft-mode cookie attributes in the headers shim, use SameSite=None and Secure outside development, and clear the bypass cookie with an expired date. Add focused compatibility coverage for production enable and disable attributes.
2026-04-30 17:13:14 +01:00
Nathan Nguyen dd1dfae3c6 fix(app-router): scope layout params and layout error boundaries (#938)
* fix(app-router): scope layout params and propagate layout head errors

Layouts received full route params, layout generateMetadata failures were swallowed, and layout-thrown forbidden()/unauthorized() fell back through the not-found boundary path. Those diverge from Next.js when nested layouts depend on segment params, when head generation fails before page render, or when HTTP access APIs are thrown from a layout.

The generated RSC entry now delegates param slicing, head resolution, and parent access-boundary selection to typed runtime helpers. The helpers keep route-specific imports in the entry while moving the behavioral core into unit-tested modules.

Tests port Next.js layout params, global-error, forbidden, and unauthorized coverage into focused helper and integration regressions.

* test(app-router): cover layout viewport error boundaries

Layout generateViewport() failures now intentionally share the same boundary path as layout generateMetadata() failures. Copilot flagged that this behavior change needed direct coverage.

Add dev and production-preview compat assertions for co-located error.tsx handling and global-error escalation, with fixtures that throw from layout generateViewport().

* chore(app-router): address head review nits
2026-04-29 10:40:49 +01:00
Nathan Nguyen 213173aaf8 fix(app-routes): preserve returned cookie precedence (#955)
App route handlers appended cookies().set() mutations after cookies already present on the returned Response. That made same-name mutable cookies override the Response cookies, which differs from Next.js route-handler semantics.

Next.js treats mutable cookies as fallbacks and reapplies returned Response cookies as final values. Deduplicate vinext pending mutable cookies by name, filter out pending cookies shadowed by the returned response, and then reappend the returned Set-Cookie headers last.

Adds focused helper coverage plus a nextjs-compat fixture that exercises the live App Route path.
2026-04-29 10:35:03 +01:00
Nathan Nguyen 18962c51d6 fix(app-router): action redirects (#910)
* Fix app form action redirects

* Trigger CI rerun

* Refactor progressive action handling

* Address app action review feedback

* Trigger CI rerun
2026-04-27 20:40:03 +01:00
Stephen Zhou 14ff303cbd feat: csp support (#795)
* fix: support csp

* tweaks

* tweaks

* tweaks

* tweaks

* tweaks

* tweaks

* clean

* tweaks

* clean

* fix test

* tweaks

* fix lock

* fix test

* trigger ci

* tweaks

* tweaks
2026-04-09 09:14:13 +01:00
Webdesign29 7d0203d421 fix: RSC compatibility for dynamic() and layout segment context (#466)
* fix: RSC compatibility for dynamic() and layout segment context

Three related fixes for React Server Component environments:

1. **dynamic.ts: Remove "use client" and add RSC async path**

   The `"use client"` directive forced `next/dynamic` into a client
   component boundary, but `dynamic()` should work in server components
   too. In the RSC environment, `React.lazy` is not available (the
   `react-server` condition exports a stripped-down React). Added a
   runtime check: when `React.lazy` is not a function, use an async
   server component pattern instead (the RSC renderer natively supports
   async components).

   Also switched from destructured imports (`lazy`, `Suspense`,
   `useState`, `useEffect`) to `React.lazy`, `React.Suspense`, etc.
   to avoid importing names that don't exist under the `react-server`
   condition.

2. **layout-segment-context.tsx: Remove "use client"**

   This module is imported directly by the RSC entry. The `"use client"`
   directive created a client component boundary that breaks the RSC
   rendering pipeline. `getLayoutSegmentContext()` already returns
   `null` when `React.createContext` is unavailable (RSC), and the
   `LayoutSegmentProvider` gracefully falls back to passing children
   through unchanged.

3. **app-rsc-entry.ts: Wrap route handler params with makeThenableParams**

   Next.js 15+ changed route handler params to be async (Promises).
   Route handlers that `await params` crash when params is a plain
   object. `makeThenableParams()` wraps the object so it's both a
   Promise and has synchronous property access.

* chore: fix formatting and update entry-templates snapshots after merge with main

* fix: restore 'use client' in layout-segment-context to fix useSelectedLayoutSegment(s)

Removing 'use client' caused LayoutSegmentProvider to run in the RSC
environment where React.createContext is undefined. getLayoutSegmentContext()
returned null, the provider became a no-op, and useSelectedLayoutSegments
always returned [] instead of the actual segments.

The 'use client' boundary is required so the RSC bundler renders this
component in the SSR/browser environment where createContext works.

* test: add regression tests for RSC dynamic() and route handler await params

Add two regression tests for the fixes in PR #466:

1. Route handler await params (Next.js 15 async params pattern):
   Tests /api/catch-all/[...slugs] which uses `await params` in the
   handler — verifies that makeThenableParams() correctly wraps params
   so both await and direct property access work.

2. dynamic() in RSC (async component path):
   Adds a pure server component fixture that uses dynamic() without
   "use client". In the RSC environment React.lazy is unavailable, so
   dynamic() must fall back to the async component pattern. Tests that
   the dynamically-loaded component renders in the HTML output.

* fix: address bonk review comments on dynamic.ts RSC async path

- Add comments explaining LoadingComponent is intentionally ignored in
  the RSC async fallback path (parent Suspense boundaries handle loading
  states; error handling defers to nearest error boundary)

- Add comment explaining the 'as unknown as ComponentType<P>' cast is
  safe because the RSC renderer natively supports async components, but
  TypeScript's ComponentType<P> doesn't account for async return types

- Add unit tests for the AsyncServerDynamic path by stubbing React.lazy
  to undefined (simulating a react-server condition without lazy).
  Verifies: displayName, async resolution, bare component exports,
  and that LoadingComponent is correctly ignored.

Note: React 19.x exports React.lazy from the react-server condition,
so typeof React.lazy !== 'function' does not trigger in current React.
The AsyncServerDynamic path is defensive forward-compatibility code.
The unit tests stub React.lazy to exercise it directly.

* chore: remove unused vi import from dynamic.test.ts

* docs: correct inaccurate React.lazy/RSC comments throughout

React 19.x exports React.lazy from the react-server condition, so the
typeof React.lazy !== 'function' guard does not trigger in current
React. The AsyncServerDynamic path is defensive forward-compatibility
code only — it does not execute today.

Update all comments that incorrectly claimed React.lazy is unavailable
in the react-server environment:

- dynamic.ts: top-level doc comment and guard comment
- dynamic-rsc.tsx fixture: comment explaining the RSC path
- rsc-dynamic/page.tsx fixture: explain which path actually executes
- nextjs-compat/dynamic.test.ts: clarify that the integration test
  exercises LazyServer + Suspense, not AsyncServerDynamic

---------

Co-authored-by: James <james@eli.cx>
2026-03-28 15:19:50 +00:00
Divanshu Chauhan (divkix) 1cd17b5b69 fix: usePathname() returns "/" during SSR of "use client" page components (#689)
* fix: usePathname() returns "/" during SSR of "use client" page components (#688)

The navigation shim uses a registration pattern where navigation-state.ts
calls _registerStateAccessors() to upgrade navigation.ts from module-level
state to ALS-backed state. This only updates the specific module instance
that imported navigation-state.ts.

In Vite multi-environment dev mode, "use client" components can end up with
a separate module instance of navigation.ts (due to pre-bundling or different
resolver chains). That instance never receives the ALS-backed accessors and
falls back to _serverContext (null), causing usePathname() to return "/".

Fix: navigation-state.ts now also stores the accessor functions on globalThis
via Symbol.for("vinext.navigation.globalAccessors"). The default accessors in
navigation.ts check this global before falling back to module-level state.
This ensures all module instances can reach the ALS-backed state regardless
of which instance _registerStateAccessors was called on.

On the browser (where navigation-state.ts is never imported), the global key
does not exist, so the fallback to module-level _serverContext is preserved —
which is the correct behavior for client-side hydration.

Closes #688

* refactor: deduplicate global accessor key and tighten types

- Export GLOBAL_ACCESSORS_KEY from navigation.ts and import in
  navigation-state.ts to prevent silent drift if the Symbol string
  ever changes
- Add satisfies constraint on the accessor object so shape mismatches
  are caught at compile time
- Assert warm-up fetch status in test to surface fixture setup failures
2026-03-26 12:31:03 -05:00
Steve Faulkner 3545fb5faa refactor: extract app page boundary render runtime (#637)
* refactor: extract app page boundary render runtime

* test: harden navigation noindex assertions
2026-03-21 22:23:48 -05:00
James Anderson a86b67dff4 feat: full next/compat/router support and fix vinext check scanner for shimmed modules (#584)
* feat: full next/compat/router support and fix check scanner for shimmed modules

- Export NextRouter interface from shims/router.ts so compat-router.ts can use it
- Add NextRouter | null return type to useRouter() in shims/compat-router.ts
- Add next/compat/router, next/form, next/web-vitals, next/constants to IMPORT_SUPPORT in check.ts so vinext check no longer flags them as unsupported
- Add integration fixture pages and components for next/compat/router in both pages-basic and app-basic fixtures
- Add integration tests in tests/pages-router.test.ts and tests/nextjs-compat/hooks.test.ts
- Add unit tests in tests/check.test.ts covering all four newly-registered imports

* test: update entry-templates snapshots for compat-router-test fixture page

* remove type assertion

* fix: add declare module for next/compat/router to fixture next-shims.d.ts files

* fix: add declare module for next/compat/router to internal next-shims.d.ts

* pin to vp setup 1.0.0
2026-03-19 08:47:40 +00:00
Nathan Nguyen f3c02b84a0 test: add production coverage for global-error compat (#556)
* Add production coverage for global-error compat

* Address global-error review feedback

* docs: update tracker notes for tests #3 and #4 to reflect production preview coverage
2026-03-16 11:19:40 +00:00
Nathan Nguyen 847260ccde fix: align next/headers cookie path semantics (#555)
* Align next/headers cookie path semantics

* Address cookie path review feedback
2026-03-16 11:15:35 +00:00
Stephen Zhou 62910e80e9 chore: move to vp pack, merge vitest config (#550)
* chore: migrate to vp pack

* No copy

* Move test

* Update tsconfig
2026-03-15 11:58:42 +00:00
Stephen Zhou c17d6941be chore: migrate to vite plus (#535)
* chore: migrate to vite plus

* Disable typeAware and typeCheck

* Update CI

* Fix CI

* Fix test

* Clean

* Run test with vp

* Try revert

* react: false In test

* Fix test

* Revert "Try revert"

This reverts commit 009da10473.

* Update

* Update

* Try revert ci changes

* revert

* Run vp migrate

* Disable typeAware and typeCheck for now

* Better resolve for test

* Use vp dev instead of vite

* Update expect

* Fix NormalizeManifestModuleId

* Try increase timeout

* Update to use vp

* Try new check

* Bring back npx vp

* Migrate CI

* Make next-intl resolvable

* Update

* Update

* Update
2026-03-15 10:50:13 +00:00
Nathan Nguyen f12c2005a6 feat: add ISR caching for App Router route handlers (#523)
* feat: add ISR caching for App Router route handlers

Route handlers with `export const revalidate = N` now get server-side
ISR caching (MISS/HIT/STALE with background regeneration), matching
the existing page ISR behavior. Previously only Cache-Control headers
were emitted — the handler re-executed on every request.

- Add `__isrRouteKey` helper (suffix "route") for cache key construction
- ISR cache READ before handler execution (HIT skips handler entirely)
- ISR cache WRITE on MISS via `response.clone()` + `waitUntil`
- STALE serves stale data and triggers background regen with clean
  context (synthetic request, empty headers/cookies)
- Guards: production-only, GET/HEAD only, skips when handler is dynamic
  (uses headers()/cookies()), skips when handler sets own Cache-Control,
  filters Infinity from revalidateSeconds
- Reuses existing CachedRouteValue (kind: "APP_ROUTE") and KV serialization

* chore: update entry-templates snapshots for route handler ISR

* test: add auto-HEAD ISR cache test for route handlers

* fix: review findings — force-dynamic guard, consistent header filtering in cache writes

* test: poll for ISR background regen instead of fixed sleep

Replace fixed 1000ms sleep with a polling loop (up to 5s) to wait
for the background regen to complete. The fixed sleep was flaky
because regen timing varies with system load. Also bumps the stale
wait from 1500→2000ms for slow CI.

* fix: capture request.url before regen closure to avoid pinning Request

Hoist request.url into a local const (__revalUrl) before the
background regeneration closure. This lets the original Request
object (with its body stream and headers) be GC'd sooner on
memory-constrained Workers isolates.

* fix: address remaining review findings

- Hoist url.searchParams into __revalSearchParams before the regen
  closure so the outer URL object can be GC'd during background regen
  (same class of fix as the request.url hoisting)
- Add test for force-dynamic + revalidate route handler guard
- Add comment noting order-dependent test cache state
2026-03-14 12:48:40 +00:00
Sékou Dayifourou KEITA d29ee0eebb fix(route-handlers): align Allow and default-export behavior with Next.js (#497)
* fix: ensure route handlers send correct Allow header

* Inline route handler Allow helpers in RSC entry

* fix: refresh entry snapshots and fmt

* fix(route-handlers): align Allow and default-export behavior with Next.js

* fix(route-handlers): align default-export behavior with Next.js per review

Follow-up for PR #497 review feedback (requested changes).

Changes:

- stop throwing on default export in route handlers

- log dev-only warning when default export is detected

- ignore default export for dispatch so default-only handlers return 405

- keep sorted Allow on implicit OPTIONS and no Allow on 405

- update integration/e2e tests, snapshots, and tracking notes
2026-03-13 12:16:46 +00:00
Divanshu Chauhan (divkix) 9d0fc0d2fb fix: bind iterator methods on request headers proxy (#480) 2026-03-12 07:46:53 +00:00
James Anderson 5b60a70f94 test: headers() and cookies() in server actions and route handlers (#473)
* test: add regression tests for headers() and cookies() in server actions and route handlers

* test: address review comments on action-headers tests

- Remove ghost test that always passed via 404 bailout
- Remove unused getHeaderFromActionSync fixture export
- Fix extractReturnValue to parse full first line instead of greedy {.+} match
- Fix action ID extraction to match by export name instead of array index
- Add comment clarifying why the route handler test is distinct from existing ones

* test: address second round of review comments

- Use fetchHtml helper for page fetches (consistent with other nextjs-compat tests)
- Clarify page.tsx comments: wrapper closures discard return values, named
  export tests are needed for value assertions
2026-03-11 18:07:54 +00:00
Nathan Nguyen b87ab02d58 feat: metadata parity for appLinks, iTunes, Twitter player/app cards (#452)
* feat: add metadata support for appLinks, iTunes, and Twitter player/app cards

Add four missing Next.js metadata features to the Metadata type and
MetadataHead renderer:

- appLinks: renders al:* property tags (ios, iphone, ipad, android,
  windows_phone, windows, windows_universal, web)
- itunes: renders apple-itunes-app name tag with app-id and app-argument
- twitter player cards: renders twitter:player, twitter:player:stream,
  twitter:player:width, twitter:player:height
- twitter app cards: renders twitter:app:name/id/url per platform
  (iphone, ipad, googleplay)

Includes fixture pages and tests ported from Next.js test suite.

* chore: format metadata files

* fix: resolve twitter player/app and appLinks URLs against metadataBase

* fix: un-nest twitter app card name/url checks to match Next.js behavior

In Next.js, the name, id, and url checks in the twitter app card
platform loop are independent. Our implementation nested name and url
inside the id check, so a platform without an id wouldn't get name or
url tags. This un-nests them to match Next.js parity.

Also adds the missing twitter:app:name:ipad test assertion.

* Update packages/vinext/src/shims/metadata.tsx

---------

Co-authored-by: James Anderson <james@eli.cx>
2026-03-11 17:40:18 +00:00
Jared Stowell 0775ce55bb fix: return ReadonlyURLSearchParams from useSearchParams (#424)
* Update navigation search params

* Fix server readonly cache scope
2026-03-10 23:56:11 +00:00
Jared Stowell 3a5fabe7ec fix: next/headers readonly semantics and legacy sync compatibility (#402)
* Add headers/cookies legacy tests

* Fix headers sync error handling

* Fix headers sync guards

* bonk

* Update packages/vinext/src/shims/headers.ts

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

* bonk

* Clarify dynamic error comment

---------

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: James <james@eli.cx>
2026-03-10 20:21:55 +00:00