Vinext's app-rsc route matcher previously fired interception on any RSC
request whose URL matched an intercept target, regardless of the source
pathname. Next.js implements interception as a rewrite that is only
activated when the Next-URL header matches the *intercepting route*
regex (`^<interceptingRoute>(?:/.*)?$`). Without that gate, a direct
`.rsc` fetch to an intercept target would incorrectly render the modal
page instead of the underlying route, and unrelated sources could pull
in modals they have no slot for.
This change:
1. Captures the *intercepting route* URL pattern (`sourceMatchPattern`)
alongside each `InterceptingRoute` at scan time. The pattern is the
slot owner's path with route groups and `@slot` segments stripped,
matching Next.js' `extractInterceptionRouteInformation`. The value
is propagated through the App RSC manifest.
2. Adds source-pathname filtering to `createAppRscRouteMatcher`'s
`findIntercept`: an entry only matches when the provided source
pathname satisfies its `sourceMatchPattern` (with descendants
allowed). When no source pathname is provided (no Next-URL /
X-Vinext-Interception-Context header), no intercept fires.
3. Adds unit coverage in `app-rsc-route-matching.test.ts` and
`app-route-graph.test.ts` for the new behavior, including the
patterns used by the failing Next.js deploy suite cases
(`parallel-routes-and-interception-basepath`,
`parallel-routes-and-interception-from-root`, multi-catchall, and
two-levels-up).
4. Updates `app-router.test.ts` integration tests that previously
relied on the buggy behavior; intercept fires only when the test
sends `X-Vinext-Interception-Context` and that context matches the
slot's intercepting-route prefix.
Cross-references:
- https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/interception-routes.ts
- https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/generate-interception-routes-rewrites.ts
PR #1249 is orthogonal: it carries proof of interception through the
planner/lifecycle when the matcher has already fired. This change
restricts when the matcher fires in the first place.
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-foundhttps://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.
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.tshttps://github.com/vercel/next.js/blob/canary/packages/next/src/build/webpack/loaders/next-metadata-image-loader.tshttps://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/action-handler.tshttps://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-basepath/index.test.ts
Server-rendered user modules can currently observe a partially populated browser global when the runtime provides one. That violates Next.js SSR semantics, where server code must see browser globals as absent, and it breaks common typeof window guards during module evaluation.
Add a shared server globals bootstrap that removes window and document before user modules load, preserves the existing AsyncLocalStorage edge-runtime global, and import it ahead of generated App and Pages Router user imports. Cover the runtime behavior and generated import order with focused regression tests.
Under the Pages Router, hooks from `next/navigation` returned empty
objects:
expect(pagesParamsJSON).toEqual({ slug: 'foobar' }) // got {}
expect(pagesSearchparamsJSON).toEqual({ q: 'pages' }) // got {}
Next.js treats `next/navigation` as a cross-router compat shim. The
Pages Router server (render.tsx) and client (client/index.tsx) wrap
each page with SearchParamsContext / PathParamsContext / PathnameContext
providers populated from the router via `adaptForSearchParams` and
`adaptForPathParams` in
.nextjs-ref/packages/next/src/shared/lib/router/adapters.tsx.
vinext drives those hooks from a module-level navigation context that is
only set for App Router renders. For Pages Router renders the context is
null, so useParams / useSearchParams / usePathname fell through to empty
fallbacks.
This change adds a Pages Router compat source published by next/router
through a Symbol.for global handle. next/navigation reads that handle
when the App Router context is null and derives:
- pathname from window.location / SSR asPath
- searchParams from the URL query string only (not merged route params)
- params from the route pattern matched against the resolved path
The publisher is exposed via globalThis[Symbol.for(...)] rather than a
direct module import so navigation.ts does not become a transitive
dependency of every next/router consumer (which would pull in the
window.history.pushState patch at module-load time and break unit tests
that only need next/router).
Ports the behaviour covered by these Next.js tests:
- test/e2e/app-dir/params-hooks-compat/index.test.ts
- test/e2e/app-dir/use-params/use-params.test.ts ("should work on pages
router")
Adds vitest coverage at tests/pages-router.test.ts ("next/navigation
useParams returns only dynamic route params under Pages Router" and
"next/navigation useSearchParams returns only query string under
Pages Router") via a new fixture page at
tests/fixtures/pages-basic/pages/nav-compat/[slug].tsx that mirrors
.nextjs-ref/test/e2e/app-dir/params-hooks-compat/shared/params-component.js.
* fix(app-router): track hash-only traversal metadata
Hash-only App Router navigations wrote null history state even though they are app-owned browser-visible entries. That kept traversal direction unknown, but it broke the metadata invariant introduced by the App Router navigation lifecycle work.
Route hash-only push and replace through a browser-entry-owned commit hook so the same traversal index allocator and metadata shape are used without fetching RSC. Also update the local traversal index during same-route hash popstate so replace-after-back uses the current entry index.
Closes#1252
* fix(app-router): avoid stale hash replace scroll state
Hash-only App Router replace now passes the effective scroll option into the browser-entry commit hook.
Default-scroll replaces strip vinext scroll restoration keys before adding traversal metadata, avoiding contradictory history entries whose URL points at a hash target but whose state restores the old scroll position. Explicit scroll:false keeps the previous scroll restoration state.
Add browser coverage for replace-after-back to #content, including the traversal back to the replaced entry.
Interception proof construction and AppElements wire parsing enforce the same matched-url path shape. Keeping the predicates separate makes future proof validation changes easy to drift while preserving the same runtime contract today.
Extract the shared path validator and use it from both callers. Proof construction still returns null for invalid input, and wire parsing still throws for invalid serialized metadata.
Wraps the RSC response body in dev mode to check for invalid dynamic usage errors (e.g. cookies()/headers() inside "use cache") after the stream is fully consumed. This mirrors Next.js behavior where workStore.invalidDynamicUsageError is surfaced via the Flight pipeline so errors caught by user try/catch don't silently disappear.
- Dedups against React's Flight error chunk using digest presence
- Only applies to RSC responses (client-side navigations) in dev
- HTML path intentionally deferred; error still flows through rscErrorTracker
- Ported from Next.js: https://github.com/vercel/next.js/commit/f5e54c06726b571a042fce67417e40a29f6b8689Closes#1195
* feat(cache): add disabled static layout reuse proof
Static layout cache artifacts can already carry render observations, but the cache proof model could not prove or reject static layout reuse independently of cache serving. That left private, dynamic, and incomplete observations covered only by downgrade classification, not by the reusable artifact boundary.
The missing invariant was that a reusable layout needs positive layout/root/boundary proof and negative request API proof before any future cache authority can consume it.
Add a disabled static layout proof evaluator with rejection trace codes for mismatched layout output, unknown root boundaries, private variant dimensions, private/dynamic observations, and missing or observed request APIs. Keep runtime cache reuse disabled by carrying the proof only on the disabled decision.
* feat(cache): enforce route variant budget fallback
Cache variant construction previously had a lossy scalar route count, so callers could not distinguish a duplicate admissible variant from a genuinely new variant that would exceed the route ceiling.
That violated the #726 cache proof invariant that variant cardinality must have an owned breaker path. Route budget admission now tracks canonical variant keys per route, allows duplicate variants without consuming budget, and returns structured breaker fallbacks for route mismatches or over-ceiling variants.
Tests cover duplicate admission, second distinct admission, and the over-budget breaker fallback for #726-CACHE-10.
* fix(cache): derive static layout proof authority
* docs(cache): clarify static proof evidence copy
* perf(cache): binary-search route variant keys
* refactor(cache): move sorted key search to utils
* test(cache): cover static proof boundary mismatch
* fix(image): preserve inline styles on remote images
Remote next/image renders with explicit width and height delegate through UnpicImage, but that branch dropped the user style prop. This made remote images ignore inline styling such as borderRadius, objectPosition, and transform while local images and fill images preserved it.
The shim now passes style through the remote constrained Unpic boundary and isolates the Unpic type gap where the runtime supports style but the public type omits it. A focused SSR regression test covers the rendered remote image style contract.
* refactor(image): avoid remote style type assertion
The previous fix isolated an assertion around @unpic/react because the runtime forwards style but the public component type omits it.
Keep the existing dependency shape and pass the runtime-supported style prop through a typed spread object instead. The remote image style regression coverage remains unchanged.
Client-side next/script insertion left dynamically created script elements in the browser's forced async state when callers passed async={false}. That breaks ordered third-party script loading because the shim skipped false boolean props instead of clearing the DOM script async flag.
Set script boolean attributes through their DOM properties and explicitly set/remove disabled boolean attributes to clear force async. Cover the regression with a focused handleClientScriptLoad test.
RSC redirects were followed by mutating browser history before the redirected payload had passed the visible commit lifecycle. That could collapse the initiating history entry and leave back/forward traversal without an explicit intent model.
Keep redirect hops inside the initiating navigation until an approved commit publishes history, add explicit terminal redirect decisions, and record per-entry traversal metadata so unknown browser history state stays unknown instead of being guessed.
Add focused unit coverage for redirect decisions and traversal metadata plus a browser regression for router.push redirect back navigation.
Client reference dedupe currently rewrites every package file imported through the RSC package proxy back to the package root. That loses subpath identity when a package exposes client modules through an exported subpath instead of its root barrel.
The plugin now derives a bare import specifier from package metadata, preserving exact and pattern exports while keeping private internals on the package root so existing context dedupe behavior stays intact.
Regression coverage exercises exported subpaths, scoped packages, export patterns, private internals, and legacy deep imports.
* feat(router): promote intercepted preservation through planner
Intercepted navigation could still preserve source UI through context-bearing payload shape and same-URL history state. That made modal preservation depend on transport metadata instead of the visible route world that had actually been committed.
The planner now requires explicit interception proof before preserving source layouts or unrelated slots, and the browser lifecycle clears stale previous-next history state when normal target payloads commit. Wire metadata carries proof, while missing, stale, or incompatible proof falls back to hard navigation.
Tests cover approved source-slot preservation, missing, stale, and malformed proof rejection, current-context refresh and action handling, traverse restoration, and direct-target refresh clearing stale interception state.
* fix(router): normalize intercepted proof paths
Interception proof could be built from browser-derived percent-encoded paths while committed route snapshots used the server-normalized route-state form. Non-ASCII intercepted source or target paths could then fail proof validation and fall back to a hard navigation.
Normalize proof matched URLs before encoding source and target route IDs, and normalize client planner snapshot matched URLs to the same route-state representation without changing the user-facing navigation snapshot.
Adds regressions for encoded non-ASCII proof generation and planner validation.
* review: address ask-bonk PR review comments
- Add interceptedRejectedTargetMismatch reason code for target URL
mismatch in planner traces (was misusing UnknownSource)
- Document double-attempt replaceState fallback for browser quirks
- Clarify navigate branches: proven interception vs legacy fallback
- Document cache migration safety for legacy interceptionContext
- Tighten isInterceptionMetadataValue type guard with string checks
- Extract interception attachment into shared step in wire metadata
entries to remove asymmetric branching
* fix(router): ignore context-only interception metadata
Context-only AppElements payloads could enter intercepted preservation planning or leave context-suffixed route identity as later proof authority. That made transport metadata influence visible-world preservation when a normal response was rendered from an intercepted request context.
Only explicit interception proof now enters the preservation branch. Planner snapshots also canonicalize context-suffixed route IDs when no proof is present, keeping render keys partitioned while preventing stale context from poisoning future explicit interception proof.
Tests cover the direct planner decision and the two-step browser lifecycle regression.
* test(router): cover intercepted proof rejection matrix
Planner traces now expose distinct rejection reasons for mismatched intercepted targets and incompatible roots. Without direct tests, those branches could regress while the broader intercepted route scenarios still passed.
Add focused planner tests for target mismatch and incompatible root rejection, use the local target snapshot consistently, document two lifecycle ordering contracts, and avoid the no-op interception metadata spread on non-intercepted payloads.
* fix(router): keep next/router import free of popstate side effects
Importing next/router currently installs the Pages Router popstate listener as module-level browser state. That listener can be pulled into App Router client bundles by compatibility imports, where it races the App Router history runtime and can turn back/forward traversal into a document navigation.
Move Pages Router popstate installation behind an internal vinext runtime module that only Pages Router entrypoints call after hydration setup. Keep the existing popstate decision logic in the router shim, but make global listener ownership explicit at the Pages boundary.
Add unit coverage for side-effect-free next/router imports and an App Router e2e regression that imports next/router, traverses history, and asserts no document request occurs.
* refactor(router): remove redundant beforePopState assertion
The Pages Router popstate handler already narrows the beforePopState callback before invoking it. Call the narrowed function directly so the production router code does not carry an unnecessary type assertion.
* fix(script): dedupe concurrent same-src loads
Multiple next/script components with the same src could append duplicate DOM scripts when they mounted before the first script load event fired. That double-executed third-party code and diverged from Next.js' in-flight script cache behavior.
The shim now tracks remote scripts that are currently loading separately from scripts that have completed loading, and fans out load/error callbacks without adding another script element.
Adds a Pages Router browser regression that verifies simultaneous same-src scripts produce one DOM script and one execution.
* fix: address review feedback for script load dedupe
- Defer loadedScripts.add(key) to after in-flight promise resolves
- Add onReady() callback for duplicate callers joining in-flight load
- Clean up loadingScripts entry on promise settlement to allow retry
* fix(link): preserve native URI scheme navigation
Link treated only http, https, and protocol-relative hrefs as browser-owned. Native schemes such as mailto:, tel:, and sms: were therefore locale-prefixed, eligible for prefetch normalization, and intercepted by client navigation.
The URL boundary now mirrors Next.js absolute URL scheme classification while preserving vinext's protocol-relative handling. Link rendering, click interception, relative resolution, and prefetch normalization all share that classification so non-local schemes stay with the browser while same-origin absolute URLs remain routable.
Adds focused regression coverage for native scheme rendering, click handling, and prefetch decisions.
* refactor(router): reuse shared URL scheme detection
Router and navigation shims already had local external URL predicates with the same browser-owned URL semantics now used by Link.
Reuse the shared url-utils predicate for Pages Router locale handling, Pages Router external detection, App Router external detection, and App Router prefetch normalization. Add focused shim coverage for native URI schemes through the Pages Router helpers.
* test(link): add hasAttribute stub to native URI scheme click event
The merge of main introduced a download-attribute check in handleClick that
runs before the native-URI early return, so test click events must provide
currentTarget.hasAttribute.
---------
Co-authored-by: James <james@eli.cx>
Priority images currently only render eager loading and high fetch priority. That misses the resource hint Next.js emits, so the browser cannot discover the optimized image until it reaches the img element.
The image shim now calls ReactDOM.preload with the final optimized image URL, srcset, sizes, and fetch priority metadata for priority and preload images. Focused image component coverage asserts the SSR preload hint and the modern preload prop behaviour.
Co-authored-by: James <james@eli.cx>
* fix(config): provide CJS globals when loading next.config.ts
Next.js's `next.config.ts` loader transpiles the file to CommonJS via SWC
and evaluates it through Node's `Module._compile`, which exposes the CJS
globals (`__filename`, `__dirname`, `module`, `exports`, `require`) even
when the source uses ESM syntax. vinext previously loaded the config
through Vite's ESM-only module runner, so configs that read `__dirname`,
called `require()`, or used `module.exports` threw `ReferenceError`s
during build.
Inject the same globals via a small Vite `transform` plugin that runs
only on the `.ts`/`.mts`/`.cts` config entry point. Reassigning
`module.exports` is preserved by exposing the wrapper object as a named
export and reading it back in `unwrapConfig`.
Reference: packages/next/src/build/next-config-ts/{transpile-config,require-hook}.ts
* fix(config): guard CJS-globals injection on static reference check
Skip cjsGlobalsInjectorPlugin's transform entirely when the next.config
source contains no bareword reference to __filename, __dirname, require,
module, or exports. The vast majority of next.config.ts files are pure
ESM (every upstream `test/e2e/app-dir/next-config-ts` fixture except
node-api-cjs is) and pay no cost from the transform.
Substring matching is intentional: a false positive (e.g. the word
`module` inside a comment or `node:module` specifier) costs at most one
wasted, idempotent transform, while a false negative would be a
correctness regression. Bareword `\b` boundaries already exclude
`requireSomething` and `myModule` style identifiers, and `\bexports\b`
does not match `export default`.
* fix(config): skip module.exports wrapper when source uses ESM-only
Statically detect whether a `next.config.ts` source assigns to
`module.exports` (via `module.exports =`, `module.exports.foo =`, or
`module.exports[...] =`) with a new `reassignsModuleExports` helper.
Only emit the `__vinext_cjs_exports` named export and its companion
initial-reference export when that check matches. Pure-ESM configs —
the overwhelming majority — get a slightly smaller injected preamble
and a simpler unwrap path that just reads the ESM `default` export.
The previous sentinel-on-module.exports approach tagged the initial
exports object with a `Symbol.for("vinext:cjs-initial")` property on
every config load and checked for it during unwrap. That worked but
ran unconditionally and left a stray symbol-keyed property on the
final exports object whenever the user reassigned. The new shape only
pays for the wrapper when the source plausibly needs it, and keeps a
referential-identity check (`module.exports !== __vinext_cjs_initial_exports`)
as belt-and-braces so that a `reassignsModuleExports` false positive
inside a string or comment still falls back to the ESM default
instead of returning an empty `{}`.
* fix(image): clear blur placeholder after image load
Blur placeholders currently stay on the img background after the high resolution image finishes loading. That is wrong for transparent image formats because the low quality placeholder remains visible through the rendered pixels.
The shim treated placeholder styling as static props instead of load lifecycle state. Track blur completion per src, clear local and remote placeholder backgrounds after successful load or error, and cover the transparent-image regression in an app-router browser test.
* chore: rerun ci
---------
Co-authored-by: James <james@eli.cx>
* fix(link): normalise href for trailingSlash config
Port `normalizePathTrailingSlash` from Next.js (client/normalize-trailing-slash.ts)
into a small util under `shims/url-utils.ts`, wire the resolved `trailingSlash`
config through a new `process.env.__VINEXT_TRAILING_SLASH` build-time define,
and apply it inside `<Link>` so the rendered `href` matches the canonical URL
form instead of relying on a 308 redirect bounce.
This unblocks the upstream `e2e/trailing-slashes/*` suite, which asserts on
rendered `<Link>` hrefs (not just redirect Location headers).
* fix(link): normalise href again after applying basePath
Matches Next.js's `addBasePath`, which runs `normalizePathTrailingSlash`
after prefixing. Without this second pass, joining a non-empty basePath
with the bare root (`/`) under `trailingSlash: false` would render
`/foo/` instead of the canonical `/foo`.
* Update packages/vinext/src/shims/url-utils.ts
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
---------
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
Pages Router useRouter previously owned a hook-local router snapshot and subscribed each hook call to vinext:navigate. That diverged from Next.js RouterContext semantics and made mounted hook count scale global listeners.
Move the reactive snapshot into the Pages Router provider, make next/router consume RouterContext like Next.js, and keep singleton navigation methods as the shared command surface. Add regression coverage for context reads, missing context errors, and per-hook listener scaling.
Co-authored-by: James <james@eli.cx>
App Router popstate currently sends every history traversal through __VINEXT_RSC_NAVIGATE__, even when the target pathname and search already match the committed route and only the hash entry changed. That violates the hash-only traversal invariant and replays cached RSC payloads unnecessarily.\n\nCompare popstate targets against the committed App Router pathname and search before dispatching an RSC traversal. Same-route entries now restore scroll directly, while real route traversals keep the existing pending-navigation coordination.\n\nAdd an App Router E2E regression that wraps __VINEXT_RSC_NAVIGATE__ and verifies hash back/forward traversal does not call it.
Co-authored-by: James <james@eli.cx>
* fix(router): derive shallow dynamic params from the URL
Pages Router shallow navigation across one dynamic route template updated the browser URL but kept router.query params from the original __NEXT_DATA__ payload. That left client state stale after transitions such as /posts/42 to /posts/43.
The router now matches the current pathname against the hydrated route pattern before falling back to __NEXT_DATA__.query. This preserves shallow data-fetch skipping while keeping dynamic params aligned with the visible URL.
Tests cover the router shim boundary and the browser-visible Pages Router shallow transition.
* refactor(router): reuse route-pattern matching for shallow params
The shallow dynamic params fix originally duplicated route-pattern parsing inside the router shim.
Reuse the existing routePatternParts and matchRoutePattern helpers instead, keeping the __NEXT_DATA__ fallback behavior unchanged.
---------
Co-authored-by: James <james@eli.cx>
* fix(css): pass sassOptions from next.config to Vite preprocessor
Next.js threads `sassOptions` from `next.config.js` into sass-loader,
destructuring `prependData` / `additionalData` / `implementation` and
forwarding the rest (e.g. `includePaths`, `loadPaths`) as Sass options.
vinext was dropping this entirely, so SCSS fixtures that rely on
`additionalData` to define variables, `includePaths`/`loadPaths` to
resolve partials, or a custom `implementation` all failed to build
with `Undefined variable` / `Can't find stylesheet to import`.
Resolve `sassOptions` from next.config into `ResolvedNextConfig`, then
translate it onto Vite's `css.preprocessorOptions.scss` (and `.sass`)
shape:
- prependData precedence matches Next.js (prependData || additionalData)
- legacy includePaths is merged into modern loadPaths (Vite uses
modern Sass, which dropped includePaths)
- implementation and any remaining Sass options are passed through
Affected upstream fixtures:
- test/e2e/app-dir/scss/basic-module-include-paths
- test/e2e/app-dir/scss/basic-module-additional-data
- test/e2e/app-dir/scss/basic-module-prepend-data
Refs: cloudflare/vinext CI run 25951111875
Source: packages/next/src/build/webpack/config/blocks/css/index.ts
* fix(css): address sass passthrough review feedback
- Drop `export` on `VitePreprocessorOptions` so Knip stops flagging it
as unused (CI Check blocker).
- Use truthy-OR (`prependData || additionalData`) to match Next.js's
`sassPrependData || sassAdditionalData` exactly — empty-string
`prependData` now falls through to `additionalData` instead of
overriding it.
- Widen `additionalData` type to accept Vite's function form
(`(source, filename) => string | Promise<string>`) and drop the
inaccurate `as string` cast.
- Add tests covering the empty-`prependData` fallthrough and the
function-form `additionalData` passthrough.
router.refresh() currently skips the pending browser router state even when it is dispatched from a React transition. That lets useTransition return to idle while the refresh RSC request is still in flight.
The refresh path should follow the same App Router invariant as other programmatic navigations: publish a deferred router-state promise before the request and resolve it when the refreshed tree commits.
Allow programmatic refresh navigations to create pending router state, and cover the behaviour with a browser regression that observes isPending during a delayed same-route refresh.
Replaces the bare-side-effect install in edge-globals.ts with an exported
installEdgeGlobals() function, called explicitly from each runtime entry
that may evaluate user edge code (App Router worker entry, middleware
runtime, Pages API route).
Also documents that URLPattern is intentionally NOT installed: Cloudflare
Workers expose it natively, and CI now pins Node 24+ (#1283), which has
the global URLPattern. The TODO(edge-globals) marker is dropped.
Remote Image with fill=true currently routes through Unpic fullWidth, which renders a flowing image without data-nimg="fill" or absolute positioning. That violates Next.js's fill invariant for parent-filling layouts.
Centralize fill styles and render remote fill with the same DOM contract as local fill and getImageProps. Add SSR regression coverage for remote fill.
Vinext strips dangerous Link href values before rendering the anchor. That keeps javascript: URLs inert, but the unsafe render branch also dropped the developer-provided onClick handler, so links used as click targets stopped working after hydration.
The dangerous-href branch now preserves the anchor ref, link status provider, and user click handler while continuing to omit href. The regression fixture verifies that the click handler runs and the browser URL stays unchanged.
Link currently treats anchors with the HTML download attribute like normal same-origin SPA navigations. That prevents the browser from performing its native download behavior and can also run onNavigate for a click that should never become client-side navigation.
The click handler now bails out after user onClick and before preventDefault whenever the anchor has a download attribute, matching Next.js Link semantics. A focused regression test covers the default action, onNavigate, transition, and RSC navigate boundaries.
Next.js's edge sandbox installs `AsyncLocalStorage` on the runtime's
global context (see `packages/next/src/server/web/sandbox/context.ts` —
`context.AsyncLocalStorage = AsyncLocalStorage`). vinext executes user
code directly on the Cloudflare Workers runtime, which only exposes
`AsyncLocalStorage` via `import { AsyncLocalStorage } from "node:async_hooks"`
under the `nodejs_compat` flag, not as a global.
Edge fixtures like `.nextjs-ref/test/e2e/edge-async-local-storage/` do
`new AsyncLocalStorage()` without an import and were failing with
`ReferenceError: AsyncLocalStorage is not defined`.
Install `AsyncLocalStorage` on `globalThis` (idempotently) from every
entry point that may evaluate user edge code: the App Router worker
entry, the middleware runtime, and the Pages API route runtime.
A TODO marks the spot for future consolidation with the parallel
`URLPattern` work into a single `installEdgeGlobals()` helper.
* fix(app-router): emit streamed redirect and not-found meta tags
Streaming App Router SSR currently returns a 200 once the shell commits, but redirect() or notFound() discovered inside a later Suspense boundary never emits the browser-visible meta tag that Next.js relies on. That leaves the control-flow digest only in the RSC payload until client hydration handles it.
The SSR onError path now captures digest-bearing render errors and a request-local flusher injects noindex or refresh meta tags through the existing server-inserted HTML stream hook. The app page pipeline also forwards basePath into SSR so streamed redirect meta tags match early redirect responses.
* fix(app-router): avoid double-prefixing basePath root redirects
Streamed redirect meta tags checked basePath against the full redirect location string. Already-prefixed root URLs with a query or hash, such as /docs?from=checkout, failed the pathname-shaped basePath check and were prefixed a second time.
Add a shared pathname-only basePath prefix helper and use it from both early redirect responses and streamed redirect metadata. The streamed path now splits query/hash before checking the pathname.
External history.pushState and history.replaceState currently store caller data as-is. That drops vinext's App Router previousNextUrl marker when apps perform shallow URL updates inside an intercepted route, so later traversal can lose the interception source.
The History API wrappers now copy the current App Router metadata onto caller-provided state before delegating to the browser history method. A focused shim regression covers object, null, and undefined caller state against the same contract Next.js preserves.
Lets workflow_dispatch runs target a single suite (e.g.
test/e2e/edge-async-local-storage) instead of the full ~16-shard run,
shortening the iteration loop when investigating a specific failure.
The compat-ingest step is gated off for path-filtered runs so partial
snapshots don't drag the historical pass-rate trend.
* fix(link): reschedule visible prefetches
Visible App Router links were prefetched as one-shot observer callbacks. That lost the mounted visibility state needed to re-prefetch after cache invalidation or router state changes, and automatic dynamic links could not be upgraded to a full prefetch on hover.
The Link shim now keeps mounted prefetch instances, tracks the visible set, exposes an internal ping hook for cache and router commits, and persists the full-prefetch strategy after unstable_dynamicOnHover intent.
Regression coverage verifies visible-link cache invalidation and dynamic-on-hover strategy upshift.
* fix(link): preserve pages intent prefetch
Pages Router Link prefetch=false should only disable viewport prefetch. The shared gate also blocked hover and touch intent prefetches, which matches App Router but diverges from the Pages Router contract.
Split intent eligibility by router mode so App Router keeps the stricter gate while Pages Router intent still prefetches in production. Pages viewport prefetch remains one-shot per mounted link to avoid duplicate document prefetch nodes under the visible-link registry.
Regression coverage verifies App Router prefetch=false intent suppression, Pages Router hover and touch intent prefetch, and Pages viewport re-entry dedupe.
`createRequire` of a `.js` file in a `"type": "module"` package still gets
resolved as ESM by Node (extension + parent type wins over the require()
caller), so the existing CJS fallback in loadNextConfig threw `require is
not defined` for every plain `module.exports = {}` config after `vinext
init`. Worked around in the Nextjs deploy suite by rewriting every
fixture's config from CJS to ESM in-place via scripts/cjs-to-esm-config.mjs.
Copy the source to a sibling temp `.cjs` (extension overrides the parent
type field) and require that instead. Relative imports inside the config
still resolve against the original directory. Drop the deploy-suite
rewrite step and its 145-line shim.
Both useRouter() and the Router singleton carried near-identical push/replace
implementations (~250 lines combined). Extract a shared performNavigation()
helper plus small primitives (updateHistory, dispatchNavigateEvent,
extractHash, prefetchUrl). Behaviour and event ordering are preserved.
Metadata title defaults currently render without the active ancestor template. That diverges from Next.js when a child layout or page provides title.default under a parent layout title.template.
The merge path resolved only the final title after collecting templates, so object defaults skipped the stashed template used for that segment. Resolve each title as it is encountered against the current ancestor template, then stash the current layout template for descendants.
Covers the child-layout default regression and updates page-default expectations to match Next.js resolveTitle semantics.
* fix(server): render progressive action not-found HTML
Progressive multipart server actions returned an empty status response when an action called notFound() or another HTTP fallback. That diverged from Next.js' MPA action path, where the action error re-enters app rendering so the not-found boundary produces full HTML.
The root cause was treating HTTP fallback action throws as terminal responses in the action execution helper. Pass action failures through the progressive form-state path, preserve redirects as 303 responses, and rethrow action failures during page element construction so existing special-error rendering handles the response body.
Regression coverage asserts the action helper handoff and the rendered not-found HTML body.
* chore: retrigger CI
* fix(server): forward progressive action failures in RSC entry
* refactor(server): make progressive action failure state explicit
* Apply suggestion from @james-elicx
---------
Co-authored-by: James Anderson <james@eli.cx>
* fix(router): decode hash scroll targets
Hash-only client navigation looked up raw URI fragments directly. That meant ids such as "hello world" and browser-compatible named anchors were skipped when links used encoded fragments or legacy anchors.
The router shims now share the decoded id, named-anchor fallback, and top-fragment behavior already used by the app browser popstate path. Regression coverage exercises hash-only Pages Router pushes for decoded ids and name fallback.
* fix(router): respect scroll false for hash navigation
Match Next.js Pages Router behavior by gating hash target scrolling behind the scroll option for both hash-only and hash-bearing navigations.
Add regression coverage for hash-only Router.push with scroll disabled.
* fix(server): progressive actions trigger error boundaries on HTTP fallbacks
* fix(server): type progressive action errors
* fix: guard stale popstate scroll restoration with active nav id
* revert(server): drop action error changes from popstate PR
The popstate race branch included server action commits from a separate branch. That made the PR review surface change progressive action error semantics unrelated to browser history restoration.
Restore those files to main's behavior in this branch and keep only the popstate scroll restoration fix. Also correct the new popstate tests so targeted check and unit coverage pass.
* test(app-router): wait for RSC cache seeding
The same-build visited payload E2E asserted cache replay after visible commit from a void router.push. The visited-response cache is populated when the internal RSC navigation promise resolves, so the test could navigate away before the cache entry existed and count a second fetch.
Wrap the internal navigation function in the test and wait for the captured promise before asserting the replay path.
* refactor(navigation): extract UnrecognizedActionError into a shared module
`UnrecognizedActionError` and `unstable_isUnrecognizedActionError` were
defined inline in the `next/navigation` shim. The client server-action
dispatcher needs to throw that exact class for the predicate's
`instanceof` check to recognize it, but `instanceof` is identity-based
per module instance, and importing the whole navigation shim into the
dispatcher would be wrong layering.
Move both into a dependency-free `shims/unrecognized-action-error.ts`,
mirroring Next.js's own `unrecognized-action-error.ts`. The navigation
shim re-exports them, so the public `next/navigation` surface is
unchanged.
No behavior change; existing predicate tests still pass.
* fix(app-router): throw a typed error for unrecognized server actions
When a server action POST returns the `x-nextjs-action-not-found`
response header, the App Router client dispatcher threw a plain `Error`.
`unstable_isUnrecognizedActionError` is an `instanceof UnrecognizedActionError`
check, so it returned `false` for the framework's own action-not-found
path. Client `catch` blocks and error boundaries that rely on the
predicate to detect client/server deployment skew never fired, which is
the exact case the predicate exists for.
Next.js's server-action reducer throws `new UnrecognizedActionError(...)`
on this same header. vinext exposed the class and predicate but never
constructed the error from its dispatcher.
Add `throwOnServerActionNotFound`, the client-side counterpart of
`createServerActionNotFoundResponse`, and call it from the client
server-action callback so the thrown error is recognizable. The two
internal helpers it subsumes are no longer exported.
Tests feed the real server-emitted response through the helper and
assert the public predicate recognizes the result.
* feat(cache): classify private and dynamic render downgrades
Render observations recorded dynamic request usage but did not carry an owned cacheability downgrade decision. That left future cache proof consumers to infer privacy from raw request API observations and cacheability strings.
Add pure downgrade classification for public variants, private request state, auth/session dimensions, uncacheable draft state, dynamic fetches, and incomplete observations. Bump the cache proof schema for the serialized observation shape and keep the disabled proof model as the only reuse decision.
* chore(cache): rename public cache candidate flag
Inlined App Router Flight data could only travel through string chunks. That corrupted arbitrary binary RSC chunks during SSR hydration and static prerender extraction because non-UTF-8 bytes were decoded to replacement characters before React read them.
Encode non-UTF-8 embedded chunks as base64 binary records, decode those records back to bytes in the browser, and write prerendered .rsc payloads as bytes instead of UTF-8 text.
Regression coverage exercises server embedding, browser reconstruction, and prerender extraction for binary RSC chunks.
* fix(app-router): promote default slot persistence through route state
Default and unmatched parallel slots were still inferred from AppElements transport markers. That made soft navigation persistence depend on wire shape instead of planner-owned route state, which breaks the #726 invariant that AppElementsWire is transport only.
Thread slot binding metadata through AppElements, browser state, and the navigation planner. The commit boundary now preserves previous slot content and binding proof only when target route-state marks the slot default or unmatched and the visible state proves a mounted/default value exists.
Tests cover the new planner contract, metadata validation, route wiring, mergeElements behavior, and browser commit lifecycle for default and unmatched slot targets.
* fix(app-router): align intercepted slot binding metadata
Intercepted slot payloads could render active modal content while their route-state metadata still described the slot as default. The browser planner could then preserve the previous default slot for an ID that the fresh payload should replace, breaking intercepted navigations in app-router E2E.
Derive slot IDs through one helper for both metadata and rendering, assert graph/wire ID divergence, and compute binding state from the same resolved override default export used by the render path. Move the app hydration marker to the committed root layout effect so E2E link clicks wait for a hydrated tree.
* fix(app-router): harden slot binding preservation
Review follow-up for #726-CORE-14. Centralize AppElements slot binding normalization so duplicate slot ids and stale owner layout ids fail at the wire boundary instead of creating quiet last-write-wins behavior.
Alias planner slot binding snapshots to the AppElements binding shape, share slot id ordering helpers, document the no-proof compatibility law, and add negative planner/wire tests plus a browser-visible soft navigation E2E. Refs #726.
* fix(app-router): align slot binding proof with rendering
* docs(app-router): clarify slot preservation invariants
next/navigation useRouter returned the module-level app router singleton even when no AppRouterContext provider was mounted. That masked components rendered outside the App Router boundary, diverging from Next.js and hiding integration bugs.
The shim now reads the mounted AppRouterContext and throws the Next.js invariant when it is absent. The App Router browser and SSR roots provide the existing appRouterInstance so legitimate app renders keep the same public router surface.