Commit Graph

1925 Commits

Author SHA1 Message Date
JamesbbBriz 7075d210e7 feat(cli): native App Router route type generation (#1144)
* Add native App Router route typegen

* Address route typegen review feedback

* Respect typegen target directory

* Require explicit LayoutProps route

* Serialize route type regeneration

* Derive layout params from route graph

* Scope layout slot typegen by route group

* Map typegen slots to owner layouts

* Preserve slot-local layout type keys

* Generate next-env.d.ts during typegen

* Address review feedback for route typegen

* Defer route sort and tighten typegen helpers

* Stabilize Windows create-next-app smoke check

* Address route typegen review fixes

* Match Next route type import

* Stabilize Windows create-next-app smoke

* Skip redundant route param shape writes

* Defer dev route type generation
2026-05-20 10:45:51 +01:00
Nathan Nguyen fa1392616d fix(pages-router): preserve i18n client root navigation (#1323)
* fix(pages-router): preserve i18n client root navigation

Pages Router client navigation fetched the unprefixed root URL even when the transition resolved to the default locale. That let root Accept-Language detection redirect a link click from /new to the preferred locale path, diverging from Next.js.

Use the active locale for implicit Link and Router transitions, fetch default-locale root HTML through a locale-qualified internal URL while preserving the visible unprefixed URL, and parse i18n __NEXT_DATA__ scripts that append locale globals.

* fix(pages-router): preserve i18n client root navigation

Pages Router client navigation fetched the unprefixed root URL even when the transition resolved to the default locale. That let root Accept-Language detection redirect a link click from /new to the preferred locale path, diverging from Next.js.

Use the active locale for implicit Link and Router transitions, fetch default-locale root HTML through a locale-qualified internal URL while preserving the visible unprefixed URL, and parse i18n __NEXT_DATA__ scripts that append locale globals.

* fix(pages-router): validate next data parsing

* fix(pages-router): preserve link locale transitions

* fix(pages-router): preserve popstate locale fetch

* chore(pages-router): address locale review nits
2026-05-20 10:40:58 +01:00
Nathan Nguyen 705da9a52f feat(cache): introduce static layout artifact reuse proof primitive (#1307)
* Enable static layout artifact reuse proof

* fix(cache): reject unproven static layout variants

Static layout reuse could previously accept public or internal candidate variant dimensions without proving that the current request had the same canonical dimensions. Public visibility only means the values are safe to expose, not that the layout artifact is invariant across routes, params, search state, or other dimensions.

Reject dimensioned static layout candidates until the API can compare candidate and current variants by canonical dimension source, name, privacy, and value hashes. Make cache outcome recording best-effort so telemetry failures cannot turn a cache hit into a miss or escape the read path.

* fix(cache): require route-budgeted layout reuse candidates

Static layout artifact reuse could still accept a raw buildCacheVariant result at the authority boundary. That allowed callers to bypass route-budget admission before receiving a canReuse true decision.

Require BuildCacheVariantWithRouteBudgetResult for createStaticLayoutArtifactReuseDecision and add a type-level regression that raw variants do not satisfy the authority input.

* chore(cache): clarify static layout proof traces

The approved review called out non-blocking ambiguity in trace fields and telemetry documentation. The proof behavior is unchanged, but the trace output and type regression are easier to reason about.

Sort unproven dimension sources defensively, rename artifact compatibility trace fallback fields, document raw app-page cache keys as internal-only, and make the raw-variant type regression explicit without a dead branch.

* chore(cache): dedupe static layout dimension sources

The unproven-dimension trace already sorted sources, but duplicate source labels could still make traces noisier than necessary. Use the existing sortedUnique helper so the source summary is deterministic and compact while dimensionCount remains exact.
2026-05-20 10:36:20 +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
Nathan Nguyen 3262443f0b fix(pages-api): execute edge API routes with Fetch Request (#1320)
* fix(pages-api): execute edge API routes with Fetch Request

Pages API routes with config.runtime set to edge were still executed through the Node-style req/res adapter. That gives user handlers a plain headers object and turns valid Edge API code that returns a Response into a 500.

Detect edge and experimental-edge API route modules before Node body parsing, pass the Fetch Request through, and forward the returned Response. The dev Node bridge mirrors the generated Web Request path and preserves Set-Cookie response headers.

Regression coverage ports the relevant Next.js edge-async-local-storage contract for Fetch Request execution, concurrent AsyncLocalStorage isolation, and the dev bridge.

* fix(pages-api): stream edge API responses in dev

* fix(pages-api): handle closed dev edge streams

* fix(pages-api): stream dev edge request bodies

* fix(pages-api): validate dev edge forwarded protocol
2026-05-19 18:12:42 +01:00
Nathan Nguyen afc549d624 fix(router): normalize trailing slash parity (#1316)
Trailing slash handling treated every non-api path the same on server redirects, so trailingSlash:true added slashes to file-looking catch-all routes and Pages Router imperative navigation skipped the client canonicalization path used by Link.

Next.js splits the invariant between route-manifest redirects and client resolveHref normalization: file-looking paths lose a trailing slash, non-file paths gain one, and queries stay attached to the canonical pathname. Share the server redirect decision across Pages Router runtimes and apply the same client helper in Router.push and Router.replace.

Adds focused regressions for catch-all dot segments, query preservation, .well-known exclusion, and Pages Router history writes.
2026-05-19 18:06:36 +01:00
Nathan Nguyen 5b6b764bbd fix(app-router): prerender layout static params (#1318)
* fix(app-router): prerender layout static params

* address review: uniform resolver contract, sentinel comment, drop defensive clamp

---------

Co-authored-by: James <james@eli.cx>
2026-05-19 18:03:16 +01:00
Nathan Nguyen 1551f91cbd fix(metadata): match Next dynamic route semantics (#1317)
* fix(metadata): match Next dynamic route semantics

Metadata routes diverged from Next.js in several observable cases: robots.txt emitted Sitemap before Host, dynamic metadata Response results could miss framework cache headers, TSX sitemap files were not discovered, and rendered metadata URLs treated canonical, manifest, and file-based social image routes as the same URL class.

The implementation now follows Next's metadata route extension, cache, robots, and URL resolution rules while keeping file-based social image route markers non-enumerable so resolved metadata shape does not leak internal state.

* fix(metadata): gate social image preview fallback

File-based social image routes used the preview deployment URL whenever VERCEL_URL was present outside development. That let deployment URLs override an explicit metadataBase in non-preview production environments.

Gate preview URL fallback on VERCEL_ENV=preview, add coverage for metadataBase precedence and production fallback URLs, and keep static metadata image cache headers stable in development.
v0.0.51
2026-05-19 14:11:47 +01:00
Nathan Nguyen e0eabc122b feat(router): add manifest-backed interception topology (#1314)
* feat(router): add manifest-backed interception topology

* test(router): cover concrete dynamic interception proof ids

* feat(router): index interception facts by slot
2026-05-19 13:15:31 +01:00
James Anderson 9415a238f4 feat(config): implement assetPrefix (#1311)
* feat(config): parse and apply next.config assetPrefix at build time

Wire `assetPrefix` from next.config through to the Vite build so every
emitted JS/CSS/image/static asset URL is prefixed accordingly. Mirrors
Next.js semantics: `assetPrefix` controls asset URLs only, independent
of `basePath` (which still controls route URLs).

* Add `assetPrefix` to NextConfig and ResolvedNextConfig, normalised via
  a new `normalizeAssetPrefix` helper that trims trailing slashes,
  accepts absolute URLs (`https://cdn.example.com[/sub]`) and path
  prefixes (`/custom-asset-prefix`), and rejects non-string / unparseable
  values.
* New `packages/vinext/src/utils/asset-prefix.ts` module collects the
  shared helpers: `resolveAssetsDir` (on-disk layout), `resolveAssetUrlPrefix`
  (emitted URL prefix), `assetPrefixPathname` (path portion for runtime
  matching), and the canonical `_next/static` directory constant.
* In the Vite plugin, set `build.assetsDir` so on-disk layout matches the
  URL (`<prefix>/_next/static/` for path prefixes, `_next/static/` for
  absolute URLs). Use `experimental.renderBuiltUrl` to override Vite's
  default `base + url` URL emission so `assetPrefix` and `basePath` stay
  independent — routes continue to anchor at `base = basePath + '/'`.
* Persist `assetPrefix` in the embedded `vinextConfig` for the Pages
  Router server and in a new `vinext-runtime-config.json` sibling for
  the App Router prod server (the RSC entry only embeds `__basePath`).

@see https://nextjs.org/docs/app/api-reference/config/next-config-js/assetPrefix

* feat(server): serve assetPrefix-prefixed assets at runtime

Update the production server to resolve incoming asset requests under
`<assetPrefix>/_next/static/<filename>` (path-prefix form) and
`/_next/static/<filename>` (absolute-URL form) in addition to the
historical `/assets/` URLs. Both routers (Pages and App) share a new
`resolveAppRouterAssetPath` helper that returns the lookup path on disk
or `null` to fall through.

* App Router prod server loads `assetPrefix` from the new
  `vinext-runtime-config.json` sibling written by the Vite plugin.
* Pages Router prod server reads `assetPrefix` from the embedded
  `vinextConfig` and threads it through the existing
  `stripBasePath` lookup.
* StaticFileCache marks any path under `_next/static/` as content-hashed
  (alongside the existing `assets/` rule) so emitted bundles get the
  long-lived `immutable` cache headers. Same change in the slow-path
  branch of `tryServeStatic`.

Cloudflare Workers needs no code change: the ASSETS binding serves
`<prefix>/_next/static/...` directly from `dist/client/<prefix>/_next/static/...`
because the on-disk layout already mirrors the URL. Absolute-URL
prefixes are expected to be served by the CDN, so they don't reach the
worker.

* test(asset-prefix): add unit + e2e build tests for next.config assetPrefix

Covers the surface area added in the previous two commits:

* Unit tests on the new helpers: `normalizeAssetPrefix` (path vs URL,
  trailing-slash trimming, validation), `resolveAssetsDir` /
  `resolveAssetUrlPrefix` (on-disk vs emitted-URL layout),
  `assetPrefixPathname` (runtime matching), and
  `resolveAppRouterAssetPath` (request → on-disk lookup translation).
* End-to-end build tests that exercise `vinext build` against the
  `tests/fixtures/app-basic` app patched with each `assetPrefix` shape:
    - Path prefix (`/custom-asset-prefix`): SSR HTML references
      `/custom-asset-prefix/_next/static/` URLs, on-disk layout matches,
      and fetching the bootstrap URL from the prod server returns 200.
    - Absolute URL (`https://cdn.example.com`): emitted asset URLs are
      fully qualified; no `/assets/` leaks.
    - Combined `basePath: '/app'` + `assetPrefix: '/cdn-prefix'`: routes
      stay under `/app/` while asset URLs live under `/cdn-prefix/` —
      proving the two configs stay independent.
    - Unset (default): historical `/assets/` layout is preserved.

Ported from Next.js: test/e2e/app-dir/asset-prefix/asset-prefix.test.ts
https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/asset-prefix/asset-prefix.test.ts

* test(next-config): add assetPrefix field to makeResolved helper

ResolvedNextConfig requires assetPrefix; the test helper was missing
it which broke the type check.

* fix(config): replace polynomial regex with loop (CodeQL)

CodeQL flagged trim().replace(/\/+$/, "") and replace(/^\/+/, "")
as polynomial-time backtracking on uncontrolled input. Swap both for
explicit while loops with linear-time semantics. Same fix applied to
the URL pathname trim in assetPrefixPathname().

* refactor(prod-server): drop dead null fallback; tighten hashed-asset detection

- Remove the `?? null` after resolveAppRouterAssetPath; the helper
  already returns null on miss, so the fallback (and its obsolete
  comment) were no-ops.
- Add explicit startsWith("_next/static/") / startsWith("/_next/static/")
  prefix checks before the substring fallback in both static-file-cache
  and prod-server. The substring check stays so assetPrefix-prefixed
  layouts (e.g. "cdn/_next/static/") are still recognised.

* fix(build/precompress): use resolved assetsDir so assetPrefix builds get compressed

precompressAssets() hardcoded `<clientDir>/assets/`. When `assetPrefix`
is configured the build writes hashed assets to a different subdirectory
(`<prefix>/_next/static/` for path prefixes, `_next/static/` for
absolute URLs) and the precompression step walked an empty directory,
silently emitting zero .br/.gz/.zst files. Cloudflare Workers builds
with assetPrefix would then serve uncompressed assets.

Thread the resolved subdirectory through from resolveAssetsDir(assetPrefix)
at the plugin call site, default to `"assets"` for backward compat,
and add focused tests for both assetPrefix layouts.

* refactor: drop redundant isHashed startsWith; harden test cleanup against build failures

- prod-server.ts: drop the redundant `pathname.startsWith("/_next/static/")`
  check; `pathname` always has a leading slash so the existing
  `includes("/_next/static/")` already covers it.
- static-file-cache.ts: keep both `startsWith("_next/static/")` and
  `includes("/_next/static/")` because `relativePath` has no leading
  slash — the prefix and substring checks cover different layouts
  (absolute-URL prefix vs path-prefix). Clarify the comment.
- tests/asset-prefix.test.ts: register the tmp-dir cleanup with the
  shared `cleanups` array synchronously inside `buildFixtureWithConfig`,
  right after `mkdtempSync`. Previously the cleanup was only pushed
  after the build returned, so a thrown `createBuilder`/`buildApp`
  would leak the tmp dir.

* refactor(build): embed assetPrefix in App Router entry instead of sidecar JSON

Mirrors the existing __basePath embed pattern (see entries/app-rsc-entry.ts)
and how Pages Router exposes `vinextConfig.assetPrefix` from its own entry.
The runtime-config.json sidecar was unnecessary — the App Router entry
template already inlines config values as compile-time constants, so
assetPrefix should be inlined the same way.

Changes:
- Add `assetPrefix` to `AppRouterConfig` in entries/app-rsc-entry.ts.
- Emit `export const __assetPrefix = <json>` from the generated RSC
  entry alongside the existing `__basePath` / `__configHeaders` /
  `__expireTime` constants. Single-line declaration to stay within the
  "thin generated entry" guideline.
- Thread `nextConfig.assetPrefix` through the generateRscEntry call site
  in index.ts.
- App Router prod-server reads `rscModule.__assetPrefix` (with a string
  fallback for older builds) instead of `vinext-runtime-config.json`.
- Delete the vinext-runtime-config.json write in index.ts. Nothing else
  in the repo reads this file (verified via rg on packages/ tests/
  examples/).

Pages Router behaviour is unchanged: it already gets assetPrefix from
the embedded `vinextConfig` exported by pages-server-entry.ts.

Addresses james-elicx review feedback on #1311.

* fix(ssr): emit bootstrap as <script src> via bootstrapModules

The App Router SSR pipeline previously passed only `bootstrapScriptContent`
to React's renderToReadableStream, producing inline
`<script>import("/path/to/entry.js")</script>` tags. The URL was therefore
inaccessible to anything that reads `script.attribs.src` — including the
Next.js asset-prefix fixture test "bundles should return 200 on served
assetPrefix" which iterates script tags looking for a `src` attribute
prefixed by the configured assetPrefix.

Extract the bootstrap URL from the plugin-rsc-generated content and pass
it as `bootstrapModules: [url]` so React emits a real
`<script type="module" src="url">` tag. The existing
`extractModulePreloadHtml` helper already did the same extraction for
the modulepreload <link>, so the URL was already exposed (just renamed
`extractBootstrapModuleUrl` and lifted out of the head-injection helper
to share between the bootstrap and the preload tags).

The inline `bootstrapScriptContent` is no longer needed — it was only
carrying the dynamic-import call that bootstrapModules now performs
natively. Passing both would load the bootstrap module twice.

`bootstrapModules` works in both dev and prod: in dev, plugin-rsc emits
a Vite dev-server URL (e.g. `/@id/__x00__virtual:vinext-app-browser-entry`)
that the browser fetches as a module from the dev server; in prod it's the
hashed bundle URL, optionally prefixed by `assetPrefix`.

Mirrors Next.js's app-render path which combines both
`bootstrapScriptContent` (for session state) and `bootstrapScripts`
(for the actual chunk URL). See
.nextjs-ref/packages/next/src/server/app-render/required-scripts.tsx.
We use `bootstrapModules` instead of `bootstrapScripts` because vinext's
chunks are native ES modules (Vite output) — a `type="module"` tag is the
correct loader.

Tests updated where they asserted on the old `import("URL")` shape:
tests/asset-prefix.test.ts, tests/app-router.test.ts,
tests/e2e/cloudflare-workers/navigation.spec.ts.

Verified all related suites pass locally (asset-prefix, app-router,
script-nonce, script-preload, use-client-page-pathname, document,
prerender, ISR, fetch-cache, entry-templates, precompress).

* fix(build): use resolveAssetsDir for Pages Router client entry fallback scan

The Pages Router Cloudflare Workers post-build step scans the on-disk
assets directory for the client entry chunk when the SSR manifest
lookup doesn't surface one. The directory path was hardcoded to
`<clientDir>/assets/`, which is the Vite default — but when
`assetPrefix` is configured, the chunk lives under
`<prefix>/_next/static/` (path-prefix) or `_next/static/` (absolute-URL
prefix). The fallback would silently miss the entry and the worker
would ship without its client bootstrap reference.

Use the shared `resolveAssetsDir(assetPrefix)` helper that already
drives `build.assetsDir` and the prod-server lookup, so this scan
works for every supported asset layout.

Addresses round-4 reviewer feedback on #1311 (low-priority follow-up
item — only fires when the SSR manifest lookup fails).

* feat(config): mirror Next.js basePath → assetPrefix fallback

Next.js auto-sets `assetPrefix = basePath` when `basePath` is configured
and `assetPrefix` is left unset (packages/next/src/server/config.ts:528-531).
Without this, an app deployed under a basePath serves its routes
correctly but emits its assets from `<basePath>/assets/...` (Vite's
default `base + assetsDir` composition) rather than from the Next.js-
canonical `<basePath>/_next/static/...`.

Add the same fallback in resolveNextConfig, gated by Next.js's own
conditions:
  - basePath !== ""   (skip when basePath is unset)
  - basePath !== "/"  (Next.js rejects this earlier; we mirror the
                       guard so we never produce assetPrefix === "/")
  - assetPrefix === "" (user did not opt out)

The fallback feeds the rest of the asset-prefix pipeline (Vite's
`build.assetsDir`, `experimental.renderBuiltUrl`, and the prod-server
URL→file resolver) so no other code paths need to change.

Tests:
 - tests/next-config.test.ts: 5 unit tests for the four edge cases of
   the fallback condition.
 - tests/asset-prefix.test.ts: new end-to-end test that builds with
   `basePath: "/app"` (no assetPrefix), starts the prod-server, and
   asserts the bootstrap <script src> lands at /app/_next/static/...
   (and that the legacy /app/assets/... layout is gone).
 - tests/pages-router.test.ts: updated the "preserves basePath on
   backfilled SSR manifest entries" test to expect the new
   /docs/_next/static/ layout (was /docs/assets/). The manifest
   assertion is relaxed to startsWith("docs/") because Vite's raw
   SSR manifest now emits some duplicate-prefixed entries
   ("docs/docs/_next/static/...") alongside the backfilled ones; both
   forms anchor under basePath so the prod-server's URL→file lookup
   remains correct — and the user-visible HTML assertions in the same
   test are the source of truth.

Addresses round-4 reviewer feedback on #1311.

* fix(server): Pages Router must not strip basePath before asset lookup

The Pages Router prod-server stripped `basePath` from the request
pathname BEFORE handing it to `resolveAppRouterAssetPath`, which broke
the asset lookup whenever the assetPrefix path-prefix branch was active
with a path that happened to equal basePath.

Concrete failure: with the round-4 commit `bac8425` mirroring Next.js's
`basePath → assetPrefix` fallback, an app configured with
`basePath: "/docs"` (and no explicit `assetPrefix`) now resolves
`assetPrefix = "/docs"`. Emitted asset URLs are
`/docs/_next/static/…` (correct). But the Pages Router branch did:

  const staticLookupPath = stripBasePath(pathname, basePath);
  // "/docs/_next/static/x.js" → "/_next/static/x.js"
  resolveAppRouterAssetPath(staticLookupPath, "/docs", "/docs");
  // path-prefix branch needs pathname to start with "/docs/" → null → 404

The App Router branch already passes the un-stripped `pathname` to the
same helper (see line ~1140). Match that behaviour. `staticLookupPath`
is still computed and used downstream by non-asset paths
(image-optimization endpoint, page routing), where the basePath-stripped
form is correct.

Test coverage:
 - tests/pages-router.test.ts: extended the existing "preserves basePath
   on backfilled SSR manifest entries" test to FETCH every emitted asset
   URL and assert HTTP 200 (previously it only inspected HTML for the
   URL string). This is the missing assertion that would have caught
   the bug the first time.
 - tests/asset-prefix.test.ts: added a parallel Pages Router e2e test
   that builds an inline pages/ fixture with `basePath: "/docs"`,
   starts the prod-server, collects every script/link URL from the SSR
   HTML, and asserts every URL serves a 200 + correct content-type.
   Confirmed to fail on `bac8425` before this fix and pass after.

Addresses round-5 reviewer feedback on #1311.

* refactor(ssr): accept either quote style in bootstrap URL regex

plugin-rsc currently emits the bootstrap as `import("<url>")` with
double quotes, but the contract isn't documented and a future version
could switch to single quotes. Tighten the URL extraction regex to
`import(["'])([^"']+)\1` so either form works.

Addresses non-blocking round-5 review feedback on #1311.

* docs(ssr): note CSP implications of bootstrapModules switch

The round-3 commit `3a2c886` switched the App Router SSR bootstrap from
inline `bootstrapScriptContent` (`<script>import(…)</script>`) to
React's `bootstrapModules` option (`<script type="module" src=…>`).
That has user-visible CSP consequences worth documenting at the call
site so app authors hit them in code search rather than at runtime:

  - Apps no longer need `script-src 'unsafe-inline'` for the bootstrap
    (improvement — inline imports previously required it).
  - Apps that restrict script sources need `'self'` for same-origin,
    or the CDN origin when assetPrefix is an absolute URL.
  - React still threads nonce through to the emitted <script> tag, so
    nonce-based CSP (`script-src 'nonce-…' 'strict-dynamic'`) keeps
    working unchanged.

Addresses non-blocking round-5 review feedback on #1311.
2026-05-19 11:53:49 +01:00
Nathan Nguyen 1dbcbfbcc8 feat(app-router): plan navigation from RouteManifest topology (#1312)
* feat(app-router): plan navigation from RouteManifest topology

Navigation commit approval still trusted flight payload snapshots for root-boundary and target-slot topology even when the static RouteManifest existed. That made stale or missing snapshot metadata authoritative during App Router commits, which could preserve layouts across the wrong root or fall back to soft commits when the graph already proved a same-root transition.

The planner now resolves current and target topology through RouteManifest when available, uses snapshot slot bindings only as visible content proof, and keeps explicit interception proof as the authority for intercepted slot commits. The generated browser entry embeds the manifest and approval paths pass it through to the planner.

Targeted coverage exercises stale snapshot roots, missing snapshot roots, dynamic URL pattern resolution, manifest target slot bindings, visible-slot proof, intercepted source topology, and browser-entry manifest serialization.

* fix(app-router): read route manifest lazily during navigation approval

The browser entry assigned the generated RouteManifest global before a static import, but ESM evaluates imported modules before the importer body. That let the browser navigation controller capture null at module evaluation, leaving manifest-aware planning disabled in real browser bootstraps.

Make the controller accept a lazy RouteManifest getter and read it at commit-planning time. Also require direct manifest route-id hits to match the snapshot matchedUrl before trusting them, so stale-but-valid route ids fall back to concrete URL pattern resolution.

Covered by app browser controller and navigation planner regressions, plus targeted entry template checks.

* fix(app-router): clarify manifest route topology resolution

Manifest route lookup now shares one matched-url normalization helper across direct route checks and pattern fallback. That keeps concrete dynamic URL fallback from drifting across call sites.

Document that manifest pattern iteration follows buildAppRouteGraph route precedence, and record why intercepted targets cannot use direct-target manifest slot bindings.
2026-05-18 18:24:45 +01:00
Divanshu Chauhan (divkix) a85c2c1dee fix: add x-action-forwarded guard and E2E tests for server action forwarding loop (#1292)
* feat(server-action): add x-action-forwarded guard to prevent forwarding loops

Defensively implements the Next.js server action forwarding loop fix.
- Adds ACTION_FORWARDED_HEADER constant to headers.ts
- Adds x-action-forwarded to INTERNAL_HEADERS blocklist
- Adds guard to handleServerActionRscRequest and handleProgressiveServerActionRequest
- Returns 404 + x-nextjs-action-not-found when header is present

Ported from Next.js: vercel/next.js@20892dd

* test(e2e): add action-forward-loop fixtures and tests

- Adds /nextjs-compat/action-forward-loop fixture with middleware rewrite
- Adds /nextjs-compat/action-forward-loop-rewrite as rewrite target
- Adds error boundary component using unstable_isUnrecognizedActionError
- Adds E2E tests: middleware rewrite + action POST, not-found header contract

* test(e2e): fix action-forward-loop tests per review feedback

- page.tsx: use useActionState so action result renders into #action-result
- actions.ts: update runAction signature for useActionState compat
- Remove dead error-boundary.tsx (never exercised in single-worker model)
- Rename second test to match actual behavior (stale action ID -> 404)

* test(request-pipeline): add x-action-forwarded to INTERNAL_HEADERS snapshot
2026-05-18 18:07:05 +01:00
James Anderson 4aaae3d23e fix(build): pin cssTarget so esbuild preserves max-width media-query syntax (#1310)
* fix(build): pin cssTarget so esbuild preserves `max-width` media-query syntax

Vite's default `build.cssTarget` follows `build.target` (modern evergreens),
which lets esbuild's CSS minifier rewrite `@media (max-width: 768px)` to
the Media Queries Level 4 range syntax `@media (width <= 768px)`. Both
forms are semantically equivalent in modern browsers, but the rewrite is
observable to user code that inspects `cssText` on `CSSMediaRule`s and
breaks tools that pattern-match the raw query string. Next.js's CSS
pipeline (webpack + cssnano, or lightningcss only when opted in via
`experimental.useLightningcss`) preserves the original syntax by default,
so user code carried over from Next.js can break when migrating to vinext.

Pin `build.cssTarget` to ["chrome87", "edge88", "firefox78", "safari15"],
which predate Safari 16.4 — the first browser to support MQ Level 4 range
syntax. esbuild checks the configured target and skips lowering when any
target is too old. Only literal media-query syntax is affected; matching
behavior at runtime is unchanged.

Mirrors the Next.js fixture
test/e2e/app-dir/css-media-query/css-media-query.test.ts which asserts
`cssText` preserves `max-width: 768px`.

Tests:
- New tests/css-media-query-target.test.ts builds a minimal fixture
  and asserts the produced CSS asset contains `max-width: 768px` and
  does not contain `width<=768px`. Fails without this change; passes
  with it.
- All existing CSS / SCSS / build-optimization / nextjs-compat app-css
  tests continue to pass.

* refactor(build): narrow cssTarget to only pin Safari low

Address review feedback on #1310. Only Safari needs to be pinned to a
version that predates MQ Level 4 range syntax (Safari 16.4) so esbuild
skips the @media (max-width: …) → @media (width <= …) rewrite. The other
targets were pinned far older than necessary, which caused esbuild to
downlevel unrelated modern CSS features (`:is()`, CSS Nesting, `lab()`,
`oklch()`, gradient interpolation, etc.).

Bumps chrome/edge/firefox targets to ~2023 baselines while keeping
safari15 to retain the MQ rewrite fix.
2026-05-18 16:59:56 +01:00
Divanshu Chauhan (divkix) 54e271ddc9 fix(cache-runtime): attach inner "use cache" call site as cause of nested-dynamic error (#1289)
* fix(cache-runtime): attach inner "use cache" call site as cause of nested-dynamic error

Ports Next.js PR #93707 to vinext. When a nested "use cache" propagates a
dynamic cache life (revalidate: 0 or expire < 5 minutes) to a parent without an
explicit cacheLife(), the error now attaches the inner invocation as .

Changes:
- Add NestedDynamicUseCacheError class (captured eagerly at inner call site)
- Track hasExplicitRevalidate / hasExplicitExpire on CacheContext
- Propagate dynamicNestedCacheError via ??= (first child wins)
- Throw with  when outer lacks explicit cacheLife for the relevant field
- Update error messages to consistently backtick "use cache"
- Add unit tests covering revalidate:0, short expire, explicit outer, first-child-wins

Refs: #1194
Ported from: https://github.com/vercel/next.js/pull/93707

* docs(cache-runtime): document nested-dynamic error propagation invariants

Addresses bigbonk review feedback on #1289:

- Guard `Error.captureStackTrace` with a typeof check for non-V8 runtime
  robustness. The constructor already captures a stack; this call only
  trims the constructor frame.
- Document that propagating the inner's resolved life into the parent's
  `lifeConfigs` is load-bearing: minimum-wins must reflect the inner's
  dynamic values for the outer's nested-dynamic detection to fire, and
  the outer's explicit `cacheLife()` correctly suppresses the throw by
  pushing the resolved values above the dynamic thresholds. Matches
  Next.js `propagateCacheLifeAndTagsToRevalidateStore`.
- Clarify that `hasExplicitRevalidate` / `hasExplicitExpire` flags
  really mean "cacheLife() was called and the resolved config includes
  this field", not "the user explicitly passed this field" — matches
  upstream which tracks the flag at the work unit store level.
- Strengthen the first-child-wins test to assert the outer error
  message contains "revalidate" (not "expire"), rigorously verifying
  that `dynamicNestedCacheError ??=` preserves the first dynamic
  child's eager error as the cause.

* fix(cache-runtime): gate nested-dynamic throw on prerender/dev to match Next.js

Addresses bigbonk review feedback on #1289 (round 2):

- Previously the nested-dynamic-cache error threw unconditionally, even
  during normal production dynamic SSR. The error messages say
  "not allowed during prerendering", which is misleading outside the
  prerender phase. Next.js gates the equivalent check on the work unit
  type being `prerender` (or `request` in development) — see
  use-cache-wrapper.ts cases 'prerender'/'request' at the read site.
- Gate the throw on `process.env.VINEXT_PRERENDER === "1"` (set by
  build/prerender.ts during the prerender phase) or NODE_ENV ===
  "development". Production runtime SSR no longer throws; the outer
  function still executes and its result simply isn't cached
  (minimum-wins resolves its effective revalidate to 0).
- Add a regression test verifying no-throw behavior under production
  runtime conditions (no VINEXT_PRERENDER, NODE_ENV=production).
- Document caller paths (shared MISS, private, dev) on
  `runCachedFunctionWithContext` — all three reach this helper and
  propagate lifeConfigs / dynamicNestedCacheError up to the parent,
  matching Next.js's `propagateCacheEntryMetadata`. Request-scoped
  recording uses minimum-wins so inner-vs-outer order doesn't matter.
- Add a test documenting the potentially surprising minimum-wins
  semantics: with `cacheLife({ revalidate: 60 })` on the outer and
  `cacheLife({ revalidate: 0 })` on the inner, the throw is suppressed
  but the outer's effective revalidate resolves to 0, so the outer's
  result isn't cached and the function executes on every call. Matches
  Next.js `propagateCacheLifeAndTagsToRevalidateStore`.
- Wrap prerender-gated tests in a `withPrerenderFlag` helper that
  sets/restores VINEXT_PRERENDER around each test.

* fix(cache-runtime): make nested-dynamic error message context-aware; pin dev/prod gate

Addresses bigbonk review feedback on #1289 (round 3):

- Make the nested-dynamic error messages context-aware: emit "in
  development" when NODE_ENV=development, "during prerendering"
  otherwise. The previous unconditional "during prerendering" wording
  was misleading when the gate fired in dev SSR. Done via two small
  message-builder functions instead of constants.
- Add a dev-mode test ("does throw in development mode even without
  VINEXT_PRERENDER set") that pins down the other side of the gate.
  Without this, Vitest's default NODE_ENV=test would mask a regression
  that broke the gate to e.g. `!== "production"` instead of
  `=== "development"`. Together with the existing production test,
  both branches of the gate are now covered.
- Document the sequencing invariant between the inner's
  `parentCtx.lifeConfigs.push(effectiveLife)` and the outer's
  `resolveCacheLife(ctx.lifeConfigs)`. The correctness depends on the
  inner's await completing (including its post-execution propagation)
  before the outer's resolveCacheLife runs. A future refactor away from
  `await` (e.g. Promise.all with short-circuit) would silently break
  this and produce stale minimum-wins results.
- Document the eager-Error allocation overhead trade-off: matches
  Next.js (captured eagerly so `cause` points at the original call
  site), with the lazy-capture optimization noted for future profiling.

Verified: tests/shims.test.ts (918 tests), cache + ISR + fetch-cache
suites, and full `vp check` all clean.

* docs(cache-runtime): clarify gate priority, effectiveLife semantics, and per-field flag note

Addresses bigbonk review feedback on #1289 (round 4):

- Prioritize VINEXT_PRERENDER over NODE_ENV=development in
  nestedCacheContextPhrase. If the prerender flag is set, the user is
  prerendering regardless of NODE_ENV (e.g. a dev-config prerender),
  so "during prerendering" is the accurate phrase.
- Document the semantic difference between checking the outer's merged
  effectiveLife.revalidate vs the inner's entry metadata (as Next.js
  does). Behavior is observably equivalent in all cases due to the
  hasExplicit* guards; we use effectiveLife because vinext doesn't
  model a CacheResultMetadata type.
- Extend the hasExplicit* comment in cache.ts to clarify that the
  `!== undefined` checks are effectively unconditional (default profile
  always provides both fields). They remain as defensive guards; per-
  field suppression would require inspecting the raw user input and
  would diverge from Next.js semantics, so it's noted as a deliberate
  design change rather than an incidental one.

Bonk's remaining comments are informational:
- Comment 2 ("lifeConfigs.push is a behavioral change"): the PR
  description already calls this out; no code change.
- Comment 4 ("NODE_ENV=test gate coverage gap"): explicitly flagged as
  not blocking; the dev + prod test pair already pins both branches.

Verified: tests/shims.test.ts (918 tests) and full `vp check` clean.

* docs(cache-runtime): correct hasExplicit guard explanation; note parallel-call invariant

Addresses bigbonk review feedback on #1289 (round 5):

- Fix incorrect minimum-wins explanation in the throw block comment.
  The previous wording claimed minimum-wins "drops" the inner's values
  when the outer has an explicit cacheLife() — that's wrong. Minimum-
  wins takes Math.min(60, 0) = 0, so the threshold check still
  evaluates true. What actually suppresses the throw is the
  `!ctx.hasExplicitRevalidate` / `!ctx.hasExplicitExpire` guard.
  Future maintainers reading the old comment might have concluded the
  hasExplicit* guards were redundant; the corrected wording makes
  clear they are load-bearing.
- Also corrected the same misconception in the comment above the
  `parentCtx.lifeConfigs.push` block ("the outer only suppresses the
  throw when its own explicit cacheLife() pushed the effective values
  above the dynamic thresholds"). Replaced with the accurate two-step
  description: lifeConfigs.push makes the threshold checks possible;
  hasExplicit* guards independently decide suppression.
- Add a sentence to the sequencing-invariant comment noting it holds
  for both sequential (`await innerA(); await innerB()`) and parallel
  (`await Promise.all([innerA(), innerB()])`) inner call patterns,
  since the outer's `await cacheContextStorage.run(...)` only resolves
  after fn's returned promise settles.
- Updated PR description to call out `parentCtx.lifeConfigs.push` as a
  standalone behavioral change in production (not just during
  prerender/dev), separate from the error-cause feature. Linked to
  the minimum-wins documenting test that pins this behavior.

Verified: tests/shims.test.ts (918 tests) and `vp check` clean.

---------

Co-authored-by: James <james@eli.cx>
2026-05-18 15:55:42 +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 c483253b58 fix(rsc): allow JSX in .js client modules inside node_modules (#1295)
Many third-party Next.js client libraries ship plain `.js` files containing
`'use client'` (or `'use server'`) plus JSX. Next.js's SWC pipeline compiles
JSX in `.js` transparently, but vinext's `vinext:jsx-in-js` plugin
previously skipped node_modules entirely, so `@vitejs/plugin-rsc`'s
`rsc:use-client` analysis pass would feed the raw source to rolldown/oxc
with `lang: 'js'` and fail:

    [plugin rsc:use-client] node_modules/.../client/index.js
    RolldownError: Parse failed with 1 error:
    Unexpected JSX expression
    4:   return <p>hello world</p>

This affected the Next.js deploy-suite test fixtures:
  - test/e2e/app-dir/next-dist-client-esm-import
  - test/e2e/app-dir/client-module-with-package-type

Fix: extend `vinext:jsx-in-js` to also handle `.js`/`.mjs` files inside
`node_modules` *when* they begin with a React directive. We mirror
`@vitejs/plugin-rsc`'s detection by parsing the directive prologue
(skipping BOM, hashbang, comments, and earlier directives like
`'use strict'`), which keeps the transform cost essentially zero for the
vast majority of node_modules files.

Validation:
  - vp check (format / lint / type) — clean
  - vp test run tests/jsx-in-js.test.ts tests/jsx-in-js-node-modules.test.ts
    tests/intercepting-routes-build.test.ts tests/build-optimization.test.ts
  - vp test run tests/nextjs-compat/ tests/font-google-build.test.ts
    tests/build-time-classification-integration.test.ts
    tests/tsconfig-path-alias-build.test.ts (281 passing, 0 regressions)
  - vp run vinext#build (clean)
  - New tests/jsx-in-js-node-modules.test.ts reproduces the reported error
    on main and passes with the fix.
2026-05-18 14:41:38 +01:00
Nathan Nguyen 3e0ae047fc fix(build-report): parse route exports with OXC AST (#1260)
* fix(build-report): parse route exports with OXC AST

Route report classification relied on regexes and a hand-written return-object scanner. That made comments look like exports and missed valid static syntax such as no-substitution template literals and numeric separators.

The report now parses route modules with oxc-parser, reads top-level static exports from the AST, and keeps getStaticProps revalidate extraction scoped to the exported function body. Tests cover the prior false positives, supported literal forms, TypeScript generic syntax fallback, and Next.js-style export specifier handling.

* fix(cli): lazy-load build report generation

The CLI imported the route report module at startup, which also loaded the native parser dependency added for build report classification. That widened the parser and native binding path to non-build commands.

Load the build report module inside buildApp() immediately before printing the report so dev, start, check, lint, deploy, and help paths do not touch the parser-backed report code.

* perf(build-report): reuse parsed route modules

Route classification reparsed the same source for each static export query. With the TSX-to-TS fallback, a single route could invoke the parser several times before producing one report row.

Thread the parsed OXC Program through internal route-report helpers so classifier paths parse each source once while preserving the public string-based helper signatures. Also clarifies the synthetic getStaticProps wrapper path and the unsupported unary-operator branch.

* fix(build-report): use Vite parser export

Route report parsing now uses Vite's OXC-backed parser export instead of depending on oxc-parser directly. Adding the parser as a package dependency duplicated the toolchain parser and exposed native optional bindings through vinext itself.

Import parseSync and ESTree types from Vite, keep the existing parser options and AST analysis unchanged, and remove the direct package/catalog entries so the lockfile only retains transitive parser usage from dev tooling.
2026-05-18 14:39:17 +01:00
James Anderson 69c72b02e2 fix(build): emit SSR CSS assets referenced by SSR entry (#1304)
Set `build.ssrEmitAssets: true` so the SSR environment writes asset
files (CSS, etc.) that JS chunks reference. Vite defaults
`emitAssets` to `false` for environments with `consumer: "server"`,
so when the CSS plugin rewrites a server-component CSS import into
`import "<hash>.css"` and emits the CSS asset, the asset file is
subsequently stripped from the bundle by Vite's asset-cleanup hook.
The dangling import then crashes `vinext start` with
ERR_MODULE_NOT_FOUND on `dist/server/ssr/style.css`.

We set the top-level `build.ssrEmitAssets` rather than
`environments.ssr.build.emitAssets` because Vite re-applies the
top-level value during `resolveConfig` and would otherwise overwrite
any per-environment value. Mirrors `@vitejs/plugin-rsc`'s existing
`emitAssets: true` on the `rsc` environment.

Adds tests asserting both the App Router and Pages Router SSR
environments resolve to `emitAssets: true` and that every CSS import
in the SSR build output resolves to a real file on disk.
2026-05-18 12:42:18 +01:00
James Anderson 1d57274ba4 test(config): regression coverage for next.config runtime value shapes (#1297)
Adds 12 ported upstream fixtures as unit tests for loadNextConfig,
covering shapes not exercised by the four extension/import tests added
in #1296 (which validates the runner's resolve.extensions + commonjs()
configuration). These complement that coverage by asserting the
returned config value is actually surfaced for shapes that historically
build successfully but resolve to empty/undefined when normalisation
has gaps:

  - async default-function export (next-config-ts/async-function)
  - plain default object export (next-config-ts/export-default)
  - aliased default export, 'export { x as default }' (export-as-default)
  - mixed sibling extensions under both type:commonjs and type:module
    (next-config-ts/import-js-extensions-{cjs,esm})
  - nested-import propagation under both module-type flavours
    (next-config-ts/nested-imports-{cjs,esm})
  - Node API at module scope under type:module (next-config-ts/node-api-cjs)
  - bare-specifier CJS packages (next-config-ts/import-from-node-modules)

Originally this PR also introduced a hand-rolled cjsImportsPlugin that
used createRequire + globalThis stashing to interop CJS siblings. After
#1296 (cf20845) was refactored to reuse the existing vite-plugin-commonjs
dependency, that hand-rolled plugin became fully redundant — all 12
tests pass against main's commonjs()-based loader unchanged. Dropped
the implementation and kept the tests as additional regression
coverage.
2026-05-18 12:25:49 +01:00
James Anderson 8de3afdf25 fix(config): recognize next.config.mts and interop CJS imports in config loader (#1296)
* fix(config): recognize next.config.mts and interop CJS imports in config

The next.config.ts loader did not recognize next.config.mts and did not
interop CJS imports (.cjs/.cts/.js/.ts files that assign to module.exports)
brought in by the config file. As a result, fixtures from the Next.js
test/e2e/app-dir/next-config-ts-native-mts/ family failed with either
"Cannot find next.config.*" (silent null return) or "module is not
defined" inside Vite's ESM-only module runner.

Two changes:

1) The loader's local CONFIG_FILES list is extended to include
   next.config.mts (matching the canonical list in shims/constants.ts,
   which already mirrors Next.js packages/next/src/shared/lib/constants.ts)
   while keeping .cjs for backwards-compat with existing vinext users.
   deploy.ts and check.ts pick up the same extended list so their config
   scanners stay in sync.

2) A new Vite plugin (cjsImportsInteropPlugin) is installed on the inline
   runnerImport instance. It wraps .cjs/.cts files (and .js/.ts files that
   statically assign to module.exports) so that module.exports becomes the
   ESM default export, mirroring how Next.js's SWC pipeline auto-converts
   CJS imports from inside next.config.ts. The transform is scoped to the
   config-load instance only, so it never affects the user's application
   module graph.

Tests ported from .nextjs-ref/test/e2e/app-dir/next-config-ts-native-mts/
{nested-imports,import-js-extensions-cjs,import-js-extensions-esm,
import-from-node-modules}/.

* docs(config): clarify CJS exports/module.exports semantics in comment

The previous comment said "Named exports off module.exports are also
reflected", which glosses over the asymmetric behaviour that Node CJS
modules exhibit when user code does `module.exports = X` followed by
`exports.foo = Y`: the latter writes to the discarded original object
and does not show up on the final default export. Rewrite the comment to
spell out the aliasing, the reassignment break, and the
`module.exports.foo` workaround.

* refactor(config): reuse vite-plugin-commonjs for next.config sibling imports

The hand-rolled cjsImportsInteropPlugin duplicated logic that vinext
already pulls in via vite-plugin-commonjs (used globally for application
code in packages/vinext/src/index.ts:795). Drop the bespoke plugin and
install commonjs() on the inline runnerImport instance instead.

Two pieces of configuration are needed for parity with the previous
behaviour:

1. resolve.extensions explicitly includes .cjs and .cts. vite-plugin-commonjs
   keys its 'should I transform this id?' check off config.resolve.extensions,
   and Vite's default list (.mjs/.js/.mts/.ts/.jsx/.tsx/.json) omits both
   CJS-only extensions. Without this, .cjs/.cts imports flowed through
   the runner untransformed and still crashed with 'module is not defined'.

2. A filter that:
   - Returns false for the config file itself, so cjsGlobalsInjectorPlugin
     remains the sole producer of the file's module wrapper (otherwise
     both plugins inject 'module = …' and the runner trips on
     'Identifier module has already been declared').
   - Returns true for ids under node_modules, so bare-import packages
     imported by next.config.* (CJS plugin wrappers, etc.) get transformed.
     vite-plugin-commonjs defaults to skipping node_modules.

Net diff: -59 lines, all four next-config-extensions tests still pass,
no regression on tests/next-config.test.ts (108 passing) or the broader
deploy/features/shims/check suites (1637 passing in aggregate).
2026-05-18 12:07:44 +01:00
Divanshu Chauhan (divkix) aed79d46b0 test: regression tests for issue #1196 (catch-all route params with basePath + rewrites + middleware) (#1291)
* test: add regression tests for issue #1196 (catch-all route params with basePath + rewrites + middleware)

Adds integration and unit tests to verify that catch-all route params
are preserved when basePath, rewrites, and middleware are combined.

- Integration test in pages-router.test.ts: checks server-rendered
  __NEXT_DATA__ contains correct page and query for a catch-all route.
- Unit test in shims.test.ts: verifies client-side router.query stays
  intact for a catch-all page under basePath.

Both tests are ported from the Next.js test suite and target the
regression described in GitHub issue #1196.

* test: address review feedback on #1196 regression tests

- Use {.*?} regex for consistency with other __NEXT_DATA__ extractions
- Drop unused { appRouter: false } option (vinext auto-detects)
- Rename shims test to describe what it actually verifies (initial state)
- Move vi.resetModules() after window restore for clean teardown ordering

* test: add real client-nav test for #1196 catch-all router.query

Adds a Router.push() test that mocks fetch and exercises the actual
client-side navigation code path: performNavigation → pushState →
navigateClient → fetch. The mocked fetch handler captures router state
mid-navigation via a Probe component, then asserts router.query.path
reflects the destination URL segments after basePath is stripped.

This addresses the bonk review feedback that the previous tests only
verified initial hydration state and didn't exercise client navigation.
The new test also uses the previously-unused buildNavHtml `query`
parameter, addressing the dead-signature concern.

vinext's navigateClient doesn't construct _next/data/... URLs the way
Next.js does, so it doesn't have the architectural pattern that causes
#1196 in Next.js. But the route-param extraction path that ultimately
produces router.query must still strip basePath from the resolved
pathname before matching the catch-all pattern — and that's what this
test verifies.

---------

Co-authored-by: opencode <noreply@opencode.ai>
2026-05-18 11:59:53 +01:00
James Anderson 0a77ebd46f fix(router): gate intercept matching on Next-URL source pattern (#1302)
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.
2026-05-18 11:55:31 +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
Nathan Nguyen bafbcd4d75 fix(server): clear browser globals before SSR user modules (#1285)
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.
2026-05-18 11:33:14 +01:00
James Anderson 329b89e3c3 fix(navigation): populate useParams/useSearchParams under Pages Router (#1303)
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.
2026-05-18 11:32:10 +01:00
Nathan Nguyen f4a37b4ade fix(app-router): track hash-only traversal metadata (#1306)
* 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.
2026-05-18 11:20:02 +01:00
Nathan Nguyen 7397d676c6 refactor(router): share interception matched-url validation (#1298)
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.
2026-05-18 11:16:50 +01:00
Divanshu Chauhan (divkix) 69165d4557 fix(app-page-render): surface invalid dynamic usage errors via flight in dev (#1290)
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/f5e54c06726b571a042fce67417e40a29f6b8689

Closes #1195
2026-05-18 11:10:40 +01:00
Nathan Nguyen 6790e3431f feat(cache): add static layout proof and variant budget guardrails (#1288)
* 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
2026-05-18 11:09:36 +01:00
Nathan Nguyen 8dfd6c8ede fix(image): preserve inline styles on remote images (#1287)
* 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.
2026-05-18 10:44:31 +01:00
Nathan Nguyen e8cf0dad13 fix(script): honor async false for client scripts (#1286)
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.
2026-05-18 10:43:32 +01:00
Nathan Nguyen 16f9ea393f fix(app-router): model RSC redirect and traversal lifecycle (#1248)
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.
2026-05-18 10:42:19 +01:00
Nathan Nguyen 8dc6810fb6 fix(rsc): preserve exported client reference subpaths (#1257)
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.
2026-05-18 10:34:18 +01:00
Nathan Nguyen 656d954abf feat(router): promote intercepted preservation through planner (#1249)
* 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.
2026-05-18 10:18:49 +01:00
Nathan Nguyen 7283e44beb fix(router): keep next/router import free of popstate side effects (#1294)
* 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.
2026-05-18 10:08:40 +01:00
Ghxst 85f2c10fab chore: update @vitejs/plugin-rsc to 0.5.26 (#1293) 2026-05-18 08:28:33 +00:00
Nathan Nguyen d558923fde fix(script): dedupe concurrent same-src loads (#1263)
* 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
2026-05-16 20:16:38 +01:00
Nathan Nguyen ff46514563 fix(link): preserve native URI scheme navigation (#1268)
* 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>
2026-05-16 19:14:26 +00:00
Nathan Nguyen 1cd7255881 fix(image): emit preload hints for priority images (#1266)
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>
2026-05-16 19:12:28 +00:00
James Anderson e69721f392 fix(config): provide CJS globals when loading next.config.ts (#1278)
* 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 `{}`.
2026-05-16 20:10:23 +01:00
Nathan Nguyen ba1829a804 fix(image): clear blur placeholder after image load (#1267)
* 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>
2026-05-16 20:06:34 +01:00
James Anderson da9998a00f fix(link): normalise href for trailingSlash config (#1282)
* 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>
2026-05-16 19:05:38 +00:00
Nathan Nguyen fdf3029ea4 fix(router): share pages useRouter state through context (#1271)
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>
2026-05-16 20:04:12 +01:00
Nathan Nguyen d280e3620b fix(app-router): skip RSC navigation for hash-only history traversal (#1270)
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>
2026-05-16 18:59:46 +00:00
Nathan Nguyen be9f338306 fix(router): derive shallow dynamic params from the URL (#1272)
* 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>
2026-05-16 18:58:11 +00:00
James Anderson 9252ca2966 fix(css): pass sassOptions from next.config to Vite preprocessor (#1277)
* 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.
2026-05-16 19:45:03 +01:00
Nathan Nguyen deef64470b fix(app-router): keep refresh transitions pending (#1269)
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.
2026-05-16 19:36:48 +01:00
James Anderson 1d66c09e15 refactor(edge-globals): consolidate into installEdgeGlobals() helper (#1284)
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.
2026-05-16 18:34:17 +00:00