* 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.
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.
* 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
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.
* 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>
* 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
* 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
* 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
* 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
* 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
* 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
* 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>
* fix: handle single object for openGraph.images and twitter.images in metadata shim
Next.js allows `openGraph.images` and `twitter.images` to be a single
object (e.g. `{ url, width, height }`) in addition to a string or an
array. The metadata shim only handled string and array cases, causing
"imgList is not iterable" when a single object was passed.
Normalize single objects to arrays before iterating. Also update the
Metadata type to reflect the full range of accepted shapes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fmt
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
* add oxfmt formatter: config, scripts, CI, editor setup, docs
* rebuild lockfile
* fix: add Format to required checks list, remove dead ignore pattern
* run fmt
* add format to agents.md again
* refactor: delete app-dev-server.ts and update all references to entries/ directly
The file was already a pure re-export shim with no logic of its own.
Update every import/dynamic-import site to point straight at the
individual entry modules:
entries/app-rsc-entry.ts ← generateRscEntry, AppRouterConfig
entries/app-ssr-entry.ts ← generateSsrEntry
entries/app-browser-entry.ts ← generateBrowserEntry
Sites updated:
packages/vinext/src/index.ts
tests/entry-templates.test.ts
tests/app-router.test.ts
tests/shims.test.ts
Also update stale comments in:
server/middleware-codegen.ts, server/request-pipeline.ts,
server/instrumentation.ts, shims/metadata.tsx,
tests/rsc-streaming.test.ts, tests/nextjs-compat/rsc-context-lazy-stream.test.ts,
examples/app-router-cloudflare/instrumentation*.ts
* docs: update app-dev-server.ts references in markdown files to entries/
* fix: pass parent promise to generateMetadata() for ancestor metadata access
Implements Next.js's eager-execution-with-serial-resolution pattern for
generateMetadata(): all calls kick off concurrently but each segment's
parent promise resolves serially (root → leaf), so `await parent` inside
a child's generateMetadata correctly gets the accumulated ancestor metadata.
Fixes#375
* regen snaps
* fix: address review comments — remove dead array, pass searchParams to generateMetadata
- Remove unused layoutParentPromises[] array (dead code pointed out in review)
- Pass searchParams to page's resolveModuleMetadata() — the URL query string was
previously always passed as undefined, so generateMetadata() could never read
searchParams from the URL
- Reuse the already-computed spObj for pageProps.searchParams to avoid double iteration
- Add 2 regression tests for searchParams in generateMetadata
* regen snaps
* regen snaps
* fix: pass actual route params to layout generateMetadata() in fallback render
renderHTTPAccessFallbackPage was always passing {} as params to
resolveModuleMetadata(), so layouts with generateMetadata({ params })
would get undefined for all dynamic route segments (e.g. params.slug)
when rendering not-found/forbidden/unauthorized fallback pages.
Fix: use opts?.matchedParams ?? route?.params ?? {} (same expression
already used for the layout component props just below) so that
generateMetadata() in fallback layouts receives the real matched params.
Adds two regression tests to not-found.test.ts: hit an invalid slug that
triggers notFound() and assert the title contains the actual slug value.
Also updates entry-templates snapshots.
* fix: forward params to resolveModuleViewport in renderHTTPAccessFallbackPage
generateViewport({ params }) in a layout wrapping a not-found/error
boundary now receives the actual route params instead of an empty object.
Mirrors the same fix already applied to resolveModuleMetadata.
Adds generateViewport export to the layout-params-notfound fixture and
two regression tests confirming the themeColor includes the real slug.
draftMode() reads the __prerender_bypass cookie, which is
request-specific data. Without calling markDynamicUsage(), a Server
Component branching on draftMode().isEnabled could have its output
cached by ISR — serving draft content to regular users or stale
content to draft users.
headers() and cookies() already call markDynamicUsage(); draftMode()
was the only request-reading API that didn't.
When a server component, client SSR, or generateMetadata() throws with no
local error.tsx boundary, global-error.tsx renders as the last resort. It
provides its own <html> and <body> (replacing the root layout), matching
Next.js behavior.
Previously, global-error was wrapped inside the root layout in both paths:
1. In-tree ErrorBoundary in buildPageElement — the root layout's <html>
was already serialized in the RSC stream before the ErrorBoundary
activated during SSR, producing double <html>/<body> tags.
2. renderErrorBoundaryPage — explicitly wrapped the error component with
layouts regardless of whether it was a local error.tsx or global-error.
Fix:
- Remove global ErrorBoundary from buildPageElement (it can't work for
SSR because the RSC stream already contains the layout output).
- Add it conditionally for RSC requests only (client-side navigation
needs it for error recovery after hydration).
- In renderErrorBoundaryPage, track whether the resolved component is
global-error and skip layout wrapping when it is.
Strengthen test assertions to match Next.js behavior:
- Assert HTTP 200 (error boundary "handles" the error)
- Assert global-error.tsx content renders (data-testid, error message)
- Assert exactly one <html> and one <body> tag per response
* fix: nav context hydration mismatch
* fix: address bonk review — add __VINEXT_RSC_NAV__ type, serialise searchParams as entry pairs
- Add __VINEXT_RSC_NAV__ global type declaration to global.d.ts with
searchParams typed as [string, string][] (array of entry pairs)
- Change generateSsrEntry() to serialise searchParams via
[...navContext.searchParams.entries()] instead of Object.fromEntries(),
preserving duplicate keys (e.g. ?tag=a&tag=b)
- Update tests to assert the new array-of-pairs shape; new URLSearchParams()
on the client correctly handles both single and duplicate keys
* fix(app-router): don't clear RSC context before lazy stream is consumed
* fix(app-router): remove premature context clears from server action and intercepting route RSC branches
Same bug as fixed for renderHTTPAccessFallbackPage/renderErrorBoundaryPage: context
was cleared immediately after renderToReadableStream() returned, before the stream
was lazily consumed by the client. Async server components running during stream
consumption would see null context and fail.
Two additional instances fixed:
- Server action RSC re-render branch (after action.apply())
- Intercepting route RSC response branch
The getAndClearPendingCookies() / getDraftModeCookieHeader() calls in the server action
branch are intentionally preserved — they read synchronously from context set during
action execution (not during stream rendering) and must run before the response is sent.
Context cleanup is handled naturally by the AsyncLocalStorage scope from
runWithHeadersContext() unwinding after all async continuations complete.
The previous depth-based approach sliced the pathname by a numeric URL
depth, which broke when route groups were present — they don't appear
in URLs but Next.js includes them in the segments array. This also
meant dynamic params were returned as raw URL strings rather than
resolved values matching the route tree.
Replace `layoutSegmentDepths: number[]` with two new fields:
- `routeSegments: string[]` — filesystem segments from app/ root
- `layoutTreePositions: number[]` — directory depth per layout
Each layout now receives pre-computed child segments through React
context (including route groups, with dynamic params resolved to
actual values and catch-all segments joined with "/").
Additional fixes found during code review:
- Pass matched params to error/fallback rendering paths so dynamic
segments resolve correctly during error page rendering
- Skip optional catch-all segments when param is empty/undefined
- Extend routeSegments for parallel slot sub-routes
- Update stale type declaration in next-shims.d.ts
Ported from Next.js: test/e2e/app-dir/hooks/hooks.test.ts
https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/hooks/hooks.test.ts
* chore: add .worktrees/ to gitignore
* feat: support `export const revalidate` on App Router GET route handlers
Read the route segment config `revalidate` from route handler modules
and set `Cache-Control: s-maxage=N, stale-while-revalidate` on GET
responses, matching Next.js behavior. Only applies when the handler
does not set its own Cache-Control header.
Unskip two E2E fixme cases and add vitest coverage.
* fix: guard revalidate=0 and add proper test fixtures
- Skip Cache-Control injection when revalidate is 0 (means "never cache")
- Add /api/custom-cache fixture that sets its own Cache-Control header
- Add /api/no-cache fixture with revalidate=0
- Fix "does not override" test to use a fixture that actually sets Cache-Control
- Add revalidate=0 test case
* Sanitize redirects/headers and use execFileSync
Replace execSync calls with execFileSync (pass argv arrays) to avoid shell interpolation and adjust package-install logic to split package manager and args. Sanitize redirect destinations in SSR paths to prevent protocol-relative open-redirects and use sanitizeDestinationLocal for Next.js redirects. Ensure middleware receives a cleaned pathname by stripping internal .rsc suffix before creating NextRequest and add a test for this behavior. Strip sensitive headers (cookie, authorization and x-middleware-*) from proxied external requests and add a test. Document cache key hazards for auth headers in fetch-cache, add a security note for dangerouslySetInnerHTML in head SSR, and set the Draft Mode cookie to include Secure in production (with tests for production/dev behavior).
* Rename middleware header to x-custom-middleware
Change middleware header name from x-middleware-test to x-custom-middleware across fixtures and tests, and clarify server comment about stripping x-middleware-* headers from responses (reserved for internal routing signals). Files updated: packages/vinext/src/server/prod-server.ts (comment tweak), tests/fixtures/pages-basic/middleware.ts (header set), and tests/pages-router.test.ts (expectations and test titles/comments). This ensures tests reflect the new public header name while preserving internal x-middleware-* semantics.
* Normalize leading slashes and strip headers
Normalize redirect/rewrite destinations by collapsing any leading slashes or backslashes to a single "/" to avoid protocol-relative redirects and handle browser-backslash behavior. Strip additional sensitive headers (x-api-key, proxy-authorization) from proxied requests and ensure all x-middleware-* internal headers are removed from responses (broadened from x-middleware-request-*). Update dev/server sanitization logic accordingly. Update tests and fixture middleware to use shortened x-mw-* header names and add tests covering backslash normalization.