* fix(pages-router): load custom _app/_document via resolved file paths in dev
The dev server imported pages/_app and pages/_document by extensionless id
and relied on the module runner applying custom resolve.extensions (e.g.
".page.tsx" from pageExtensions). The runner no longer resolves those, so
apps using pageExtensions silently lost their custom App/Document (the load
failure is swallowed). Import the file path resolved by findFileWithExts
instead, at all three call sites.
* test(e2e): add pages-router-complex example app and behaviour suite
A deliberately convoluted Pages Router app that serves as a compatibility
target: the kinds of patterns that only surface in large, long-lived
enterprise pages-router codebases, each pinned by a Playwright spec whose
behaviour is verified against real Next.js (73/73 under next dev --webpack).
Highlights: custom pageExtensions everywhere (middleware.page.ts,
instrumentation.page.ts), app-shell getInitialProps with a TTL-memoised
chrome fetch and embedded-shell/draft bypasses, class _document with its own
GIP, a zone route dimension driven by middleware rewrites and a real
i18next/react-i18next runtime, catch-all routes with static-sibling
precedence and cacheable-404/redirect hygiene, record-driven template
branching on a dynamic/static/dynamic route, an urql-style server-snapshot
data layer, shallow routing + router.events + next/compat/router +
next/navigation hooks in pages, next/image with a custom loader, draft-mode
gateway APIs, a function-form next.config, and a Cloudflare setup matching
the vinext init scaffold.
The playwright project (pages-router-complex, port 4199) is intentionally
not in the CI e2e matrix: vinext currently passes 59/73, and the failures
are the compat backlog documented in the example README.
* fix(pages-router): cover compound page extensions in CI
* feat(cache): implement Next.js 16 revalidateTag two-phase stale/expired model
- Add TagRevalidationDurations interface and update CacheHandler interface
- Rewrite MemoryCacheHandler to use stale/expired TagManifestEntry model
- Rewrite KVCacheHandler with KVTagEntry JSON format (backward-compat with legacy plain-timestamp)
- Add deprecation warning to public revalidateTag() when called without profile
- SWR semantics when profile with expire>0: mark stale immediately, hard-expire after window
- Hard invalidation when no profile or expire=0: set expired=now, next get() is a miss
- Fix >= comparisons for same-millisecond set()+revalidateTag() correctness
- Add tests for deprecation warning, SWR stale return, expire=0 hard miss, JSON KV format
* refactor(cache): address bonk review comments
- Document >= vs > as deliberate divergence from Next.js with rationale
(same-millisecond set+revalidateTag must invalidate; strict > would allow
stale serves when both events share a timestamp)
- Export TagRevalidationDurations interface from next-shims.d.ts and use it
in CacheHandler, MemoryCacheHandler, and revalidateTag signatures
- Remove dead ...existing spread in MemoryCacheHandler.revalidateTag — both
branches fully overwrite the TagManifestEntry, the spread could accidentally
preserve stale fields if the interface grows
- Tighten deprecation warning test regex to match the exact emitted message
* fix(cache): match Next.js updateTags branch logic for durations without expire
When revalidateTag is called with a truthy durations object, stale is now
always written to the tag manifest regardless of whether expire is set.
The expired field is only set when durations.expire !== undefined.
This fixes two divergences from Next.js default.ts updateTags:
- revalidateTag('tag', {}) → { stale: now } (stale-only SWR), was { expired: now }
- revalidateTag('tag', { expire: 0 }) → { stale: now, expired: now }, was { expired: now }
Applies the same fix to both MemoryCacheHandler and KVCacheHandler.
Adds tests covering the new stale-only ({}) and expire=0 shapes.
* fix(cache): reuse now variable in MemoryCacheHandler.get for time-based expiry check
* fix(cache): preserve prerendered page cache tags
* fix(metadata): add meta's 2024 crawler UAs to the html-limited bot list
Meta introduced meta-externalagent and meta-externalfetcher in 2024. The
default html-limited bot list (mirrored from Next.js) only carries the
legacy facebookexternalhit, so the new UAs get streamed metadata. Meta's
crawlers don't execute JS, so every og:* tag lands after </head> and
link previews on WhatsApp/Instagram/Facebook render blank.
Deliberate, documented divergence from Next.js's default list (which
predates Meta's UA migration) per AGENTS.md. A user-provided
htmlLimitedBots config still replaces the whole list.
Test: new UAs get blocking metadata, the legacy UA stays covered, and a
custom htmlLimitedBots config still replaces the default.
* refactor(metadata): share html-limited bot detection
---------
Co-authored-by: MrIago <28714061+MrIago@users.noreply.github.com>
Co-authored-by: James <james@eli.cx>
* fix(app): log RSC render errors on the dev-server terminal
`reportRequestError` is a no-op when no `onRequestError` instrumentation
hook is registered, so a server-component render error was swallowed
silently in the dev server. Next.js's instrumentation wrapper logs render
errors in development regardless of user instrumentation; mirror that by
emitting a `console.error` from the RSC onError handler in dev.
The `!hasDigest` guard dedupes: the same error object reaches the handler
on both the RSC and SSR/HTML render passes, and the first pass stamps it
with a digest — matching Next.js's `silenceLog` dedup so it logs once.
* fix(app): ignore expected RSC render cancellations
* docs(app): clarify digest log suppression
* perf(build): cache repeated compatibility transforms
App Router builds run pure dynamic-request and typeof window transforms repeatedly across RSC, SSR, and analysis passes. Re-parsing identical module input adds build work without changing output.
The transform hooks now reuse results for exact module id, source, and environment replacement keys while replacing stale per-id source entries. Focused tests cover cache reuse and key separation.
* perf(build): reuse pure compatibility transforms
Repeated build environments can parse and rewrite the same module source more than once. The transform result is deterministic once the module id, source, and environment-derived variant are fixed.
Share the existing bounded per-module cache across the compatible transform plugins and cover source, module, variant, and null-result boundaries.
* fix(build): invalidate cached source identities after junction retargets
Repeated transforms can keep emitting import.meta.url and CJS globals for a junction's previous canonical target when the raw ID and source stay unchanged.
The import-meta-url cache variant omitted the canonical path even though the rewrite derives its output from that path. Include canonicalId in the variant and cover retargeting between identical source files.
* ci: rerun performance benchmarks
* perf(build): avoid composite import-meta cache keys
Eligible import-meta transforms allocate and hash a composite string containing the canonical root and module path on every invocation, including cache hits. That adds deterministic work to dev cold start.
Keep source, canonical root, and canonical id as direct equality fields in a per-module entry. Retain only the two-value environment result map so canonical-path invalidation remains correct without composite key allocation.
---------
Co-authored-by: James <james@eli.cx>
* fix(router): replace stale optimistic layouts across dynamic params
A detached optimistic shell can commit stale dynamic-layout output before the authoritative payload resolves. Preparing the latter from live router state then makes the shell appear current, so stale server props and BFCache identity survive a cross-param navigation.
All payloads in one navigation must derive reuse identity from the same initiation state. Capture that state once and pass it through commit preparation, while live router state remains the authority for cancellation and commit approval.
Add composition coverage for cross-param replacement, same-param preservation, and layout-owned slots, plus a deterministic browser regression for the prefetched-shell handoff.
* fix(router): require navigation initiation state for payload preparation
Navigation payload callers could omit the initiation state and silently prepare from live router state. A future caller could therefore compile while reintroducing the optimistic-to-authoritative identity bug.
Require the state at both browser-entry and controller boundaries and remove the live-state fallback. Isolated controller tests now choose current-state preparation through an explicitly named test helper.
* test(router): synchronize payload tests on state dispatch
Navigation payload regressions advanced a fixed number of microtasks before reading router state. That coupled the tests to the controller's current async scheduling depth.\n\nExpose a one-shot visible-commit dispatch waiter from the controller harness and await that explicit boundary before assertions.
* docs(router): document the currentState baseline in createPendingNavigationCommit
createPendingNavigationCommit's currentState param has no note on what
it should be. Navigation callers now pass the frozen navigation-initiation
state (per the previous two commits), while the HMR caller still passes
live state, and nothing marks that split as deliberate.
A future navigation call site that passes live state instead of the
initiation state would silently reintroduce the stale cross-param reuse
bug this branch fixes, with no type error to catch it.
Document the invariant on the field so the split reads as intentional.
---------
Co-authored-by: James <james@eli.cx>
* fix(cloudflare): report custom-domain deploy URLs
Cloudflare deploys that disable workers.dev currently finish with "(URL not detected in wrangler output)" even when Wrangler confirms a custom-domain target. Wrangler emits custom domains as bare hostnames, while vinext only parses HTTPS workers.dev URLs.
Parse Wrangler custom-domain target markers, validate the hostname as an HTTPS origin, and ignore wildcard routes and disabled domains. Preserve workers.dev URL precedence and cover the deploy boundary plus Wrangler 4.110 output variants.
* ci: retry flaky unit test shard
The upstream unit-test shard failed while removing a temporary typegen directory after all test assertions passed. The affected test is unrelated to this branch and passed 20 consecutive targeted runs locally.\n\nTrigger a fresh GitHub Actions run without changing the pull request diff.
---------
Co-authored-by: James <james@eli.cx>
* perf(build): split react-dom/server into its own client chunk
createClientManualChunks keyed the always-loaded "framework" chunk on the bare
package name ("react-dom"), so react-dom/server.browser + its cjs implementation
rode along on every page even though only client code that renders to a string
(e.g. an embedded Sanity Studio) imports it. Route the server/static renderer
entrypoints to a dedicated "react-dom-server" chunk so framework stays ~35KB
brotli lighter on every page; the server renderer loads only where used.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(build): normalize react-dom server chunk ids for Windows
* test(build): cover react-dom server chunk entrypoints
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
* fix(metadata): pass parent to cached resolvers with default or rest parameters
* fix(metadata): preserve parent for opaque cache exports
---------
Co-authored-by: James <james@eli.cx>
* fix(init): complete an existing Cloudflare config instead of only adding to it
`vinext init --platform=cloudflare` generates a correct wrangler.jsonc when
there isn't one, but when a wrangler.jsonc already exists it only merged
`cache`, `images` and `kv_namespaces` into it. `main` and `assets` were never
added and never warned about, so the two paths disagreed about what a valid
Cloudflare config is and only one of them was checked.
Without `main`, @cloudflare/vite-plugin builds the project as assets-only: the
RSC environment emits no dist/server/wrangler.json, the `start:vinext` and
`deploy:vinext` scripts init just wrote point at a file that no longer exists,
and the deploy falls back to the assets-only config. It reports success and
ships a Worker with no SSR entry, so static assets return 200 and every route
returns 404 with no index.html to fall back on. Nothing in the output points
at `main`.
The vite config had the same shape of bug: `ensurePlugins` only adds plugins
that are absent, so an existing bare `cloudflare()` kept its defaults and an
App Router project silently lost
`viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] }`.
- `updateWranglerConfigForCloudflare` now fills in `main` and `assets` when
absent, reusing the worker-entry resolution `generateWranglerConfig` already
used, so both paths produce the same config. Existing values are left alone
and the update stays idempotent.
- `ensureCloudflareViteEnvironment` adds `viteEnvironment` to an existing
`cloudflare()` call for App Router projects, whether it is called bare or
with other options.
Reproduced on 1.0.0-beta.2 with a generator that emits an app and its
wrangler.jsonc together, then runs `vinext init --platform=cloudflare`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(init): validate existing Cloudflare config
---------
Co-authored-by: piffie <1213363+piffie@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
* fix(fonts): don't append ?dpl= to font preload hrefs
The @font-face src URLs (inline style block and built CSS) are emitted
bare, and a preload only matches a font request when the URLs are
byte-identical. Appending the deployment-id query to the preload hrefs
made every font preload a wasted download and re-fetched each font a
second time once the CSS parsed — measurably late, inside the LCP
window. Font files are content-hashed, so the query added no
cache-busting value.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(fonts): drop ?dpl= from Pages Router font preloads
The Pages Router had the same bug fixed for the App Router in the
previous commit, in two places: buildPagesFontHeadHtml wrapped the HTML
<link rel="preload"> hrefs in appendAssetDeploymentIdQuery, and the
pages handler's HTTP Link: header did the same — while the @font-face
src URLs in the <style data-vinext-fonts> block are emitted bare. A
preload only matches a font request when the URLs are byte-identical,
so every Pages font preload was a wasted download. Fonts are
content-hashed, so ?dpl= added no cache-busting value.
Adds a Pages Router production test (new pages fixture with
next/font/google) pinning a deploymentId and asserting both the HTML
preload hrefs and the Link: header URLs are query-free and
byte-identical to the @font-face src URLs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(fonts): preserve deployment IDs across font URLs
* fix(fonts): keep immutable font assets query-free
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
* fix(font-google): share SSR collection state via globalThis
In Vite's multi-environment dev mode the font-google-base shim can be
loaded more than once in the same worker (e.g., resolved through
different IDs across the rsc/ssr environments), so each module copy
ends up with its own freshly-initialized closure variables. The
fontLoader call site and the SSR getSSRFontStyles() reader landed on
different copies — the reader returned an empty array even though the
loader's pushes succeeded — and the collected <style data-vinext-fonts>
block never made it into the HTML head.
Back every piece of mutable state (counters, injection-tracking Sets,
SSR collection arrays) with a Symbol.for slot on globalThis, the same
pattern navigation.ts already uses for its layout segment context.
This collapses every module copy onto a single shared store so writes
from one copy are visible to reads from another.
Production builds were unaffected because the build plugin inlines
_selfHostedCSS into each font-loader call, so the runtime cycles
through different code paths that don't depend on cross-copy state
visibility.
* fix(font-local): share SSR collection state via globalThis
The font-local shim had the same multi-copy bug as font-google-base:
Vite's multi-environment dev mode can load the shim more than once in
the same worker, so each module copy got its own classCounter,
injection-tracking Sets, and SSR collection arrays. The localFont()
call site and the SSR getSSRFontStyles()/getSSRFontPreloads() readers
could land on different copies, and two copies both minting
__font_local_0 would collide.
Back every mutable binding with a Symbol.for slot on globalThis
(namespaced vinext.fontLocal.*). The counter is reassigned rather than
mutated in place, so it is boxed in a shared { value } object instead
of a bare alias.
Also tighten the globalThis augmentation in both font shims from a
loose [k: symbol]: unknown index signature to the named-key style used
by navigation.ts, removing the per-site casts.
Adds a regression test that creates a genuinely fresh second module
copy via vi.resetModules() and asserts SSR style/preload visibility
and class counter continuity across copies.
* fix(font-local): stabilize class identities across environments
---------
Co-authored-by: James <james@eli.cx>
* fix(server): defer after callbacks until response close
* fix(server): drain pages after callbacks on response close
* chore(ci): rerun flaky typegen cleanup
* fix(server): await nested after callbacks
* fix(server): preserve after lifecycle parity
---------
Co-authored-by: James <james@eli.cx>
* fix(check): exclude test-runner files from app compatibility scans
vinext check scans test modules and test-runner configuration as though they are bundled into the migrated application. Apps using CommonJS globals only in Vitest files therefore receive unsupported migration issues even when the vinext build succeeds.
Separate the application compatibility candidate set from the general recursive file finder. Exclude test and spec modules plus conventional Jest, Playwright, and Vitest config files, while keeping ordinary runtime config modules visible to import and convention checks.
* ci: rerun performance benchmarks
---------
Co-authored-by: James Anderson <james@eli.cx>
* fix(app-router): stream generated metadata after the document shell
Dynamic App Router document renders currently await generateMetadata before constructing the page element tree. This delays response headers and the first HTML chunk for streaming-capable clients.
Head resolution coupled metadata and viewport into one awaited result, so the renderer could not suspend only metadata. Start the branches independently, keep blocking callers unchanged, and expose metadata through paired Suspense tag and error outlets. The hidden host wrapper preserves React's shell flush in the presence of hoistable metadata tags.
The focused head test verifies metadata remains pending while viewport resolution completes.
* test(app-router): verify generated metadata follows the production shell
Production coverage only established that HTML eventually arrived, so delaying the first byte until generateMetadata completed remained undetected.
Read the production response incrementally and require the page shell to precede delayed metadata while still asserting the final tags.
* test(app-router): align metadata error coverage with streamed responses
Streaming generateMetadata errors return a recoverable 200 shell before local or global boundaries render after hydration. The compatibility suite incorrectly expected those boundaries and 500 statuses in raw server HTML, which conflicts with Next.js 16.2.7 and fails the integration shard.
Update raw-response assertions to cover shell behavior and add browser coverage for page and layout metadata errors with and without local boundaries.
* fix(app-router): stream metadata during navigation
* fix(app-router): isolate connection probes from streaming metadata
Dynamic metadata prefetches can leave Flight responses open indefinitely when generateMetadata calls connection(). Streaming starts metadata in parallel with page classification, but the speculative probe mutated shared request state and captured the sibling metadata branch.
Run probes in a nested request scope, then propagate dynamic usage and new diagnostic errors back with concurrency-safe rules. This preserves classification while allowing sibling metadata work to complete.
* refactor(app-router): isolate fallback metadata planning
Streamed metadata fallbacks previously configured the general head resolver with traversal flags for boundary repetition, leaf ordering, and viewport suppression. That made fallback policy part of normal metadata resolution and obscured the RSC navigation transport contract.
Build an explicit HTTP-access fallback metadata source plan, then resolve it through the shared ordered metadata merger. Add focused planner coverage and pin delayed navigation redirects to HTTP 200 Flight digest transport.
* fix(app-router): preserve not-found metadata search params
Page-local not-found metadata lost searchParams after deferred metadata called notFound(), so query-derived tags were wrong and search access was invisible to dynamic-usage tracking. Terminal fallback rendering also recomputed the boundary head without the normalized query.
Classify not-found ownership from module identity and tree position, attach query state and its observer only for page-owned conventions, and thread normalized search params through terminal fallback rendering. Cover repeated fallback leaves, observer access, and a production page-local not-found route.
* fix(app-router): release completed connection probes
Async work created inside a speculative connection probe retained the child request store after the probe returned. Because that store still referenced the completed probe, a later connection() call suspended forever.
Restore the child store's currently inherited probe during deterministic cleanup. This preserves nested probe ownership while allowing late continuations to observe the completed scope, with a real AsyncLocalStorage regression covering the lifecycle.
* fix(app-router): preserve deferred metadata cache signals
Deferred metadata dynamic usage can overlap a speculative layout probe. The probe cleared and later consumed the shared request flag, allowing an RSC cache entry to be written even though the completed response was marked no-store.
The layout classifier treated save-and-restore mutation as async isolation. Run probe classification in a child dynamic-usage scope so sibling metadata retains the parent request state, and cover the overlap through the dispatch cache boundary.
* fix(app-router): preserve fallback metadata parity
---------
Co-authored-by: James <james@eli.cx>
* fix(cache): guard 'use cache' KV key against Cloudflare's 512-byte limit
A long dynamic-route slug flows into the 'use cache' KV key via the
serialized args. When the assembled key exceeded Cloudflare KV's 512-byte
key limit, handler.get threw a 414 *before* the wrapped render reached
notFound()/redirect(), masking those control-flow signals and surfacing a
catch-all not-found as a 200 error boundary instead of a 404 (soft-404).
- buildUseCacheKey now hashes the oversized parts (fnv1a64), mirroring the
ISR cache's guard in isr-cache.ts (buildCacheKey); the readable
function-scoped prefix is preserved when it fits so distinct cached
functions never collide.
- handler.get is wrapped so any cache-store failure falls through to fresh
execution, letting the function's own thrown digest propagate.
Regression tests added in tests/shims.test.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cache): enforce Cloudflare KV key limits
* refactor(cache): keep key hashing in KV adapter
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>