Commit Graph

181 Commits

Author SHA1 Message Date
Nathan Nguyen cda19e1c58 fix(app-router): preserve recent segment state with Activity BFCache (#1739)
* fix(app-router): preserve recent segment state with Activity BFCache

Fresh App Router navigations were reusing the active React tree when moving between dynamic segment values. That leaked client state into a newly entered segment and made the upstream back-forward-cache suite fail before it even reached browser traversal assertions.

The root cause was that vinext had history-entry bfcacheId plumbing but no separate segment state-key cache. Next.js treats those identities separately: render state is keyed by segment identity/pathname, while useRouter().bfcacheId identifies a concrete history entry.

Add state-key maps for app router slots, render retained entries through React.Activity when cacheComponents is enabled, keep the active one-entry keyed reset path otherwise, and port focused Next.js regression coverage for the three-entry BFCache behavior.

* fix(bfcache): store stateKeyMap in retained slot entries and re-provide on render

Review feedback requested two changes:

1. Add stateKeyMap to BfcacheSlotEntry so each retained BFCache entry carries
the state-key map that belonged to it when active, rather than borrowing the
current navigation's map. This prevents nested hidden entries from being rendered
with old elements but new pathname-derived state keys — an identity corruption
bug that upstream Next.js avoids by rendering each retained entry from its own
state.

2. Add a nested dynamic regression test: /nested/[section]/item/[id] with client
counters in both the [section] layout and [id] page. The existing flat /page/[n]
tests do not exercise nested retained-entry identity, which is exactly where the
missing state-key-map bug lives.

Changes:
- BfcacheSlotEntry gains stateKeyMap?: Readonly<Record<string, string>>
- updateBfcacheSlotEntries accepts and stores an optional stateKeyMap argument
- BfcacheSlotBoundary stores the current stateKeyMap alongside elements when
caching the active entry
- Both the Activity visible/hidden path and the non-cacheComponents single-entry
path now wrap rendered entries in BfcacheStateKeyMapContext.Provider using the
entry's own stateKeyMap (falling back to current map for forward compatibility)
- New e2e test verifies nested layout + page counter preservation across
segment navigations

* refactor(bfcache): extract shared provider stack into BfcacheEntryProviders

BfcacheSlotBoundary rendered the BfcacheStateKeyMapContext / ElementsContext /
SegmentContext provider stack in two places: the one-entry keyed reset path and
the per-entry Activity retention list. The two stacks were identical except for
where the React key sat, so the retained-entry bookkeeping and its render shape
were tangled together and harder to audit than needed.

Extract BfcacheEntryProviders as a pure presentational helper and render it from
both paths. The keyed-reset path keeps its remount semantics: the key now sits on
the BfcacheEntryProviders element, so a fresh active stateKey still remounts the
whole provider subtree.

Hook ordering is unchanged. All hooks stay above the early returns in
BfcacheSlotBoundary, so the boundary still satisfies the Rules of Hooks across the
activeStateKey undefined-to-defined transition. Covered by the slot BFCache
entry-state suite in tests/shims.test.ts.

* test(bfcache): isolate cacheComponents BFCache coverage in a dedicated fixture

The back/forward-cache e2e suite ran against the shared app-basic fixture with
cacheComponents: true. app-basic backs the single app-router Playwright server
that every app-router spec uses, so enabling cacheComponents globally changed a
core DOM invariant for unrelated tests: inactive route trees stay mounted as
hidden Activity DOM. The contamination already leaked a :visible workaround into
the interception-modal bfcacheId spec, which had nothing to do with Activity.

cacheComponents is an opt-in semantic mode, not default app-router behaviour, so
it does not belong on the shared baseline fixture. Move the BFCache routes into a
dedicated tests/fixtures/app-bfcache fixture with cacheComponents: true, served by
a new app-router-bfcache Playwright project on port 4183, and run the
back-forward-cache spec there against the project baseURL. The :visible locators
stay in that spec, where duplicate hidden DOM is the expected contract.

Remove cacheComponents and the BFCache routes from app-basic so the shared fixture
models default behaviour again, and revert the :visible workaround in
use-router-bfcache-id.spec.ts now that the modal route no longer retains hidden
DOM. Fix the nested item page import while moving it: nested/[section]/item/[id]/
page.tsx imported ../counter, but the counter lives two levels up at
nested/[section]/counter.tsx, so it must be ../../counter.

Validation: PLAYWRIGHT_PROJECT=app-router-bfcache (5 passed) and the full
use-router-bfcache-id spec under app-router (11 passed), plus the slot BFCache
unit suite (1172 passed) and vp check (formatting + lint + types clean).

* ci: run the app-router-bfcache e2e project in the matrix

The dedicated app-router-bfcache Playwright project was added to
playwright.config.ts but not to the CI E2E matrix, so the back/forward-cache
specs never ran in CI. Add it to the matrix project list so the BFCache suite
runs on every push. It uses the default chromium browser, so the existing setup
and browser-cache steps apply unchanged.

* fix(bfcache): keep persisted slots mounted when cacheComponents is off

Without cacheComponents there is no Activity retention, yet BfcacheSlotBoundary
still keyed the active entry by its segment stateKey. That stateKey tracks the
pathname (createBfcacheSegmentIdentity), so any slot whose identity moves with
the URL (shared layouts, interception source slots) was remounted on navigation,
discarding client state that survives a normal route change. The baseline router
reconciles these slots in place.

This regressed seven existing app-router e2e tests once cacheComponents was no
longer masking it on the shared fixture: interception source-context and modal
preservation in advanced.spec.ts, and layout-persistence.spec.ts dynamic-segment
layout counter survival. Fresh-entry reset is driven by userland bfcacheId keying
in the fixtures, not by remounting the slot subtree, so the keying was pure
overreach in the non-cacheComponents path.

Render the single active entry unkeyed when cacheComponents is off so the boundary
reconciles in place like the baseline. The keyed Activity retention path is
unchanged for cacheComponents.

Validation: full app-router project 436 passed / 0 failed (the seven regressions
now pass); use-router-bfcache-id 11 passed (fresh-push reset intact); app-bfcache
5 passed (Activity retention intact); shims unit suite 1024 passed.

* docs(test): point bfcacheId spec comment at the app-router-bfcache project

The header noted that Activity-backed preservation is covered in
back-forward-cache.spec.ts, but that spec moved to the dedicated app-bfcache
fixture under the app-router-bfcache project. Name the project and path so the
cross-reference stays accurate.

* docs: align cacheComponents comment with unkeyed active-segment behavior

* test(bfcache): assert fresh bfcacheId after Activity-restored return

* style: collapse locator chains per oxfmt

* refactor(bfcache): split slot entry order from snapshots

* fix(bfcache): keep non-cacheComponents slot path baseline

* fix(bfcache): use string cacheComponents define

* test: remove external fetch from revalidate tag fixture

* fix(bfcache): harden Activity state key retention

* docs(bfcache): document cacheComponents Activity caveats

* fix(bfcache): preserve navigation context for fallback SSR

Fallback boundary rendering can outlive the original request-scoped navigation context. That made app SSR fail fast with a missing navigation context when loading-boundary digest routes rendered their HTTP fallback shell.

The SSR boundary contract still requires a navigation context for real request renders, but boundary renderers now synthesize the same typed shape from the request URL and matched params when request scope has already been cleared.

* refactor(bfcache): normalize pathname in state-key map, document invariant callers

Moves hydration-pathname normalization into createBfcacheSegmentStateKeyMap so SSR and client always produce byte-identical Activity keys. Documents the requireNavigationContext guaranteeing callers per review feedback.

* fix(bfcache): tolerate malformed state-key pathnames

* docs(bfcache): document known Next.js divergence and dead-branch contract

* chore(deps): update lockfile for vite 0.1.24

* fix(app-router): preserve encoded delimiters in bfcache keys

* docs(bfcache): track router-owned retention follow-up

* chore: retrigger ci

* fix(app-router): preserve redirect bridge fallback
2026-06-07 23:07:25 +01:00
Nathan Nguyen aab9240e10 fix(app-router): hard navigate streamed redirects (#1742)
* fix(app-router): hard navigate streamed redirects

* fix(navigation): guard page redirect marker checks

* test(app-router): count redirect document loads
2026-06-07 21:55:56 +01:00
Nathan Nguyen 1a480016f6 fix(app-router): track searchParams access for static bailout (#1788)
* fix(app-router): track searchParams access for static bailout

App Router pages that awaited or used the searchParams prop during prerender could still be classified as static when the prerender request had an empty query string. That seeded no-query output into the deploy cache and served it for requests like ?search=hello.

The dynamic decision was based on query-string content instead of page prop access. Observe the searchParams thenable itself, including React use() status reads, and mark searchParams dynamic only when the prop is consumed.

* chore: rerun ci

* fix(app-router): avoid static cache for searchParams pages

* fix(app-router): tolerate synthetic page paths in manifest codegen

* fix(app-router): observe searchParams access for static bailout

* fix(app-router): preserve app page cache hits

* fix(app-router): observe loading search params during render

* refactor(app-router): extract searchParams probe fan-out into helper

Move the per-request page probe fan-out (matched page + parallel slot
pages + interception page) out of the generated RSC entry and into a
unit-testable buildAppPageProbes() helper in server/app-page-probe.ts,
keeping entries/app-rsc-entry.ts thin (AGENTS.md guidance). Addresses
bonk review concern #3 on #1788.

Document the known slot over-bail (probing inactive parallel-route slot
pages can mark an otherwise-static page dynamic; fails safe toward
dynamic) and track it as a caching-efficiency follow-up in #1798
(bonk review concern #1).

* fix(app-router): skip probing interception-overridden slot pages

Narrow the searchParams probe fan-out so it only probes page components
that actually render for the request. When an interception matches, it
replaces the page of the slot named by intercept.slotKey (the element
builder sets overrides[slotKey].pageModule to the interception page,
which wins over slot.page in app-page-route-wiring.tsx). We now probe
the interception page in place of that slot's own page instead of
probing both — probing the overridden slot page marked an otherwise
static request dynamic for a component that never renders.

Fixes the over-bail flagged in review of #1788.

* docs(app-router): clarify buildAppPageProbes slot precision

Address review feedback: the doc comment overstated precision without
explaining why non-overridden slots are exact. Spell out that a slot
with a page.tsx always renders that page (so probing it is correct),
default-only slots have no slot.page.default so probing is a no-op
(not an over-bail), and a default.tsx awaiting searchParams is
backstopped by the real render's observation (so no under-bail).

* fix(app-router): gate searchParams interception probe on isRscRequest

Interception only fires for RSC navigations (resolveAppPageInterceptState
returns kind:none when !isRscRequest, app-page-request.ts:324). The probe
fan-out called findIntercept unconditionally, so on HTML requests it would
skip the matched route's slot page (which DOES render normally) and probe
the interception page (which never renders). buildAppPageProbes now ignores
the interception match when !isRscRequest, so HTML requests probe every
slot's own page and skip the interception probe.

The source-route interception case (a different route renders) never reaches
this probe: dispatchAppPage returns the intercepted response before calling
probePage, so any interception seen here is the current-route override case.
Documented both in the buildAppPageProbes doc comment.

Addresses review feedback on #1788.

---------

Co-authored-by: James <james@eli.cx>
2026-06-06 23:47:47 +01:00
Divanshu Chauhan (divkix) cf04ca63e3 fix(form): DISALLOWED_FORM_PROPS stripping, file input warning, viewport prefetch, pages-router E2E (#1752)
* fix(form): implement DISALLOWED_FORM_PROPS, file input warning, viewport prefetch, and pages-router E2E

- Strip method/encType/target props with dev console.error (Gap 1)
- Warn in dev when FormData contains a File value (Gap 2)
- Add viewport IntersectionObserver prefetch for App Router (Gap 3)
- Add prefetch prop (false | null) to FormProps
- Add pages-dir fixture and E2E spec for Pages Router form tests (Gap 4)
- Fix Rules of Hooks: move useRef/useCallback/useEffect before conditionals
- Update unit tests: stub React 19 dispatcher for hook-safe direct rendering

Closes #1355

* test(form): fix 'renders a form with method prop' to assert stripping behavior

* fix(form): address code-review findings — JSDoc, parity warnings, prefetch dedup

- Fix misleading JSDoc: pages-dir prefetch prop "has no effect" (not "emits a warning")
- Add dev warning for invalid prefetch value (not null/false)
- Add dev warning for replace/scroll/prefetch passed with a function action
- Gate viewport-prefetch IntersectionObserver with getPrefetchedUrls() /
  hasPrefetchCacheEntryForNavigation() dedup check to match link.tsx
- Add 5 new test cases covering the three new warning paths

* style(form): fix formatting in form.test.ts

* fix(form): match Next.js warning text verbatim, reuse useMergedRef, skip prefetch for unsafe actions

Addresses bonk review feedback:
- Invalid prefetch warning now matches Next.js verbatim:
  "The `prefetch` prop of <Form> must be `false` or `null`"
- Function-action warnings split into the two distinct Next.js messages
  (one for `prefetch`, one for `replace`/`scroll` with doc links),
  gated separately to match app-dir/form.tsx
- Reuse the shared useMergedRef helper instead of hand-rolling ref merge
- Viewport prefetch now early-returns on unsafe actions, matching the
  submit-path isSafeAction gate
- Update form.test.ts assertions to the new verbatim text

* fix(form): run dev-mode prop validations before DISALLOWED_FORM_PROPS strip to match Next.js order

* fix(form): match Next.js validation ordering and bail for server/client action submitters

Addresses bonk review feedback:
- Run prefetch/replace/scroll dev validations before the DISALLOWED_FORM_PROPS
  strip loop, matching upstream app-dir/form.tsx ordering
- Bail out of GET interception for React server-action submitters
  (`name` starting with $ACTION_ID_/$ACTION_REF_, dev-only silent bail) and
  client-action submitters (`formAction="javascript:..."`), ported from
  Next.js form.tsx / form-shared.tsx
- Add unit tests covering both submitter bail paths

* fix(form): match Next.js dev-validation order and gate submitter warnings

Addresses bonk re-review parity findings:
- Gate the three console.error calls in hasUnsupportedSubmitterAttributes
  behind the dev check, matching Next.js form-shared.tsx (the bail itself
  still runs in all environments). Previously they fired in production.
- Hoist checkFormActionUrl(action, "action") into the top dev-validation
  block so it runs first, matching upstream app-dir/form.tsx ordering.

* refactor(form): drop dead-code GET method check for upstream parity

Addresses bonk re-review finding: the form-level `method` prop is stripped
via DISALLOWED_FORM_PROPS and any non-GET submitter `formmethod` override is
already caught by hasUnsupportedSubmitterAttributes, so the explicit
getEffectiveMethod GET check was dead code. Upstream app-dir/form.tsx has no
equivalent check. Remove it (and the now-unused getEffectiveMethod helper) and
document why the method is always GET at that point.

* fix(form): align viewport prefetch fetch with link.tsx (priority + purpose)

Addresses bonk re-review finding: the Form viewport prefetch fetch() omitted
the `priority: "low"` and `purpose: "prefetch"` options that link.tsx passes
(link.tsx:445-451). Without them, RSC prefetches competed with higher-priority
requests and weren't tagged as prefetches in devtools / Sec-Purpose. Align for
consistency with the Link prefetch path.

* fix(form): thread interception context + mounted slots into viewport prefetch

Addresses bonk re-review substantive finding: the viewport prefetch built
headers with createRscRequestHeaders() (no interceptionContext, no mounted-slots
header) and keyed the prefetched set on the bare rscUrl. The actual navigation
(and link.tsx / the router) compute getPrefetchInterceptionContext(href) +
getMountedSlotsHeader() and key on encodeCacheKey(rscUrl, interceptionContext).

For intercepted target routes or pages with mounted parallel slots, the Form's
prefetched cache key wouldn't match the navigation lookup, so the prefetch was a
silent cache miss (wasted work, no dedup against a nearby <Link>). Mirror the
Link/router computation: build context-aware headers, key the prefetched set on
encodeCacheKey, and thread interceptionContext/mountedSlotsHeader through
hasPrefetchCacheEntryForNavigation and prefetchRscResponse.

* docs(form): note prefetch effect dep-array redundancy is lint-mandated

Bonk flagged actionHref as redundant alongside action in the prefetch effect's
dep array. It is derived purely from action + the module constant, but the
exhaustive-deps lint requires it to be listed since the effect references it.
Document this so it isn't mistaken for an oversight.

---------

Co-authored-by: James <james@eli.cx>
2026-06-06 00:04:08 +01:00
James Anderson 8d00797ada feat(cache): extract Cloudflare cache adapters into @vinext/cloudflare (#1748)
* refactor(cache): extract Cloudflare cache adapters into @vinext/cloudflare

Move the Cloudflare KV data cache and edge CDN cache adapters out of
vinext into a new publishable @vinext/cloudflare package:

  - cache/kv-data-adapter(.runtime).ts  (KVCacheHandler, kvDataAdapter)
  - cache/cdn-adapter(.runtime).ts       (CloudflareCdnCacheAdapter, cdnAdapter)

tpr.ts stays in vinext. vinext now depends on @vinext/cloudflare
(workspace:*) and the package declares vinext as a peer dep; both build
from source via tsconfig paths so there is no build-order cycle. The
vinext/cloudflare barrel still re-exports KVCacheHandler for back-compat.

Wires up tsconfig paths, a vitest source alias, root build/postinstall,
and the preview/publish workflows for the new package. Updates internal
consumers (apps/web, examples/workers-cache), docs, and tests.

* ci(create-next-app): install @vinext/cloudflare from local tarball

vinext now depends on @vinext/cloudflare, which isn't published to npm
yet. The create-next-app smoke test packs vinext locally and resolves
its deps from the registry, so the install (and dev server) failed with
ERR_PNPM_FETCH_404 for @vinext/cloudflare.

Pack @vinext/cloudflare alongside vinext and add a pnpm override in the
scaffolded project pointing at the local tarball so the dependency
resolves offline.

* refactor(cloudflare): address review feedback

- Remove the root barrel export from @vinext/cloudflare; expose only the
  ./cache/* subpaths via a wildcard export (no root main/types).
- vinext/cloudflare re-exports KVCacheHandler from the full subpath.
- Drop the redundant .npmignore (the package.json "files" allowlist
  already restricts the publish to dist).
- Remove the unsupported imperative setCacheHandler/KVCacheHandler usage
  from both READMEs; the cache plugin config is the supported approach.
- Simplify test wiring: drop the now-unused @vinext/cloudflare tsconfig
  path and dedupe the vitest source alias into a shared constant.

* chore(cloudflare): drop unused vite devDependency

The @vinext/cloudflare config uses vite-plus and nothing imports vite, so
the vite devDependency was unused. build/check/knip stay green without it.

* Apply suggestion from @james-elicx
2026-06-05 14:57:20 +01:00
James Anderson f0f6aa72e0 feat(cache): configure cache adapters from vite plugin config (#1733)
* feat(cache): configure cache adapters from vite plugin config

Add a `cache` option to the vinext() plugin so CDN and data cache
adapters can be declared in vite.config instead of calling
setDataCacheHandler() / setCdnCacheAdapter() from a worker entry:

  vinext({
    cache: {
      cdn:  { adapter: require.resolve('vinext/cloudflare/cache/cdn-adapter') },
      data: { adapter: require.resolve('vinext/cloudflare/cache/kv-data-adapter') },
    },
  })

Each slot points at an adapter module whose default export is a factory
(DataCacheAdapterFactory / CdnCacheAdapterFactory). The plugin generates
a virtual:vinext-cache-adapters module that the App Router worker entry
calls per request (self-guarded, once per isolate), passing the host env
so binding-backed adapters (e.g. KV) can read their namespace.

Ships ready-made Cloudflare adapter entry points:
  - vinext/cloudflare/cache/kv-data-adapter  (KVCacheHandler)
  - vinext/cloudflare/cache/cdn-adapter      (CloudflareCdnCacheAdapter)

* feat(cache): add typed adapter builders (kvDataAdapter/cdnAdapter)

Instead of `{ adapter: require.resolve(...) }`, each adapter module now
also exports a config-time builder from the same path:

  import { cdnAdapter } from 'vinext/cloudflare/cache/cdn-adapter';
  import { kvDataAdapter } from 'vinext/cloudflare/cache/kv-data-adapter';

  vinext({ cache: { cdn: cdnAdapter(), data: kvDataAdapter({ binding: 'MY_KV' }) } })

A builder returns a plain, serializable { adapter, options } descriptor —
it never touches the Workers runtime, so nothing throws at config / build
/ dev time when bindings aren't available. Descriptor `options` (e.g. the
KV binding name) are inlined into the generated registration module and
forwarded to the factory's { env, options } context, where the binding is
resolved lazily on the first request.

- shims/cache-adapter: descriptors + options-aware factory/context types
- kv-data-adapter: kvDataAdapter() builder + configurable binding/appPrefix/ttl
- cdn-adapter: cdnAdapter() builder
- raw { adapter, options } path form still supported

* test(cache): verify absolute (require.resolve) local adapter path bundles

Real Cloudflare build pointing cache.data at a local adapter file by
absolute path (what require.resolve('./adapter') yields). Proves the
generated registration module resolves the absolute import, bundles the
local adapter into the worker, and does not need any Workers context at
build time.

* refactor(cache): builder require.resolve + register across all routers/runtimes

Addresses review feedback:

* Move adapters into their own runtime modules instead of re-exporting.
  Each adapter is now a builder module (kv-data-adapter.ts / cdn-adapter.ts)
  plus a sibling *.runtime.ts holding the default-export factory. Type
  definitions have a single home in shims/cache-adapter.ts (dropped the
  re-export shim; index.ts imports the config type from there).

* The exposed builder utility resolves the relative runtime path internally
  via import.meta.resolve (the ESM require.resolve), so the descriptor carries
  an absolute path to the runtime factory rather than a bare specifier — the
  example is just kvDataAdapter({ binding }), no require.resolve at the call site.

* Register configured cache handlers EVERYWHERE, not just the App Router worker:
  - App Router: the generated RSC entry passes registerConfiguredCacheAdapters
    into createAppRscHandler, which calls it per request — covering Workers,
    the Node server, and dev through the one shared handler.
  - Pages Router: the generated server entry registers in renderPage and
    handleApiRoute (Node/dev), and the generated worker registers with env
    (Workers, for KV bindings).
  Registration self-guards (first call with real env wins) and is now resilient:
  a factory that throws on an incompatible runtime is logged and skipped, so the
  default handler stays in place instead of failing every request.

Tests: generator-level assertions that every router/runtime entry wires
registration, plus the existing builder/codegen/factory and full-build coverage.
vp check clean; app-router (339) and pages-router (272) suites pass.

* refactor(cache): keep all Cloudflare adapter code under cloudflare/

The adapter factory contract lived in shims/cache-adapter.ts (outside
cloudflare/), and the Cloudflare adapters reached out to it. Move the
contract into cloudflare/cache/adapter.ts so every Cloudflare-specific
cache adapter file is self-contained under cloudflare/ — importing only
cloudflare-local modules and the core CacheHandler/CdnCacheAdapter
interfaces it implements.

The plugin's config schema (CacheAdapterDescriptor / VinextCacheConfig)
is genuinely framework-level (it's the vinext() `cache` option), so it
moves into the codegen module the plugin already owns; index.ts imports
it from there. Builders return a structural { adapter, options } so they
don't import the descriptor type either. Deletes shims/cache-adapter.ts.

* refactor(cache): merge KV/CDN classes into the runtime adapter files

All Cloudflare cache code now lives in one directory, cloudflare/cache/,
and each runtime file holds both the implementation class and its
config-driven factory (no separate class module to reach for):

  - kv-cache-handler.ts        -> cache/kv-data-adapter.runtime.ts
    (KVCacheHandler + ENTRY_PREFIX + createKvDataCacheAdapter default export)
  - cloudflare-cdn-cache.ts    -> cache/cdn-adapter.runtime.ts
    (CloudflareCdnCacheAdapter + createCloudflareCdnCacheAdapter default export)

Updated importers: cloudflare/index.ts re-exports the classes from the
runtime files, tpr.ts pulls ENTRY_PREFIX from there, shims/cdn-cache.ts
imports the edge adapter from there, and the tests follow the moved paths.
git mv preserves history.

vp check clean; cache/kv/cdn/app-route/tpr/shims suites pass (1300+ tests).

* chore(cache): trim low-value comments added in this branch

Remove narrating/redundant comments that just restated the code; keep
the non-obvious why (registration ordering/resilience, import.meta.resolve
rationale, edge cache-control semantics). No code changes.

* review: address PR #1733 feedback

- Make registerCacheAdapters a required field on the RSC handler options
  (the generated entry already passes it; test factory updated).
- Remove the separate cloudflare/cache/adapter.ts contract file; inline the
  factory param types directly into the two runtime adapters.
- Drop the CloudflareCdnCacheAdapter re-export from cloudflare/index.ts.
- Fold the virtual:vinext-cache-adapters declaration into global.d.ts and
  delete the standalone .d.ts.
- Remove the ./cloudflare/cache/* package.json export for now; README uses a
  local-adapter require.resolve example with a note that the built-in adapter
  export paths are pending.
- Rename the config-driven KV default binding to VINEXT_KV_CACHE (imperative
  deploy/tpr path keeps VINEXT_CACHE — flagged on the thread).

* refactor(cache): align KV binding name to VINEXT_KV_CACHE everywhere

Rename the KV cache binding from VINEXT_CACHE to VINEXT_KV_CACHE across the
whole codebase so the config-driven adapter, the imperative deploy-generated
worker, TPR's wrangler detection, and the apps/web example all agree. The
unrelated X-Vinext-Cache response-header constant (VINEXT_CACHE_HEADER) is
untouched.

* tidy

* .

* .

* .

* .

* .

* Move apps/web cache to plugin config

Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>

---------

Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
2026-06-04 23:30:33 +00:00
James Anderson d8ca7e8177 test(i18n): align shallow redirect-skip assertion with Next.js history state (#1729)
The i18n sticky-locale change (f4d13e25) made router pushState write a
Next.js-shaped history state ({ url, as, options, __N, key }) instead of
{}, and updated 6 matching assertions in shims.test.ts. The shallow
Router.push redirect-skip test (#1522) carried the same {} assertion but
was missed, breaking the unit suite on main.

Align it with the others: assert the first pushState arg via
expect.objectContaining({ __N: true }) while still verifying the visible
URL committed is /redirect-1.
2026-06-03 22:34:47 +00:00
James Anderson f4d13e25b9 fix(i18n): make locale sticky across client navigations (#1407)
* fix(i18n): make Pages Router locale sticky across client navigations

Refs #1336 (item 2).

Writes a Next.js-compatible history state shape on every router
push/replace, captures the active locale in `state.options.locale`, and
teaches the popstate handler to:

1. Ignore history entries written by third-party code (no `__N: true`).
2. Drop the first popstate when it carries the same locale + `as` as the
   current page (Safari tab-restore / BFCache replay parity).
3. Honour the locale recorded in history state when re-fetching the page
   on back/forward, so default-locale roots still go through their
   locale-qualified HTML endpoint.

This matches the Next.js behaviour exercised by
`test/e2e/ignore-invalid-popstateevent` and the client half of
`test/e2e/i18n-preferred-locale-detection`.

Scope boundary: the server-side `Set-Cookie: NEXT_LOCALE=...` on initial
Accept-Language detection is intentionally NOT modified here. The
existing `parseCookieLocaleFromHeader` already gives the cookie priority
over Accept-Language, and changing where the cookie is written would
overlap with #1336 item 4 (default-locale prefix normalisation).

* fix(i18n): preserve back/forward navigation under new history-state shape

The previous commit attached `__N: true` to router-owned history entries,
but two regressions slipped in:

1. `saveScrollPosition` merges scroll fields onto the *current* entry
   before a push. When the current entry was the initial document load
   (`state === null`), the merge produced a state object without `__N`,
   which the popstate foreign-state filter then dropped — breaking
   browser back/forward.

   Fix: when the existing state is null, mint a router-shaped state
   (`{ __N: true, url, as, options, key }`) before merging the scroll
   fields, so every entry the router touches carries `__N`.

2. The Safari-replay filter compared `state.as` against `window.location`,
   but `window.location` has already changed by the time popstate fires
   on a real back/forward. That made every genuine back navigation look
   like a replay and get ignored.

   Fix: compare against `_lastPathnameAndSearch` (the URL the router
   last actively navigated to). After a real back, that tracker still
   points at the entry we were on, so a genuine navigation differs and
   passes the filter — matching Next.js's `state.as === this.asPath`
   semantics.

Verified locally:
- tests/pages-router-i18n-sticky-locale.test.ts: 7/7 pass
- tests/shims.test.ts: 944/944 pass
- tests/e2e/pages-router/{navigation,before-pop-state,hydration,router-events}.spec.ts:
  28/28 pass (these were the CI failures from the previous commit).

* fix(i18n): stamp initial history entry with active locale on install

Closes the remaining gap in the popstate locale-recovery path: the
initial document entry has `state: null` until the first push, so a
back-navigation to it would fall back to the live `window.__VINEXT_LOCALE__`
— which an intervening locale-changing push may have flipped. Stamping
router-shaped state at install captures the SSR-resolved locale into
`options.locale` and lets the popstate handler restore the correct locale.

Also:
- Strip the hash from `state.as` so the Safari-replay filter's
  `withBasePath(state.as) === _lastPathnameAndSearch` comparison works for
  entries created from a hash-bearing URL (the tracker never carries a hash).
- Drop the unnecessary `withBasePath as withBasePathPrefix` import alias.

Adds 3 tests: install-time stamping, no-clobber of pre-existing state, and
the back-to-initial-entry locale-recovery regression.

* fix(i18n): prefer active locale over default + scope Safari-replay filter

Three small follow-ups discovered by CI on top of the i18n-sticky-locale
work:

1. `getCurrentBrowserLocale` was falling back to `__VINEXT_DEFAULT_LOCALE__`
   before `__VINEXT_LOCALE__` when no path prefix was present, which threw
   away the active locale on default-locale paths served under a
   non-default locale. Flip the fallback order so the active locale wins —
   exactly what the new `pages-router-i18n-sticky-locale.test.ts` 'Router
   .push without an explicit locale' case asserts.

2. The Safari-replay filter on the first popstate event was running even
   after the user had already navigated. After a hash-only `Router.push`,
   the entry's `state.as` is recorded without a hash, which happens to
   match `_lastPathnameAndSearch` (also without a hash); the filter then
   incorrectly dropped a genuine `goBack()` popstate, freezing the E2E
   router-events suite. Gate the filter behind a new `_routerDidNavigate`
   flag so it only fires during the synthetic initial-load replay window.

3. Update the 6 `expect(pushState).toHaveBeenCalledWith({}, ...)`
   assertions in `tests/shims.test.ts` to use
   `expect.objectContaining({ __N: true })`, since pushState now carries a
   Next.js-compatible state object instead of `{}`.

* test(i18n): align unprefixed-path locale tests with sticky-locale behavior

The previous "treat unprefixed path as default locale" assertions in
tests/link.test.ts and tests/shims.test.ts contradicted the intentional
sticky-locale flip in ab662c07 (getCurrentBrowserLocale now prefers
__VINEXT_LOCALE__ over __VINEXT_DEFAULT_LOCALE__). Update both tests to
assert the new behavior: a default-locale path served under a non-default
locale keeps its active locale for client-side root navigations.
2026-06-03 23:12:43 +01:00
한만욱 82b2720701 feat(app-router): support useRouter bfcacheId semantics (#1588)
* feat(app-router): implement useRouter bfcacheId semantics

* fix(app-router): refine bfcacheId navigation behavior

* test(app-router): align bfcache history state expectations

* fix(app-router): handle catch-all bfcache identities

* fix(app-router): align bfcache context semantics

* fix(app-router): avoid hidden bfcache DOM retention

* fix(app-router): hydrate bfcache ids from history state

* fix(app-router): handle bfcacheId redirects and slots

* test(app-router): expect active slot route ids

* Fix bfcache id history restoration

* Clarify bfcache id sentinel formatting

* Address bfcacheId review feedback

* Tie bfcacheId sentinels to shared constants

* Address bfcacheId review clarity

* Tighten bfcache slot identity check

* Deduplicate bfcache history sync check

* Handle empty bfcache segment ids

* Fix intercepted active slot route ids

* Clarify bfcacheId form state coverage

* Address bfcacheId review clarity

* fix(app-router): skip invisible tree-path segments in bfcache identity

getVisibleTreePathSegments only stripped route groups, not parallel
@slots or "." default segments. Tree paths contain raw filesystem
segments, so a slot preceding visible URL segments over-counted the
consumed pathname segments and produced a wrong identity prefix. That
re-minted the bfcacheId for layouts/slots that actually persisted across
parallel-route navigations (e.g. @modal), diverging from Next.js
bfcacheId semantics.

Delegate to the canonical isInvisibleSegment helper so the filter stays
in sync with the route graph, and add a parallel-slot layout regression
test (fails without the fix).

Also tighten the hydration-map unit test to actually exercise the
"no seeding from minted ids" property instead of asserting a local
literal, and correct the SSR bfcache context comment.

* fix(app-router): move isInvisibleSegment to browser-safe utils

The previous commit imported isInvisibleSegment from app-route-graph.ts
into app-browser-state.ts, but app-route-graph.ts imports node:path,
node:fs and node:crypto. Pulling it into the browser bundle broke client
hydration (app-with-src E2E: instrumentation-client and
dev-overlay-recovery timed out waiting for hydration / the navigation
runtime).

Move the pure isInvisibleSegment helper into the browser-safe
routing/utils.ts module (already in the client bundle), re-export it
from app-route-graph.ts so Node-side import sites keep working, and have
app-browser-state.ts import it from utils.js. The app-route-graph type
import is now type-only.

* refactor(app-router): address bfcacheId review feedback

- Reject history entries with a missing bfcache version in the
  document-scoped version gate (stop coercing null -> 0) so stale ids
  from older builds / external pushState can't be restored into a fresh
  document on the version-0 path.
- Parse AppElements metadata once per element map per commit and resolve
  slot bindings via a precomputed Map (was repeated readMetadata parses
  plus an O(slots^2) find on every navigation commit).
- Read single history-state keys without cloning the whole state object;
  the write path still clones before mutating.
- Dedupe the two identical non-negative-safe-integer validators and share
  path-segment / catch-all helpers via browser-safe routing/utils.
- Move tree-path consumed-segment counting into routing/utils and fix
  optional-catch-all over-counting.
- Drop the unused bfcacheIds param from createHistoryStateWithPreviousNextUrl.

* refactor(routing): make catch-all segment predicates module-private

isCatchAllSegment/isOptionalCatchAllSegment are only used by
countConsumedPathnameSegments in the same module; drop the exports to
clear knip's unused-export warning.

* test(app-router): lock in bfcache version-gate rejection of missing versions

Extract the version-gate decision into a pure, exported
isHistoryStateBfcacheVersionCurrent(state, currentVersion) in
app-history-state (re-exported via app-browser-state); the browser
entry's isCurrentBfcacheVersion now delegates to it. Add a direct
regression test asserting a history entry with bfcache ids but no
version key is NOT treated as current even when the current version is
0 — the exact cross-document stale-id hole that the earlier `?? 0`
coercion left open.

---------

Co-authored-by: James <james@eli.cx>
2026-06-02 13:53:27 +01:00
James Anderson 1047cccd8a fix(metadata): omit unused parent arg for cached generateMetadata (#1719)
* fix(metadata): omit unused parent arg for cached generateMetadata

A module-level `'use cache'` directive wraps `generateMetadata` in the cache
runtime. vinext always called it as `generateMetadata(props, parent)`, where
`parent` resolves to the merged ancestor metadata. When an ancestor sets
`metadataBase: new URL(...)`, the cache-key encoder (`encodeReply`) awaited the
parent promise and tried to serialize the `URL` instance, logging React's
"Only plain objects can be passed to Server Functions from the Client. URL
objects are not supported." warning.

Match Next.js, which omits the `parent` argument for `generateMetadata`
functions that don't use it (resolve-metadata.ts `getResult` /
`useCacheFunctionInfo.usedArgs[1]`):

- `resolveModuleMetadata` now only passes `parent` when `generateMetadata`
  declares it (`fn.length >= 2`).
- `registerCachedFunction` preserves the original function's arity on the
  wrapper so the arity check works for cached functions.

Adds regression tests for the arity gating, metadataBase preservation, and
arity-preserving wrapper.

* docs(metadata): note arity approximates parent usage; drop dead try/catch

Address /bigbonk review feedback:
- Document that fn.length approximates Next.js's usedArgs[1] analysis and
  diverges on default-parameter signatures.
- Remove the dead try/catch around Object.defineProperty (function length is
  always configurable per spec).
2026-06-02 11:50:17 +01:00
James Anderson ffc8e68c4a fix(shims/head): match Next.js charset/viewport order and merge _document.getInitialProps head (#1677)
* fix(shims/head): match Next.js charset/viewport order and merge _document.getInitialProps head (#1569)

Pages Router head emission did not match Next.js's canonical order
(`<meta charset>` then `<meta viewport>` then user tags, all with
`data-next-head=""`) and ignored head tags returned from a user
`_document.getInitialProps()`.

- `next/head` shim now seeds `defaultHead()` (charset + viewport) into
  the SSR collector before user tags, mirroring `defaultHead()` in
  `next/src/shared/lib/head.tsx`. They flow through the same dedupe
  pipeline so users can still override via `key="charset"` /
  `key="viewport"`.
- Serialised attribute names follow HTML conventions
  (`charSet` -> `charset`, `httpEquiv` -> `http-equiv`, etc.), matching
  Next.js's `test/e2e/next-head/index.test.ts` exact-string assertion.
- `next/document` shim no longer hardcodes charset/viewport in `<Head>`
  (those defaults now flow from `next/head`); fallback HTML shells in
  `dev-server.ts` and `pages-page-response.ts` likewise drop the
  duplicates.
- New `setDocumentInitialHead()` accessor and ALS-backed state lets the
  SSR pipeline forward head tags returned by user
  `_document.getInitialProps()` into the same dedupe pipeline as
  `next/head` children. Both the dev server and the prod
  `renderPagesPageResponse` invoke `_document.getInitialProps()` when
  present and pass the result through.

Adds focused regression coverage in `tests/head.test.ts` for ordering,
default attribute serialisation, key-based overrides, and the
`_document.getInitialProps()` merge path.

Refs #1569.

* fix(pages-router): address review feedback on _document head merge

- Extract shared `callDocumentGetInitialProps` helper under
  `server/document-initial-head.ts` so both `dev-server.ts` and
  `pages-page-response.ts` go through the same code path. The previous
  duplicated try/catch blocks drifted independently and were the kind of
  runtime-behavior that AGENTS.md says should live in a helper.

- Skip the call when `DocumentComponent.getInitialProps` is still the
  unmodified default inherited from vinext's `next/document` shim.
  Comparing against the captured base reference (`DEFAULT_GET_INITIAL_PROPS`)
  distinguishes a user override from the default — extending `Document`
  without overriding the method inherits the same static function, so
  invoking it on every render is wasted work.

- Use `jsxAttrToHtml` on the client-side `_applyHeadPropsToElement` path
  so `httpEquiv` is set as `http-equiv` rather than `httpequiv`
  (lowercased by `setAttribute`). Matches the SSR output and prevents a
  hydration mismatch when SSR emits the hyphenated form.

- Update stale `tests/document.test.ts` Head assertions: the shim's
  `<Head>` no longer hardcodes charset/viewport — those flow through
  `next/head`'s `defaultHead()` so the dedupe pipeline handles them.
2026-06-01 11:06:28 +01:00
Steve Faulkner 87479df147 fix: bound default memory cache size with LRU eviction (#1699)
MemoryCacheHandler used an unbounded Map for default ISR/fetch/cache
storage. Add a Next-style memory cap with approximate byte sizing and
least-recently-used eviction.

Behavior:
- default max size is 50 MiB, matching Next.js cacheMaxMemorySize default
- cacheMaxMemorySize: 0 disables default memory storage
- entries larger than the configured cap are not stored
- get() refreshes LRU order
- tag invalidation metadata is capped to 10k entries

Wire next.config.js cacheMaxMemorySize into both App and Pages generated
entries via configureMemoryCacheHandler(). Add focused unit coverage for
LRU eviction, get() recency refresh, and disabled storage.
2026-05-30 21:37:42 -05:00
James Anderson a6a279509c fix(shims): throw Pages Router parity error from unstable_retry (#1448) (#1670)
Closes the Pages Router parity gap noted as a follow-up in #1206. When
`unstable_catchError`'s boundary is rendered under the Pages Router,
`unstable_retry()` now throws Next.js's verbatim error message
("`unstable_retry()` can only be used in the App Router. Use `reset()`
in the Pages Router.") instead of calling App Router's `refresh()`
(which is a no-op there and leaves the user stuck on the error).

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

Regression test ported from
`test/e2e/app-dir/catch-error/catch-error.test.ts` —
"should throw when unstable_retry is called on Pages Router".
2026-05-28 21:16:51 +01:00
James Anderson 33be785d43 fix(app-router): allow draftMode() reads inside cache scopes (#1489) (#1669)
Calling `await draftMode()` and reading `.isEnabled` inside a `"use cache"`
or `unstable_cache()` callback now works the same as Next.js: the
non-mutating reads succeed and reflect the `__prerender_bypass` cookie,
while only the side-effecting `enable()` / `disable()` calls throw when
invoked inside a cache scope.

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

Ported from `.nextjs-ref/packages/next/src/server/request/draft-mode.ts`
(`getDraftModeProviderForCacheScope` + `trackDynamicDraftMode`).
2026-05-28 20:12:13 +01:00
James Anderson 98c8286fdc test(pages-router): shallow Router.push bypasses redirect for as (#1522) (#1681)
Port the Next.js "should not apply client router filter on shallow" test
(test/e2e/app-dir/app/index.test.ts) so we lock in vinext's behaviour:
`Router.push(url, as, { shallow: true })` must update history to `as`
(/redirect-1) without fetching the page HTML, even when `as` matches a
server-side `redirects` rule from next.config.js.

vinext already produces the correct behaviour — `performNavigation`
skips `runNavigateClient` when `shallow` is true, so the server-side
redirect table never runs and `window.history.pushState` lands on
`as`. The test guards against future regressions (e.g. introducing a
Next.js-style client router bloom filter without exempting shallow
updates) and serves as the parity counterpart to Next.js's own bypass
in `_bfl` in packages/next/src/shared/lib/router/router.ts.

Refs #1522.
2026-05-28 20:06:54 +01:00
James Anderson c8b8fc3370 fix(cache): updateTag throws outside Server Action (#1485) (#1641)
Match Next.js semantics: `updateTag` from `next/cache` must throw when
called outside a Server Action context (e.g. in a Route Handler or
during render), and only the in-action invariant invokes the cache
tag invalidation.

Detect the headers access phase ("action") via the existing shim and
throw the canonical error message otherwise. Existing in-action
behavior (immediate tag expiry via the active cache handler) is
preserved.
2026-05-28 14:13:20 +01:00
James Anderson 53a97e2002 fix(navigation): stabilize Pages Router useParams snapshot for SSR (#1639)
`useParams()` / `useSearchParams()` / `usePathname()` from `next/navigation`
work as cross-router compat shims under Pages Router, deriving their state
from `getPagesNavigationContext()` published by `next/router`. React's
`useSyncExternalStore` requires `getServerSnapshot` to return a stable
reference (Object.is) across calls in a single render — otherwise React
logs:

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

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

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

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

Refs #1466
2026-05-28 14:11:36 +01:00
Nathan Nguyen 8e3438d267 fix(app-router): complete default autoscroll page target parity (#1601)
* fix(app-router): complete default autoscroll page target parity

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

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

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

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

- Consume scroll intent before returning no-commit in renderNavigationPayload,
  preventing the navigateClientSide fallback from scrolling for an abandoned
  navigation.
- Replace waitForTimeout(700) with expect.poll for robust race-condition
  stability assertion (CI-flake-safe, stronger proof of stability).
- Update wrapperless targeting comment to clarify outer function / inner
  class split.
- Add three unit tests covering no-commit cleanup, stale intent rejection,
  and claim-without-commitId consumption paths.
2026-05-28 12:33:05 +01:00
Nathan Nguyen 0046e0691a fix(app-router): preserve encoded prerender params (#1597)
* fix(app-router): preserve encoded prerender params

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No behavior change; comments only.
2026-05-27 10:55:26 +01:00
Nathan Nguyen 5b633b0284 fix(pages): render masked basePath error routes (#1441)
* fix(pages): render masked basePath error routes

Pages Router route misses under basePath returned generic HTML and masked client navigations to /404 or /_error could fetch the visible as-path instead of the error route. This diverged from Next.js when applications used next/router to preserve a friendly URL while rendering the error component.

The implementation assumed the browser URL and the component route could always collapse to one fetch target. Preserve the masked history URL, fetch the Pages error route as the HTML target, allow its 404 response to hydrate, and render pages/404 for production route misses with the correct status and client module metadata.

* test(pages): keep error navigation coverage out of vitest browser

Vitest integration CI does not install Playwright browsers, so launching Chromium from tests/pages-router.test.ts made the PR fail deterministically even though the upstream deploy harness passed.

Keep the browser-level verification in the upstream deploy-suite run and cover vinext locally at the lower boundaries: production 404 rendering through HTTP and Pages Router masked error-route navigation through the router shim. The generated Pages entry now also shares one SSR manifest module lookup helper between asset tag collection and client module URL resolution.

* fix(prerender): keep custom 404 out of generic page loop

* fix(pages): fall back to _error on route misses

* fix(pages): document the catch-all guard on matchRoute("/404")

The pattern check prevents a dynamic catch-all route (e.g. [...slug]) from
being used as the 404 fallback. Without it, matchRoute can return a
catch-all match and silently hijack the error rendering path.

* fix(pages): preserve error navigation rewrite order

* fix(pages): limit fallback rewrites to route misses

* fix(deploy): skip deferred error rendering for data requests

* fix(pages): preserve ISR response status

* fix(pages): keep module metadata in ISR regeneration
2026-05-27 09:36:13 +01:00
Nathan Nguyen 5fe9789bbd fix(app-router): preserve history index on external state writes (#1600)
* fix(app-router): preserve history index on external state writes

External history.pushState and history.replaceState calls preserved vinext interception metadata but dropped the traversal index. That left userland or library shallow history entries without the metadata App Router traversal uses to classify and anchor back/forward state.

createExternalHistoryStatePreservingMetadata now preserves vinext navigation metadata as one unit from the current history entry. The shim regression covers object, null, undefined, and index-only external state writes.

* test(app-router): seed stale history context through unpatched state

Public History API calls now sanitize vinext metadata before delegating. The refresh regression test was seeding stale intercepted context through that public wrapper, so it tested the wrapper boundary instead of the stale internal-state condition.

Use the saved original replaceState handle to create the same framework-owned stale state the test is meant to refresh away.
2026-05-26 15:49:58 +01:00
Nathan Nguyen a0b8ae068a fix(draft-mode): keep draft secret out of client defines (#1592)
* fix(draft-mode): keep draft secret out of client defines

The draft-mode bypass token was emitted through Vite define, so Vite's client env payload and client chunks could contain the server-only cookie secret when app code referenced that env key.

The violated boundary was treating a server runtime credential like a shared compile-time define. Generate one plugin-scoped secret and initialize server entries with it, while next/headers reads only internal server state.

Adds regression coverage for dev env exposure, production client bundle leakage, and the built server draft cookie round trip.

* test(draft-mode): initialize secret after module reset

* fix(draft-mode): scope draft secret to request context

Draft mode validation briefly lived in a package-global singleton after removing the client-visible define. That made multiple app instances in the same process able to overwrite each other's validator.

Thread the generated app secret through the App Router request and dispatch boundaries, store it on HeadersContext, and make cache bypass validation a pure request-plus-secret check. Add a regression that proves two contexts keep independent draft validators.

* fix(draft-mode): guard stale draft controls
2026-05-26 15:00:44 +01:00
Nathan Nguyen 4b4dc8d2ef fix: handle Pages Router middleware redirects (#1439) 2026-05-26 11:35:58 +01:00
James Anderson 61cf2e78f5 fix(image): emit /_next/image URLs (#1562)
* fix(image): emit /_next/image URLs to match Next.js

Closes #1513

The default image loader and optimization endpoint switched from the
vinext-specific /_vinext/image path to Next.js's canonical /_next/image.
This unblocks the deploy suite tests that import Next.js's expected
URL shape (/_next/image?url=...&w=...&q=...).

* refactor(image): use IMAGE_OPTIMIZATION_PATH constant at remaining call sites

Replace hardcoded "/_next/image" strings in index.ts, app-rsc-handler.ts,
and the generated worker entry templates in deploy.ts with the
IMAGE_OPTIMIZATION_PATH constant from server/image-optimization, matching
the pattern already used in prod-server.ts. Prevents future drift if the
path ever changes again.

* feat(image): accept both /_next/image and /_vinext/image at the optimizer

Add a VINEXT_IMAGE_OPTIMIZATION_PATH constant and an
isImageOptimizationPath() helper, then route through every match site
(prod-server, dev server passthrough, app RSC handler, generated worker
templates, and shipped example workers). Apps that wire image URLs to
either prefix now hit the same handler; new URLs are still emitted via
IMAGE_OPTIMIZATION_PATH.
2026-05-26 10:28:22 +01:00
James Anderson e3b1cde53d fix(pages-router): document push/replace Promise<boolean> contract (#1566)
Pages Router `window.next.router.push`/`replace` already resolved to
`true` on a successful navigation (and `false` on a hard-navigation
fallback) via `performNavigation`, but the public `window.next.router`
surface typed both as `(...args: unknown[]) => unknown`, leaving
consumers (and the Next.js deploy test suite that `awaits` the result)
without a typed boolean to assert on.

- Narrow `PagesRouterPublicInstance.push`/`replace` to
  `(...args: unknown[]) => Promise<boolean>` to match Next.js's
  `Router.change` contract (router.ts:1206 — `Promise<boolean>`).
- Add unit-test regression coverage asserting
  `window.next.router.push`/`replace` resolve to `true` on success.
- Void the `router.push(...)` calls in `tests/e2e/app-router/build-id-navigation.spec.ts`
  to satisfy the tightened union (App Router push is `void`).

Closes #1467
2026-05-26 10:10:41 +01:00
Nathan Nguyen 62454ef447 fix: private use-cache request data (#1445)
* fix use cache private request data

* fix private cache nesting guard
2026-05-22 17:21:43 +01:00
James Anderson 40d23de157 fix(next-request): throw canonical relative URL error (#1560)
* fix(next-request): throw canonical relative URL error

Calling `new NextRequest('/foo')` previously bubbled up `Failed to parse
URL from /foo` because the constructor's fallback `new URL(input,
"http://localhost")` masked the real problem. Next.js's NextRequest
runs `validateURL(url)` at the top of its constructor and throws an
error containing `Please use only absolute URLs`. Middleware tests
that assert on this message text now match Next.js.

Closes #1521

* fix(next-request): collapse inline URL validation to validateURL helper

Address bot review nit: the 13-line inline URL validation duplicated the
existing `validateURL` helper at line 215. Replace it with a call to the
helper. Bonus: relative URL rejection now also picks up the
`assertSafeNavigationUrl` guard against `javascript:`/`data:`/`vbscript:`
URIs (defense-in-depth).
2026-05-22 16:07:20 +01:00
James Anderson 97c11cdc37 fix(pages-router): rename data-vinext-head to data-next-head (#1559)
Pages Router <Head> now emits the canonical `data-next-head=""`
attribute matching Next.js, rather than the vinext-specific
`data-vinext-head="true"`. This unblocks `_document` head lookups
in the deploy suite.
2026-05-22 15:54:59 +01:00
James Anderson e121d42ef0 fix(middleware): warn when legacy middleware filename is detected (#1561)
Next.js 16 renamed `middleware.{js,ts}` to `proxy.{js,ts}` and emits a
canonical deprecation warning when the legacy filename is detected. vinext
was emitting a custom message that didn't match the wording the Next.js
deprecation-warnings and app-middleware e2e suites assert on.

Update `findMiddlewareFile` to log the exact text Next.js uses (from
`packages/next/src/build/index.ts` adjacent to `MIDDLEWARE_FILENAME`):

  The "middleware" file convention is deprecated. Please use "proxy" instead.
  ...migration codemod and docs link...

The warning still fires only when `middleware.{js,ts}` is found (after
checking `proxy.{js,ts}` first) so behavior is unchanged for projects on
the new convention. Existing test in tests/shims.test.ts is tightened to
assert the canonical wording.

Closes #1455
2026-05-22 15:54:42 +01:00
James Anderson d6bf6413e9 feat(pages-router): consume _next/data JSON endpoint from the client (#1412)
* feat(pages-router): consume _next/data JSON endpoint from the client

The server-side `/_next/data/<buildId>/<page>.json` endpoint landed in
#1384, but the vinext client was still fetching the full HTML page on
every navigation and regex-extracting `__NEXT_DATA__` from it. This
wires the client to actually use the JSON endpoint, matching what
Next.js does and avoiding the HTML round trip.

## Client navigation (shims/router.ts)

`navigateClient` is split into two paths:

- `navigateClientData` (new): when `window.__VINEXT_PAGE_LOADERS__` has
  a registered code-split loader for the target route AND we have a
  buildId, fetch `/_next/data/<buildId>/<page>.json` with
  `Accept: application/json` + `x-nextjs-data: 1`. On 200 parse the
  envelope, look up the page module via the in-memory loader map (Vite
  has already split each page into its own chunk), and re-render.
- `navigateClientHtml`: the previous HTML-fetch + regex-extract logic,
  unchanged. Used in dev (where the inline hydration script does not
  populate the loader map) and as a fallback for any route not in the
  client loader map.

Failure modes — 404, 5xx, network error, malformed JSON, soft
redirect via `x-nextjs-redirect`, missing loader — all queue a hard
navigation (`window.location.href = url`). This is the deploy-skew
safety net: when the server's buildId has rotated, the data endpoint
returns 404 and the client lands on the new build via a full document
load.

## Code-split loader manifest (entries/pages-client-entry.ts)

Vite already code-splits each page into its own chunk via the dynamic
imports in `pageLoaders`. The generated client entry now exposes that
map on `window.__VINEXT_PAGE_LOADERS__` (route pattern → loader thunk),
plus `window.__VINEXT_PAGE_PATTERNS__` (ordered list) and
`window.__VINEXT_APP_LOADER__` (the `_app` thunk, when present). This
replaces Next.js' `_buildManifest.js` indirection — vinext already
generates the equivalent at build time, we just had to expose it.

## Data URL construction (shims/internal/pages-data-url.ts)

New pure helper module mirroring `getAssetPathFromRoute` +
`getDataHref` from Next.js. Handles:

- `/`            → `/_next/data/<id>/index.json`
- `/about`       → `/_next/data/<id>/about.json`
- `/index`       → `/_next/data/<id>/index/index.json` (round-trip)
- `/blog/foo`    → `/_next/data/<id>/blog/foo.json`

Also exports `matchPagesPattern` which uses the existing
`matchRoutePattern` to map a URL pathname to a registered route
pattern.

Round-trip tests verify the server's `parseNextDataPathname` agrees
with the client's `buildPagesDataPath` for every shape, and surfaced a
latent server bug: `/_next/data/<id>/index/foo.json` was parsing back
to `/index/foo` instead of `/foo`. Fixed in `pages-data-route.ts` so
the round-trip is now lossless.

## Prefetch (shims/link.tsx + shims/router.ts)

`<Link>` prefetch and `Router.prefetch()` now prefer the data path:

- Inject `<link rel="prefetch" as="fetch" crossorigin href={dataUrl}>`
  for the JSON, matching the actual navigation's request shape.
- Invoke the loader thunk to warm the chunk in parallel. Vite's
  `import()` caches the result, so the eventual click is a double
  cache hit.

When no loader matches (dev, unmapped route), fall back to the legacy
`<link rel="prefetch" as="document">` so the browser at least preloads
the HTML.

Notably this also fixes a long-standing prefetch bug: the previous
Pages branch of `link.tsx`'s prefetch check
(`__NEXT_DATA__.__vinext?.pageModuleUrl`) was only populated by the
dev server's inline hydration script, so prefetch in production was a
no-op.

## Tests

- `tests/pages-data-url.test.ts` (new): 27 tests covering URL
  construction and the client/server round trip.
- `tests/shims.test.ts` (+8): JSON-path navigation success, 404
  hard-reload (build-skew), `x-nextjs-redirect` soft-redirect handling,
  HTML fallback when no loader matches, dynamic param extraction,
  malformed JSON hard-reload, prefetch JSON path, prefetch fallback.
- Ports from Next.js: `getDataHref` in
  `.nextjs-ref/packages/next/src/client/page-loader.ts:154`,
  `fetchNextData` in
  `.nextjs-ref/packages/next/src/shared/lib/router/router.ts:498`,
  `getAssetPathFromRoute` in
  `.nextjs-ref/packages/next/src/shared/lib/router/utils/get-asset-path-from-route.ts`.

Verified: 1393 vitest tests pass across shims, pages-router,
link/navigation, prefetch, data-route, data-url, entry-templates, and
routing.

Closes #1330 follow-up.

* fix(pages-router): drop unused export on PagesPatternMatch

Knip flagged the exported `PagesPatternMatch` type as unused. It is only
referenced as the return type of `matchPagesPattern` in the same file, so
narrow it to a local type.

* fix(pages-router): merge search params into __NEXT_DATA__.query on data-path nav

bigbonk review found that navigateClientData was synthesising
__NEXT_DATA__.query from dynamic route params only, missing the URL search
params. Code reading window.__NEXT_DATA__.query directly (a public Next.js
surface) would see incomplete data after a JSON navigation, even though
useRouter().query happened to still work because getPathnameAndQuery()
re-reads window.location.search.

Parse target.search and merge it into query alongside the route params,
with route params winning on key collision — same merge order as
getPathnameAndQuery() and Next.js itself.

Also fix a misleading comment in pages-client-entry.ts: the patterns list
is already sorted by pagesRouter()/compareRoutes (static → dynamic →
catch-all), not by the router at runtime.

* test(pages-router): e2e regression guard for JSON nav path

Adds a Playwright assertion that a Link click on a hydrated Pages Router
app on Cloudflare Workers fires a request to /_next/data/<buildId>/<page>.json
and does NOT trigger a full HTML reload. If the build pipeline ever stops
exposing __VINEXT_PAGE_LOADERS__ (or the loader map mis-keys), every other
navigation test still passes — they all assert the URL changed and the new
page rendered, neither of which fails when navigateClient silently falls
back to the HTML path. This test fails loudly in that scenario.

* test(pages-router): use hydration timestamp instead of resource-type sniffing

The original assertion 'no document-resource request to /ssr' caught false
positives: <link rel="prefetch" as="document"> can fire on initial paint
for unrelated reasons, and Chromium does not always classify prefetch hints
in a stable way. Swap to checking that __VINEXT_HYDRATED_AT survives the
click — a document reload would reset it, an in-place re-render preserves it.
Combined with the data-URL network assertion, this remains a tight guard on
the JSON nav path without the flake.

* test(pages-router): use window sentinel instead of timestamp comparison

Timestamps are timing-related signals; swap to a window-scoped sentinel
that a document reload would wipe and an in-place re-render preserves.
Same guarantee, no flake.

* fix(pages-router): handle _next/data requests in Cloudflare Workers example

The Node prod-server normalizes /_next/data/<buildId>/<page>.json before
middleware and flags it as a data request so renderPage emits the JSON
envelope. The Cloudflare worker example never did — data requests hit
matchPageRoute which can't match /_next/data/... and returned 404, so
navigateClientData saw the 404 and triggered a hard navigation. The
client-side JSON nav path was effectively dead on Workers.

Mirror the prod-server normalization in the worker: detect the data path
before middleware, validate the buildId, strip it to the page path, and
pass { isDataReq: true } through to renderPage.

Export ./server/pages-data-route from the vinext package so user-written
workers can import the helpers.

* Revert "fix(pages-router): handle _next/data requests in Cloudflare Workers example"

This reverts commit 719575b9ae.

* fix(pages-router): normalize _next/data requests inside the server entry

The Node prod-server normalized /_next/data/<buildId>/<page>.json before
middleware so renderPage could emit the JSON envelope. Cloudflare Workers
go through a user-written worker entry that doesn't (and shouldn't) know
about the data endpoint protocol, so data requests were reaching
matchPageRoute as /_next/data/... URLs, returning 404, and the client
fell back to a hard navigation — defeating the whole JSON-path
optimisation on Workers.

Move the detection into the generated server entry so it works for any
caller. runMiddleware now normalizes the URL for middleware matcher and
signals via result.rewriteUrl, and renderPage auto-detects data requests
from request.url so the JSON envelope fires whether or not middleware
rewrote the URL. Mismatched buildId short-circuits to a JSON 404 in
both paths so stale clients hard-navigate onto the new build.

* chore: re-trigger CI

* refactor(pages-router): share data-nav target resolver between Link and Router

Extract `resolvePagesDataNavigationTarget` + `prefetchPagesData` into
`shims/internal/pages-data-target.ts` so the Link prefetch path and the
Router (`navigateClient` + `Router.prefetch`) share one decision helper
instead of maintaining two structurally identical copies.

Also strip the locale prefix before matching `__VINEXT_PAGE_PATTERNS__`
so locale transitions (e.g. `/fr/about`) hit the JSON data fast path
instead of falling through to the slower HTML round-trip — route
patterns are locale-unaware (`/about`), but the browser URL for a
locale-prefixed page includes the locale. The data URL itself keeps
the locale prefix because the server uses it to pick locale-specific
gSSP data.

When the data path is used and the URL implies a different locale than
the current `__NEXT_DATA__.locale`, refresh the locale fields on the
synthesised `__NEXT_DATA__` (the JSON envelope itself only carries
`pageProps`, so the locale must be derived from the URL). Without this,
`useRouter().locale` stays stale after a client-side locale transition.

* chore: re-trigger CI
2026-05-22 15:46:38 +01:00
Nathan Nguyen b6fc03cfc3 fix(router): preserve default locale for unprefixed root links (#1442)
* fix(router): preserve default locale for unprefixed root links

Unprefixed Pages Router URLs represent the default locale, even when the browser's preferred locale has previously been detected as a non-default locale. Treating the ambient runtime locale as authoritative makes a Link from /new to / request /id and returns the wrong page locale.

Derive implicit client navigation locale from the current URL first, then domain/default locale state, and render a locale-qualified default root fallback href so non-intercepted root links do not re-enter preferred-locale detection. The regression tests port the relevant Next.js i18n preferred-locale cases at the Link and router boundaries.

* refactor(router): share client locale inference

The review called out that Link and Router had identical browser locale fallback logic. Keeping that chain duplicated makes future i18n changes easy to apply in one place but miss in the other.

Move the URL-first browser locale lookup into a shared shim helper and add SSR coverage for the intentional default-locale root fallback href.
2026-05-22 13:58:42 +01:00
James Anderson 46ab691094 fix(i18n): normalise default-locale paths before route matching (#1409)
Mirrors Next.js's server-side default-locale path normalisation in
`packages/next/src/server/lib/router-utils/resolve-routes.ts` (lines
~250-263): every request that arrives without a locale prefix is
spliced with `/${defaultLocale}` before any `next.config.js`
redirect / rewrite / header rule (or filesystem route) match runs.

Without this, rules like `source: '/:locale/to-sv'` with
`locale: false` failed to match requests for `/to-sv` because there
was no segment for the `:locale` placeholder. After normalisation the
matcher sees `/en/to-sv` and the rule matches with `:locale=en`.

Per the merged PR #1382's description, the issue text's call for a 308
redirect from `/en/page` to `/page` is incorrect: Next.js serves
the same content under both URLs and normalises the path internally.
This change implements that internal normalisation; no redirect.

The new helper lives next to the existing pages i18n helpers so both
routers can use it. Wired into:

  - `prod-server.ts` (Pages Router prod)
  - `app-rsc-handler.ts` (App Router dev + prod)
  - `index.ts` (Pages Router dev plugin)
  - `deploy.ts` (Cloudflare Workers entry template)

The domain-mapped default locale (`i18n.domains[].defaultLocale`) is
preferred over the global default when the inbound host matches,
matching Next.js's `domainLocale.defaultLocale` branch in
resolve-routes.ts. `/_next/*` and `/__vinext/*` paths are excluded
to mirror Next.js's exception for build assets and prerender manifests.

The fallback "retain the original unprefixed source" branch in
`applyLocaleToRoutes` is intentionally kept as belt-and-braces: the
new normalisation pass renders it dead-code for ordinary requests, but
it harmlessly preserves backwards compatibility with any matcher
caller that still passes raw, unnormalised pathnames.

Refs #1336 (item 4)
2026-05-22 13:55:03 +01:00
Nathan Nguyen 8df248863d fix: match Next metadata image route behavior (#1436) 2026-05-22 11:05:58 +00:00
James Anderson 512be7f25c fix(middleware): preserve route priority on rewrite target resolution (#1394)
* fix(middleware): preserve route priority on rewrite target resolution

When `trailingSlash: true` is set in next.config.js, a request like
`/rewrite-1/` was matched against the next.config.js rewrite source
`/rewrite-1` literally, including the trailing slash. The match failed,
so the request fell through to the dynamic `[id]` route and rendered
the dynamic page with `id="rewrite-1"` instead of being rewritten to
the static target.

Strip a single trailing slash from the incoming pathname before
matching against config rewrite/redirect/header sources, matching
Next.js's `source + '(/)?'` behavior under `trailingSlash: true`. The
source pattern itself is preserved so catch-all (`:param*`, `:param+`)
and root (`/`) semantics are unaffected.

Refs #1331

* address bonk feedback: locale-static path, comment wording, tighter tests

- Extract `stripTrailingSlashForConfigMatch` and call it from
  `matchRedirect` so the locale-static fast path (Map.get on the
  pathname) also honors trailing slashes — previously a pure
  locale-static redirect rule could miss when `trailingSlash: true`.
- Clarify the comment to reflect that the strip is unconditional
  defense-in-depth rather than a faithful port of Next.js's
  conditional `(/)?` suffix.
- Disambiguate the pages-router integration tests by asserting on
  `id="home"` / `id="ssr"` so the assertion can't pass on the wrong
  page (both pages render "Hello World").
- Add direct unit tests for `matchConfigPattern` trailing-slash
  behavior covering exact, :param, regex-group, root, and catch-all
  cases.

* extend trailing-slash normalization to matchHeaders

Bonk flagged that matchHeaders runs its own compiled regex path that
doesn't go through matchConfigPattern, so a header source like
`/api/:path*` would miss `/api/foo/` under `trailingSlash: true`.

Normalize once at the top of matchHeaders the same way matchRedirect
already does, and add a regression test.
2026-05-21 22:15:23 +01:00
James Anderson f0f3e35947 fix(trailing-slash): enforce config in routing and middleware redirects (#1395)
Plumb `trailingSlash` from the resolved next.config into NextURL via
NextRequest, so `NextResponse.redirect(request.nextUrl)` (and any other
stringification of a NextURL) emits a Location header that matches the
user's slash policy. Mirrors Next.js's `formatNextPathnameInfo`.

Pre-route trailing-slash normalization (e.g. `/foo` -> `/foo/` when
trailingSlash:true) already runs at every entry point via
`normalizeTrailingSlash` in request-pipeline. This commit closes the
remaining gap by making redirect Locations produced from inside
middleware (the common one-hop `request.nextUrl` redirect pattern in
the Next.js middleware-trailing-slash fixture) carry the slash too.

Refs #1332. Sub-problem 3 (shallow navigation triggering a server
roundtrip) is a separate Pages Router client-side concern and deferred
as follow-up.
2026-05-21 20:47:33 +00:00
James Anderson 818093b7d9 fix(i18n): honor locale: false on rewrites/redirects + default-locale redirect (#1382)
* fix(i18n): honor locale: false on rewrites/redirects

When a rewrite or redirect carries `locale: false`, Next.js leaves the
source pattern untouched so user-supplied `:locale` segments can capture
the locale prefix themselves. Without `locale: false`, Next.js prepends
an internal locale alternation so the rule matches every locale-prefixed
URL automatically.

Vinext previously ignored the flag entirely and never injected the
locale alternation, so:
  - rewrites with `locale: false` on a source like
    `/:locale/rewrite-files/:path*` failed because the underlying
    matcher could not handle a non-trailing named param combined with a
    trailing catch-all;
  - plain rewrites/redirects with i18n configured silently failed to
    match locale-prefixed paths.

This change:
  1. Adds `locale?: false` (and `basePath?: false` for forward
     compatibility) to NextRedirect/NextRewrite.
  2. Fixes matchConfigPattern to use the regex branch whenever a pattern
     has multiple named params and a catch-all.
  3. Adds applyLocaleToRoutes mirroring Next.js's processRoutes — emits
     `/:nextInternalLocale(en|sv|nl)/...` and (for redirects) per
     default-locale literal variants while skipping rules flagged with
     `locale: false`. Retains the original unprefixed source as a
     fallback so default-locale requests that arrive without a prefix
     still match (Next.js handles this upstream via request path
     normalisation; doing it here avoids a server-wide change).

Ported tests from
  test/e2e/i18n-ignore-rewrite-source-locale/rewrites.test.ts

Refs #1336 — items 2, 3, 4 remain as follow-ups.

* fix(i18n): address Bonk review feedback

- Keep the locale-capture variants emitted by applyLocaleToRoutes on the
  redirect index fast path. `_LOCALE_STATIC_RE` now matches both the
  optional `/:locale(en|fr)?/foo` user-written form and the mandatory
  `/:nextInternalLocale(en|fr)/foo` form. Each entry records whether the
  locale segment is optional; the no-locale-prefix branch only fires for
  optional entries so mandatory variants still require a prefix.
- Thread `trailingSlash` through `applyLocaleToRoutes` so the root source
  is preserved (`/:nextInternalLocale(en|fr)/`) when trailingSlash is
  configured, matching Next.js processRoutes.
- Promote the dynamic import in next-config.ts to a static import — the
  module is already loaded for isExternalUrl.
- Drop the unnecessary `new Set([...])` dedup and document that the
  defaultLocales list will grow once domain-locale support lands.
2026-05-21 11:14:22 +00:00
James Anderson 517c2bde19 feat(pages-router): expose window.next.router on hydration (#1400)
* feat(pages-router): expose window.next.router on hydration

Promote next/router from a lazy dynamic import inside hydrate() to a
static side-effect import at the top of the generated client entry, so
window.next = { version, router, ... } is published before any test
harness or third-party script reads it.

Without this, the Next.js deploy test suite — which drives client-side
navigations via `browser.eval('next.router.push(...)')` — crashes with
`TypeError: Cannot read properties of undefined (reading 'router')` (~80
test failures across middleware-general, middleware-rewrites,
trailing-slashes, basepath, with-router, and ignore-invalid-popstateevent).

Mirrors Next.js's bootstrap order in
.nextjs-ref/packages/next/src/client/next.ts (line 5–13), which
statically imports the router from './' and sets window.next as a
top-level side effect before initialize/hydrate.

Closes #1329

* feat(router): expose state fields as live getters on Router singleton

window.next.router must expose `pathname`, `route`, `query`, `asPath`,
`basePath`, `locale`, `locales`, `defaultLocale`, `domainLocales`,
`isReady`, `isPreview`, and `isFallback` as live properties — not just
the navigation methods. The Next.js deploy test suite drives navigations
via `browser.eval('next.router.push(...)')` and then asserts on
`browser.eval('next.router.pathname')` to verify the navigation
completed; without the field getters the assertion reads undefined.

Mirrors Next.js's `singletonRouter` shape in
.nextjs-ref/packages/next/src/client/router.ts (lines 32-47), which
uses Object.defineProperty for urlPropertyFields so reads forward to
the active router state.

Strengthens the existing shims.test.ts coverage so future regressions
of the field surface fail at unit-test time.
2026-05-21 10:26:23 +00:00
James Anderson 7771099d1d fix(pages-router): guard navigation shim against SSR (#1378)
`performNavigation()` and the `Router` singleton's `back`/`reload`/
`prefetch`/`beforePopState` methods referenced browser globals
(`window`, `document`) unconditionally. Invoking `router.push()` from a
Pages Router component during SSR or prerender (`VINEXT_PRERENDER=1`)
therefore crashed with `ReferenceError: window is not defined` and took
down the request pipeline.

Mirror Next.js's `ServerRouter` (packages/next/src/server/render.tsx),
where every navigation method synchronously throws "No router instance
found". The throw happens before any browser global is accessed, so the
failure surfaces as a normal render error instead of a ReferenceError —
and the documented message + docs link keeps userland error handling
intact.

Closes #1353
2026-05-21 10:31:18 +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 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 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
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
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
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
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 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 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
Nathan Nguyen 4988436b6c fix(app-router): preserve history metadata for external state updates (#1259)
External history.pushState and history.replaceState currently store caller data as-is. That drops vinext's App Router previousNextUrl marker when apps perform shallow URL updates inside an intercepted route, so later traversal can lose the interception source.

The History API wrappers now copy the current App Router metadata onto caller-provided state before delegating to the browser history method. A focused shim regression covers object, null, and undefined caller state against the same contract Next.js preserves.
2026-05-16 18:57:11 +01:00
Nathan Nguyen 6663dd10d8 fix(router): decode hash scroll targets (#1255)
* fix(router): decode hash scroll targets

Hash-only client navigation looked up raw URI fragments directly. That meant ids such as "hello world" and browser-compatible named anchors were skipped when links used encoded fragments or legacy anchors.

The router shims now share the decoded id, named-anchor fallback, and top-fragment behavior already used by the app browser popstate path. Regression coverage exercises hash-only Pages Router pushes for decoded ids and name fallback.

* fix(router): respect scroll false for hash navigation

Match Next.js Pages Router behavior by gating hash target scrolling behind the scroll option for both hash-only and hash-bearing navigations.

Add regression coverage for hash-only Router.push with scroll disabled.
2026-05-16 18:03:35 +01:00