Commit Graph

110 Commits

Author SHA1 Message Date
James Anderson 25a33cefe9 fix(test): lock hybrid i18n fixture workspace (#2838) 2026-08-07 21:25:47 +00:00
Jerry Zhao 9ae681fc28 fix(deps): pin vitest and coverage-istanbul to 4.1.10 (#2748)
* fix(deps): pin vitest and coverage-istanbul to 4.1.10

* fix(deps): align lockfile vitest specifier with its override
2026-07-29 16:13:48 +01:00
Jerry Zhao a0e5d1be86 chore(deps): upgrade vite-plus to 0.2.6 (#2694)
* chore(deps): upgrade vite-plus to 0.2.6

* fix(app-router): suppress benign AbortError from superseded navigations

A fast follow-up navigation aborts the in-flight navigation's RSC fetch
mid-stream. When the aborted stream still holds an un-consumed React Flight
chunk (e.g. streamed metadata the superseded route never rendered), React
reports the resulting AbortError globally as a window `error` event rather
than to a specific consumer, which trips the "no console errors" e2e
assertion (metadata-icons.spec.ts).

Install a page-lifetime window listener at bootstrap that preventDefault()s
these benign navigation AbortErrors, mirroring the existing redirect-error
bridge. Installed once (not in a component effect) so there is no listener
gap while the router tree re-renders mid-navigation.

The vite-plus 0.2.6 toolchain shifted navigation timing enough to expose
this latent race on CI.

* chore: adapt to vite-plus 0.2.6 toolchain

Follow-up to the vite-plus 0.2.6 bump; keeps CI green under the new
oxfmt/oxlint/rolldown toolchain. No published runtime change.

- Formatting: oxfmt 0.60.0 reformats README.md, apps/web/next.config.ts and
  tests/nextjs-compat/TRACKING.md (collapses empty-object-with-comment;
  unpads markdown tables). Applied `vp check --fix`.
- Lint: oxlint 1.75.0 now flags dynamic `import("node:path")` under the
  existing no-restricted-imports rule. Disabled inline in loadStaticPrerender
  with a reason -- the resolved path feeds a dynamic import(), so pathslash's
  forward-slash canonicalization buys nothing there.
- Test: rolldown code-splits the server build, so the font markers moved out
  of index.js into _next/static/* chunks. Scan the whole server output for
  the markers instead of index.js alone (verified they still exist).

* Revert "fix(app-router): suppress benign AbortError from superseded navigations"

Drop the runtime AbortError suppressor to keep this PR a pure toolchain bump.
The metadata-icons "rapid icon replacement" failure it addressed is a
pre-existing, timing-dependent race (not caused by the upgrade). Assessing
whether it triggers stably on CI / locally before deciding how and where to
fix it.

This reverts commit 84454d5fd of this branch.
2026-07-28 23:24:18 +01:00
James Anderson 1c69ce3fef fix(pages-router): load custom _app/_document via resolved file paths in dev (#2692)
* 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
2026-07-24 15:35:16 +01:00
James Anderson ce00c8dbf6 fix: sync middleware matcher fixture lockfile (#2658) 2026-07-20 23:28:30 +01:00
James Anderson 71dbb45ca8 fix(pages): isolate on-demand revalidation requests (#2495)
* fix(pages): pin revalidate loopback origin

* test(pages): cover production revalidation origin

* fix(pages): preserve internal revalidation boundaries

* fix(pages): preserve on-demand revalidation boundaries

* fix(pages): dispatch Worker revalidation internally

* fix(pages): authenticate revalidation request context

* fix(pages): isolate revalidation transport headers

* test(pages): account for generated route table size

* fix(pages): align on-demand revalidation semantics

* fix(pages): align revalidation response cache parity

* fix(pages): preserve regenerated ISR representations

* fix(pages): align ISR cache representations

* fix(pages): preserve canonical ISR representations

* fix(pages): align cached response parity

* fix(pages): preserve custom App render props

* fix(pages): preserve optional App page props

* fix(pages): preserve optional App props on the client

* fix(pages): normalize client App page props

* fix(pages): preserve App data merge semantics

* fix(pages): keep redirect status helper internal

* fix(pages): match Next.js terminal ISR behavior

* fix(pages): preserve custom app error envelopes

* fix(pages): match Next.js dev revalidation semantics

* test(pages): align dev revalidation parity coverage

* chore(pages): remove stale cache helper exports

* fix(cache): preserve explicit no-store context
2026-07-20 18:45:06 +01:00
James Anderson 0b6faab89d fix(cache): isolate draft route responses (#2591)
* fix(cache): isolate draft route handler responses

* fix(cache): enforce draft policy on route errors

* fix(cache): privatize draft mode transitions

* fix(cache): respect live draft transitions

* fix(cache): discard route cookies on failed handlers

* Reviewed PR #2591: solid fix, no blocks

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

* test(cloudflare): use public CDN adapter export

* fix(ci): align Cloudflare fixture lock entry

---------

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-07-15 14:06:00 +01:00
James Anderson 8e55df80ec fix(ci): keep Vite+ installs from dirtying the lockfile (#2631) 2026-07-15 09:57:36 +01:00
James Anderson b859a03bbd feat(types): ship Next-compatible types without Next.js (#2612)
* feat(types): add vendored Next.js declarations

* feat(types): expose fallback types through vinext

* fix(types): support readonly Node environment declarations

* fix(ci): install vendored types before vinext tarballs

* test(types): avoid brittle packed consumer pins

* fix(ci): configure pnpm 11 local type overrides

* test(types): avoid registry metadata in packed install

* test(types): isolate packed declaration install

* feat(init): add standard script aliases for new apps

* fix(create-vinext-app): run typegen portably

* fix(typegen): report next env updates

* fix(create-vinext-app): use standard scripts only

* refactor(create-vinext-app): use exhaustive package manager return

* fix(create-vinext-app): fall back for unknown package managers
2026-07-14 13:27:20 +01:00
James Anderson 132d351ed7 chore(deps): upgrade TypeScript to 7.0 (#2577)
* chore(deps): upgrade TypeScript to 7.0

* fix(build): resolve native TypeScript executable
2026-07-09 22:57:23 +01:00
Nathan Nguyen aa1b6663b0 fix(app-router): handle redirects in route-miss fallbacks (#2553)
* fix(app-router): handle redirects in route-miss fallbacks

* test(app-router): fix frozen-lockfile break in not-found-redirect fixture

The route-miss redirect fixture shipped a package.json declaring six
"latest" dependencies (@vitejs/plugin-react, typescript, @types/react*,
vinext, vite) with no matching pnpm-lock.yaml importer. CI runs
frozen-lockfile installs, so every job died at the shared setup step with
ERR_PNPM_OUTDATED_LOCKFILE, turning the whole suite red before a single
test ran.

The fixture never needs those dependencies: dev tests load it via
createServer({ configFile: false }) with the plugin built in code, and
the production test copies it through createIsolatedFixture, which
symlinks the workspace root node_modules. The only file the build
actually reads is package.json's "type": "module" (for ESM output), so
the deps, vite.config.ts, tsconfig.json, and next-env.d.ts were all dead
scaffolding.

Trim package.json to name/private/type, add the empty lockfile importer
so frozen install is satisfied, and delete the unused config files. Also
drop app/result/page.tsx, which .gitignore's bare "result" rule silently
excluded from the commit anyway; the tests only assert the redirect
Location header, never render /result.

* fix(app-router): encode async route-miss redirects in the RSC flight

A redirect() thrown by an async root layout (one that `await headers()`
before redirecting) during a route-miss not-found render was lost on RSC
navigations. renderAppPageBoundaryResponse returns the RSC stream without
consuming it, so the async redirect only surfaced through React's onError
once the stream was pulled — after the synchronous captured-special-error
check had already run and found nothing. The request fell through to a raw
404 flight instead of the 200 flight-encoded redirect the client router
expects. The document path was unaffected because createHtmlResponse
consumes the stream, surfacing the redirect before the same check.

Drain a tee'd copy of the RSC boundary stream to force the render to
settle before the capture check, then hand the buffered copy back as the
body. Boundary responses are small terminal documents, so buffering them
is an acceptable cost for correct redirect handling; the document path is
untouched. Mirrors app-page-render.ts's pre-flush special-error capture.

Also:
- Extract the duplicated redirect-flight encoding (digest format, throwing
  error, stream builder) from app-page-dispatch and app-page-boundary-render
  into app-rsc-redirect-flight.ts as a single owner, with focused unit tests.
- Replace the `new Error(...) as Error & { digest }` cast with a typed
  RscRedirectFlightError subclass, and read the metadata-error marker via
  Reflect.get instead of an `as Record<symbol, unknown>` cast.
- Document that renderBoundarySpecialErrorResponse deliberately omits
  renderFallbackPage: redirects are fully handled, but a notFound()/
  forbidden()/unauthorized() re-thrown inside boundary rendering has no
  parent boundary and terminates with a plain status-text response. Pin
  that terminal behavior with a test.
- Add route-miss RSC redirect coverage (dev + production) asserting the
  200 flight response, text/x-component, and X-Vinext-Rsc-Redirect header.

* test(app-router): prove RSC redirect drain covers matched-route fallbacks

The RSC special-error drain in the HTTP-access fallback path was described
as route-miss-only, but it runs for every http-access fallback: a matched
route's notFound()/forbidden()/unauthorized() renders its boundary through
the same path, where a layout can also async-redirect. Narrowing the drain
to route misses would leave that matched-route case with the original
lazy-stream bug, so the broad behavior is intentional.

Add a matched-route fixture page (`/gated` calls notFound()) and dev +
production coverage: with the redirect trigger the matched-route not-found's
async layout redirect is encoded as a 200 flight; without it the response is
a normal 404 flight, proving the pre-response buffering neither drops nor
corrupts the matched-route payload. Broaden the drain comment to state the
behavior spans route-miss and matched-route fallbacks and why.

* fix(app-router): honor html-limited bots for fallback metadata redirects

A redirect() thrown from generateMetadata() while rendering an HTTP-access
fallback boundary always rode as a 200 streaming response (HTML meta-refresh
/ RSC flight), even for html-limited bots that Next.js serves a blocking 307.
renderBoundarySpecialErrorResponse never passed serveStreamingMetadata, so
buildAppPageSpecialErrorResponse defaulted `serveStreamingMetadata !== false`
to streaming. Matched-page dispatch already threads
shouldServeStreamingMetadata(userAgent, htmlLimitedBots); the fallback path
did not.

Thread htmlLimitedBots into the fallback renderer, compute the same
per-request decision there, and pass serveStreamingMetadata through the
boundary options into the special-error responder. Hoist the entry's
__htmlLimitedBots declaration above __createAppFallbackRenderer (it runs at
module init) to avoid a temporal-dead-zone reference. Cover both branches:
streaming document → 200 meta-refresh, Bingbot → 307 + Location.

Also fix the matched-route drain test so it actually exercises the new path.
The prior test redirected from the root layout, which fires during the
matched-route layout probe and is caught by the layout special-error path
before the fallback renders — so it passed without touching the boundary
drain. Redirect from the route's own not-found boundary instead (on its own
header, so the root layout renders normally and the fallback is reached),
which surfaces through the RSC drain in renderAppPageBoundaryElementResponse.

* fix(app-router): prefix basePath on global-not-found fallback redirects

The global-not-found branch of renderHttpAccessFallback did not pass
basePath/trailingSlash into renderAppPageHttpAccessFallback, while the normal
fallback branch did. renderBoundarySpecialErrorResponse forwards
options.basePath into buildAppPageSpecialErrorResponse, which prefixes
app-internal redirect Locations with the configured base path. So a redirect()
thrown from app/global-not-found.tsx or its generateMetadata() produced an
unprefixed Location under basePath, unlike every other fallback boundary.

Pass basePath and trailingSlash in the global-not-found branch too, matching
the sibling branch. Also refresh the stale renderBoundarySpecialErrorResponse
comment: it described a route-miss-only path and a "root not-found or error
boundary", but the responder now covers matched-route HTTP-access fallbacks
and no longer claims error-boundary handling.

* fix(app-router): preserve fallback redirect semantics

* fix(app-router): preserve push redirect history

---------

Co-authored-by: James <james@eli.cx>
2026-07-08 12:07:22 +01:00
Jerry Zhao 1860632d87 refactor: migrate path handling from node:path to pathslash (#2502)
* refactor: migrate the CLI cluster path handling to pathslash

* refactor(routing): migrate the routing cluster path handling to pathslash

* refactor(plugins): migrate the plugins cluster path handling to pathslash

* refactor(server): migrate the server cluster path handling to pathslash

* refactor(build): migrate the build cluster path handling to pathslash

* refactor(core): migrate entries, config, and shared utils to pathslash

* refactor(plugin): migrate index.ts path handling to pathslash

* refactor: finish the pathslash migration — test sweep, stragglers, lint guard

* refactor: address final-review findings on the pathslash migration

* docs(agents): document the pathslash path-handling convention

* fix(build): emit inlined dependencies outside dist/node_modules

* fix: normalize wire- and bundler-shaped ids unconditionally

* docs(agents): document the unconditional-normalization exception

* fix: gate Windows-shaped path fixtures to Windows

* docs(agents): gate Windows-shaped fixtures with runIf

* refactor: migrate upstream prerender path discovery to pathslash

* fix(create-vinext-app): bundle pathslash into the scaffolder dist

---------

Co-authored-by: James <james@eli.cx>
2026-07-06 23:49:04 +01:00
MK (fengmk2) 2bf68e906b chore(deps): bump vite-plus to v0.2.2 (#2524)
Vite+ is now in beta.
https://voidzero.dev/posts/announcing-vite-plus-beta
2026-07-06 20:49:17 +01:00
James Anderson f22c962af8 refactor(cloudflare)!: remove vinext package coupling (#2527)
* refactor(cloudflare)!: remove vinext package coupling

* Review: PR #2527 breaks a test

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

* test(cache): remove CDN auto-detection expectations

---------

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-07-04 15:29:45 +01:00
James Anderson e4aa03ac5c feat(create): add create-vinext-app (#2483)
* feat(create): add create-vinext-app

* fix(ci): support create package checks

* fix(create): address creator review findings

* fix(create): document version flag

* fix(ci): keep create-next-app cloudflare coverage

* fix(create): update init option test for cdn cache

* fix(create): bundle creator helper deps

* fix(ci): allow local vinext dlx deploy test

* fix(ci): run create-vinext-app deploy test from local tarballs

* fix(create): keep creator package self-contained

* fix(ci): preview release create-vinext-app

* fix(init): install runtime deps as app dependencies

* fix(create-vinext-app): scope workers copy to cloudflare

* fix(create-vinext-app): use pnpm in template examples

* fix(init): start cloudflare apps with wrangler
2026-07-03 17:51:49 +01:00
James Anderson b5897d0595 feat(build): require Vite 8 (#2486)
* feat(build)!: require Vite 8

* fix(build): remove unused Vite version export

* test(link): harden prefetch timing assertions
2026-07-03 15:20:33 +01:00
James Anderson b0de1af028 fix(cloudflare): support Sentry request errors on Workers (#2425)
* fix(cloudflare): support sentry request errors on workers

* fix(cloudflare): avoid worker conditions in node pages builds

* test(cloudflare): run sentry worker e2es in ci

* test(cloudflare): cover sentry render errors

* test(cloudflare): mirror sentry next setup in fixtures

* update lockfile

* update lockfile

* test(cloudflare): cover sentry client instrumentation
2026-06-30 10:58:04 +01:00
James Anderson 0a48f0f7fb feat(cloudflare): move deploy command to cloudflare package (#2405)
* feat(cloudflare): move deploy command to cloudflare package

* fix(cloudflare): expose deploy cli bin
2026-06-29 16:47:28 +00:00
James Anderson 25a3c26f7a feat(init): scaffold for cloudflare and node (#2279)
* feat(init): select deployment platform

* refactor(init): AST-update deployment config

* feat(init): configure Cloudflare cache and images

* fix(init): avoid backtracking in config updates

* fix(init): cover packaged Cloudflare setup

* ci: allow Cloudflare init smoke builds

* ci: fix packaged Cloudflare dependency install

* test(e2e): isolate browser fixture servers

* chore: allow Windows taskkill binary

* fix(init): configure Cloudflare image adapter

* fix(init): address platform setup review

* fix(init): address cache setup review

* fix(init): hide workers cache option

* fix(init): align generated config indentation

* fix(init): validate cloudflare setup before mutation

* fix(init): preserve additive config syntax

* fix(init): tidy interactive prompt spacing

* fix(images): rename Cloudflare optimizer builder

* refactor(images): remove draft name migration

* fix(init): install dependencies after setup

* fix(init): clarify setup recovery steps

* fix(init): ignore Wrangler state for Cloudflare

* fix lockfile
2026-06-26 17:09:19 +01:00
James Anderson c5e16b1ad0 fix: sync trailing slash fixture lockfile (#2377) 2026-06-26 15:13:26 +00:00
James Anderson b4843d75c1 feat(images): configure image optimization via vinext({ images }) adapter (#1873)
* feat(images): configure image optimization via vinext({ images }) adapter

Move server-side image optimization from a hand-wired custom worker entry to a
declarative `vinext({ images: { optimizer } })` option, mirroring the cache
adapter pattern. The default entries now handle `/_next/image` through a
registered optimizer, so no custom worker is required, and the same config
works across all targets — optimizing on Cloudflare, gracefully serving images
unoptimized on Node/dev where the binding is unavailable (like KV cache
degrading to in-memory).

- add an ImageOptimizer registry (set/getImageOptimizer +
  handleConfiguredImageOptimization) in server/image-optimization.ts
- generate `virtual:vinext-image-adapters` (registerConfiguredImageOptimizer)
  from the new `images` plugin option
- add @vinext/cloudflare/image/image-adapter: imageAdapter() builder + runtime
  factory reading the env.IMAGES binding
- handle /_next/image in the default app-router entry and the generated Pages
  worker via the registry; inline next.config `images` (allowed widths +
  security headers) into the RSC entry
- vinext deploy points App Router `main` at vinext/server/app-router-entry
  (no generated worker) and prints a hint to enable the optimizer

next.config `images` (remotePatterns, deviceSizes, dangerouslyAllowSVG, etc.)
continues to drive the standard Next.js options; the vite-config
`images.optimizer` only selects the runtime transform backend.

* fix(images): cover deploy image-hint helpers and preserve optimizer this-binding

The Check CI job failed because knip flagged viteConfigHasImageAdapter and
formatImageOptimizationHint as unused exports — they were only called inside
deploy.ts. Cover both with unit tests in tests/deploy.test.ts (mirroring the
existing viteConfigHasCacheAdapter / formatMissingCacheAdapterError suites),
which also closes the coverage gap for the new deploy hint path.

Also wrap the registered optimizer's transformImage in
handleConfiguredImageOptimization instead of detaching the method, so an
optimizer implemented as a class instance keeps its this binding.

* fix(images): honor configured deviceSizes/imageSizes on the App Router Node prod server

Review follow-up (ask-bonk):

- The App Router prod server (vinext start) validated /_next/image widths
  against the hardcoded Next.js defaults, rejecting valid optimizer URLs with
  400 when the app configures custom images.deviceSizes/imageSizes — while the
  Cloudflare worker entry and the Pages prod path already honored them. Read
  the __imageAllowedWidths constant inlined into the RSC entry (falling back to
  the defaults for older builds), matching how __assetPrefix/__basePath are read.
- Lock in the this-binding behavior of handleConfiguredImageOptimization with a
  class-instance optimizer test.

* fix(images): pass an explicit empty allowed-widths config through on vinext start

Review follow-up (ask-bonk, awareness note): the old-build fallback guard
conflated a missing __imageAllowedWidths export with an explicit empty
deviceSizes/imageSizes config, mapping the latter to the Next.js defaults on
the Node App Router path while the Cloudflare worker passes the empty array
straight through. Only fall back to the defaults when the export is absent.

* refactor(images): read App Router image config from the RSC entry, retire the JSON sidecar

Review follow-up (ask-bonk): the App Router had two parallel build-time sources
for next.config images security/header settings — the __imageConfig constant
inlined into the RSC entry (read by the Cloudflare worker entry) and the
image-config.json sidecar written by the vinext:image-config plugin (read by
vinext start). Unify on the RSC entry export: prod-server now reads
rscModule.__imageConfig, keeping image-config.json only as a read-side fallback
for dist outputs built by older vinext versions, and the sidecar writer plugin
is removed.

* fix(deploy): keep wrangler main on a user-authored worker entry for App Router

Review follow-up (ask-bonk): an App Router app with a custom worker/index.ts
but no wrangler.jsonc would have had its custom worker silently dropped —
generateWranglerConfig unconditionally pointed main at the default
vinext/server/app-router-entry. Respect hasWorkerEntry so a user-authored
worker keeps winning for both routers, with a regression test.

* fix(images): expose Cloudflare optimizer under images path

* fix(deploy): install Cloudflare image adapter package

* fix(examples): declare Cloudflare image adapter package

* test(images): update App Router image config codegen assertions

* fix(examples): configure image optimizer adapters
2026-06-23 20:20:36 +01:00
James Anderson 2cbd2a0356 fix(build): apply default server externals (#2257)
* fix(build): externalize Next.js default server packages

* test(node): cover externalized shiki in standalone output
2026-06-23 10:20:55 +00:00
James Anderson 92884eef42 feat(web): add Vite configuration examples (#2289) 2026-06-23 09:01:24 +00:00
Stephen Zhou 6b55874ee0 fix(config): make vite-tsconfig-paths an optional peer (#2258) 2026-06-22 14:39:36 +01:00
James Anderson cf9d601162 fix lockfile (#2233) 2026-06-21 18:18:31 +00:00
MaxtuneLee 42f8700f15 fix(router): enhance navigation with as mask for URL handling (#2076)
* fix(router): enhance navigation with `as` mask for URL handling

* fix(router): implement bracket-pattern interpolation for dynamic routes in Pages Router

* fix(router): preserve masked state in beforePopState

* fix(router): cover basepath navigation in ci

* docs(router): clarify navigation state invariants

* refactor(router): require complete history state

* refactor(router): share dynamic href interpolation

* test(router): cover localized masked navigation

* fix(router): keep interpolation internals private

---------

Co-authored-by: James <james@eli.cx>
2026-06-19 00:07:03 +01:00
MK (fengmk2) 6fff7bb88f chore: upgrade vite-plus to 0.2.1 (#2149)
v0.2.1 consumes upstream Vitest directly: the @voidzero-dev/vite-plus-test wrapper is removed and vitest now comes in transitively through vite-plus.

- catalog: vite-plus 0.1.24 -> 0.2.1, vite -> @voidzero-dev/vite-plus-core@0.2.1
- catalog: vitest -> upstream 4.1.9 (was the vite-plus-test alias), @vitest/coverage-istanbul 4.1.6 -> 4.1.9
- drop the vitest peerDependencyRules accommodations for the wrapper/old vitest
- decode Vitest's flatted blob format directly in scripts/lib/vitest-blob-timings.mjs (the removed wrapper bundled the parser this script previously reached into)
2026-06-18 16:37:34 +01:00
Nathan Nguyen a3d2f92152 fix(router): honor hybrid pages route priority (#1997)
* fix(router): honor hybrid pages route priority

* fix(router): share hybrid owner decision with client navigation

PR #1997 fixed the server-side route ownership for direct document loads,
but the same invariant broke for client-side soft navigations and the
matching prefetch path:

- navigateClientSide() always delegated to the App runtime's RSC fetch,
  even when the Pages route had higher priority. renderPagesFallback()
  short-circuits RSC requests with null, so the App catch-all won.
- prefetchUrl() prefetched an RSC stream for any URL that matched an App
  route, again ignoring Pages ownership.
- The Pages entry was loaded on every hybrid request, even when a static
  App route had already matched (Pages can never win in that case).

Expose the Pages route manifest on the client via a new
__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ window global emitted by both the
App and Pages browser entries. The link shim consults a shared
resolveHybridClientRouteOwner helper that mirrors the server-side
pagesRouteHasPriorityOverAppRoute comparison. When Pages owns the URL,
the click handler issues a window.location navigation and the prefetch
path returns early.

Gate the renderPagesFallback call behind a static-App-route check in
handleAppRscRequest: when a static App route matches, the bridge
cannot win, so skip the eager Pages entry load. Centralise the
comparison in a new resolveHybridRouteOwner helper so server and
client reach the same answer for the same (URL, route pair).

Adds the missing client-navigation coverage to the use-params e2e
fixture (Link from /app/ to /pages-dir/foobar) and unit tests for
the shared owner decision.

* fix(router): mirror server hybrid owner decision on the client

PR review flagged two split-brain bugs in the previous hybrid
ownership fix: a hand-copied client comparator and a Link-only
ownership gate that left programmatic App Router navigations on
the wrong path.

The hand-copied routePrecedence in hybrid-client-route-owner.ts
omitted the static-prefix reduction that lives in
routing/utils.ts#routePrecedence, and used a strict-less-than
comparison that returned App for identical dynamic patterns. The
server returns Pages for both cases (Pages providers sort ahead
of App providers, and routePrecedence subtracts 50 per static
prefix segment). The split produced a real ownership disagreement
on overlapping patterns like /_sites/:slug* (Pages) vs /:slug*
(App).

Add a shared compareHybridRoutePatterns to routing/utils.ts as
the single source of truth: the static/dynamic short-circuits plus
a sortRoutes call (which carries the static-prefix reduction and
the Pages-first equal-pattern tiebreak). The server
pagesRouteHasPriorityOverAppRoute and the client
resolveHybridClientRouteOwner both delegate to it, so they
cannot diverge. Drop the hand-copied routePrecedence entirely.

Wire the ownership check at the App navigation runtime boundary
so useRouter().push, useRouter().replace, gesturePush, and form
submits all get the same hard-nav contract as the Link click
handler. Specifically:

- navigateClientSide: after same-origin normalization, if Pages
  owns the URL, hard-navigate via window.location and return
  (matching the existing external-URL branch).
- _appRouter.prefetch: short-circuit RSC URL construction for
  Pages-owned targets so we do not warm an unusable cache entry.

Centralise the existing two inline hard-nav branches in
navigateClientSide into a hardNavigateTo helper for clarity.

Tests:

- Direct unit tests for compareHybridRoutePatterns covering
  identical-dynamic tiebreak, static-prefix dynamic overlap,
  static-prefix catch-all overlap, and infix-static bonus.
- Direct unit tests for resolveHybridClientRouteOwner mirroring
  the server assertions plus a basePath-stripping test.
- e2e: useRouter().push('/pages-dir/foobar') from an App page
  resolves to the Pages document.
- e2e: useRouter().prefetch('/pages-dir/foobar') issues zero
  RSC requests for the target URL.

* docs(router): fix stale score in static-prefix catch-all test comment

The hand-copied comparator note quoted the dynamic-segment scores
(51 / 1000) for the optional-catch-all example (/_sites/:slug*
vs /:slug*). The current optional-catch-all scoring actually
produces 1951 vs 2000. The test assertion was correct; only the
comment was stale.

* test(use-params): fix direct-load single dynamic param to use /a instead of /a/b

* fix(router): compare hybrid routes structurally

* fix(router): reject hybrid route conflicts

* fix(router): refresh hybrid route ownership

* fix(router): preserve hybrid routing lifecycle

* fix(router): recheck pages routes after rewrites

* fix(router): preserve rewritten pages queries

* fix(router): preserve rewritten route ownership

* fix(router): resolve client rewrites sequentially

* fix(router): apply rewrite phases sequentially

* fix(router): preserve rewrite params and endpoints

* fix(router): hand off endpoint navigations

* fix(router): preserve rewrite fragment params

* test(e2e): avoid hybrid fixture route conflicts

* test(pages): await async config validation

* test(hybrid): avoid duplicate page fixtures

* chore(router): clarify hybrid priority semantics

* refactor(router): remove duplicate app route matcher

* docs(router): clarify client hybrid comparator

---------

Co-authored-by: James <james@eli.cx>
2026-06-15 00:22:00 +01:00
Nathan Nguyen 930e03c449 fix(ppr): gate fallback shells until request-time resume is supported (#1716)
* fix(app-router): prerender cacheComponents root-param fallback shells

PR 1 of 4: core model + build integration.
Model: app-ppr-fallback-shell, pregenerated-concrete-paths, prerender-manifest
Build: prerender/run-prerender fallback shell artifact generation
Tests: createAppPprFallbackShells, pregenerated-concrete-paths core

* feat(ppr): add fallback shell payload identity

* feat(ppr): safely serve fallback shell cache entries

* refactor(ppr): extract fallback shell render pipeline and dedupe getViteMajorVersion

* fix(ppr): restore safe-serving CI checks

The safe-serving branch failed CI because a client-side server-only guard rejected valid 'use server' action modules, a fallback-shell dependency cast no longer matched its helper type, apps/web lost the Cloudflare cache adapter source path during typecheck, and Knip could not resolve the documented Cloudflare cache adapter subpaths.

The server-only guard now reads the directive prologue and skips top-level 'use server' modules, fallback-shell regeneration passes an explicit typed dependency object, apps/web gets the workspace Cloudflare cache path, the Cloudflare package exports explicit adapter subpaths, Knip uses a relative source import in the dispatch unit test, and the readiness test awaits the actual cache-ready promise.

* fix(ppr): guard fallback shell reads for queries

* fix(ppr): normalize pregenerated concrete paths

* fix(ppr): address review comments on fallback shell serving and react runtime check

* fix(ci): restore knip ignores for unlisted binaries and prune dependencies

* fix(ppr): restrict bracket check in fallback detection to legacy manifests

* fix(ppr): prefer stable ESM react-dom/static.edge before CJS fallback

The loadStaticPrerender dev-mode path hard-codes react-dom's internal
CJS file layout (cjs/react-dom-server.edge.development.js). A React
upgrade could silently break fallback-shell rendering in dev without
affecting production because the function never tried the stable ESM
entry in development.

Change the order so that react-dom/static.edge is attempted first in
all environments. Only fall back to the CJS path when the ESM entry
does not export prerender() and we are in a development runtime. This
lets future React dev builds that expose prerender() from the ESM entry
work automatically, and it surfaces the CJS-path failure only when the
stable path is genuinely unavailable.

Also handle the CJS default-export interop shape that Node.js ESM
produces when dynamically importing a CommonJS module.

* refactor(ppr): address four review cleanup items

1. Remove redundant double-normalization in seed-cache.ts
   addPregeneratedConcretePath already normalizes internally, so the
   call-site normalizePregeneratedPathname() was redundant. Dropping it
   gives a single source of truth for pathname normalization.

2. Lazily construct FallbackShellRenderDeps in tryServePprFallbackShell
   Extract the large FallbackShellRenderDeps object into a top-level
   buildFallbackShellRenderDeps helper. The closure in the probe loop
   now just delegates, so the heavy object is only created when a STALE
   entry actually triggers regeneration, not on every HIT probe.

3. Add dev-only warning for legacy bracket heuristic
   When isFallbackShellArtifactPath falls back to the bracket substring
   scan (route.fallback === undefined), emit a console.warn in dev so
   developers know a legacy manifest is being used and concrete URLs
   with literal brackets may be misclassified.

4. Update pregenerated-concrete-paths.test.ts to colon notation
   The production manifest writer uses colon notation (/:slug) for the
   route field, but the test fixtures used bracket notation (/[slug]).
   Update the fixtures so the test format matches production, exercising
   the real key format that the safe-serving guard depends on.

* test(ppr): compact fallback shell dispatch fixtures

* refactor(ppr): name fallback shell render phases

* refactor(app-router): deduplicate PPR fallback shell and app page cache render paths

* fix(ppr): avoid serving incomplete fallback shells

* chore: drop redundant cloudflare cache aliases

* fix(ppr): track fallback navigation hooks

* test(ppr): render navigation hook probes

* test(ppr): type navigation hook probes

* refactor(ppr): remove unused shell regeneration path

* refactor(ppr): remove unused shell result type

* fix(ppr): safely gate fallback shell prerendering

* fix(ppr): keep fallback shell gate internal

* fix(ppr): mark generated dynamic fallback shells

* test(ppr): align fallback fixture and search params

---------

Co-authored-by: James <james@eli.cx>
2026-06-14 00:44:54 +01:00
Nathan Nguyen e018a08708 fix(pages-router): restore scroll across reload history traversal (#1905)
* fix(pages-router): restore scroll across reload history traversal

Pages Router pages with experimental.scrollRestoration currently expose window.next.router before the popstate runtime is installed and only keep Vinext scroll coordinates in the in-memory history state. After a reload, a back or forward traversal can run before the runtime is ready and has no durable scroll snapshot for the target history entry.

The violated invariant is that the Pages Router owns browser scroll restoration whenever the Next.js scroll restoration flag is enabled, with one stable history key per entry and routeChangeComplete emitted after the new page commits.

Resolve the Next.js config flag into process.env.__NEXT_SCROLL_RESTORATION, install the Pages Router runtime during next/router evaluation, persist Next-style sessionStorage scroll snapshots per history key, and render navigations through a stable commit boundary before emitting routeChangeComplete.

Regression coverage ports Next.js reload-scroll-backforward-restoration and verifies manual restoration plus exact scroll positions across reload, back, and forward traversal.

* fix(pages-router): sequence routeChangeComplete after scroll restoration and settle on render failures

* fix(router): guard against missing window.history in stampInitialHistoryState

* fix(router): defer hash scroll until after routeChangeComplete

Move hash scrolling out of renderPagesRouterElement's commit callback
and into the post-routeChangeComplete path, matching Next.js ordering:
x/y restoration happens during render commit, hash scrolling happens
after the completion event.

Also fix E2E test expectRouteChangeComplete to use one-shot event
listeners instead of checking for stale text, and add listener cleanup
to the fixture to prevent accumulation.

* fix(test): remove post-push deadlock and throw on missing events in E2E helper

expectRouteChangeComplete() now throws if window.next.router.events is
absent instead of silently resolving. Remove the redundant
expectRouteChangeComplete call after pushWithPagesRouter in the third
test — the awaited router.push already resolves after routeChangeComplete,
so registering a listener after it would deadlock.

* fix(test): replace as any casts with type-safe window.next narrowing in E2E scroll tests

* fix(router): narrow renderPagesRouterElement scroll param, drop dead hash branch

* fix(router): reset x/y scroll on hash routes before routeChangeComplete and cancel pending render on supersede

- Pass {x:0, y:0} through commit boundary for all scrollable
  navigations, not just hash-less ones (hash scroll deferred to after
  routeChangeComplete as before).
- Cancel pending render-commit Promise when a newer navigation starts,
  preventing hang when the first root.render() commit is dropped by
  React re-reconciliation.
- Add regression tests for both edge cases.

* fix(router): use SSR-safe layout effect in commit boundary and pin shallow scroll ordering

Address bonk review on #1905:
- swap the commit boundary's raw useLayoutEffect for the SSR-safe
  useNonWarningLayoutEffect pattern from shims/image.tsx so Pages Router
  SSR renders no longer log React's useLayoutEffect warning
- document that the default-noop commit boundary intentionally also wraps
  SSR and initial hydration
- document the intentional shallow-scroll-before-routeChangeComplete
  ordering and add a unit test pinning it

* fix(router): keep history-state scroll restore on popstate when scrollRestoration flag is off

Without experimental.scrollRestoration, popstate must still honour the
__vinext_scrollX/Y saved into history state on push, since a soft popstate
re-renders content the browser's native restoration can't position. Also
document why the popstate-time sessionStorage snapshot depends on
history.scrollRestoration = "manual".

* fix(router): guard render-commit canceller against late commits from superseded trees

* fix(router): defer current-history-key commit until beforePopState allows navigation

When beforePopState cancels a popstate the app stays on the previous
history entry, but _currentHistoryKey had already been updated to the
target key, skewing subsequent scroll-position bookkeeping. Move the
key commit below the beforePopState early-return, matching the
deliberate deferral of _lastPathnameAndSearch.

* chore(router): note intentional eager scroll snapshot; fix fixture link increment

- Document that the outgoing-entry scroll snapshot is deliberately written
  before the beforePopState gate: on cancellation the app stays on
  currentKey, so the saved value remains that entry's live scroll.
- Coerce id to a number in the pages-scroll-restoration fixture Link so
  the href increments numerically instead of string-concatenating.

* fix(router): keep manual scroll restoration opt-in Pages-Router-only

Gate installManualScrollRestoration on the document not being an App
Router one (window.next.appDir === true): with the now-global
__NEXT_SCROLL_RESTORATION define, an App Router app that value-imports
next/router for compat would otherwise have its native browser scroll
restoration disabled at module eval. Upstream only flips
history.scrollRestoration inside the Pages Router constructor, which
never runs on App Router documents.

Also document the hash-only popstate path discarding the per-entry
__next_scroll_<key> snapshot as intentional upstream parity (change()'s
onlyAHashChange branch passes null to this.set and only scrollToHash
runs), and add unit coverage for both behaviors.

* ci: run pages-scroll-restoration E2E project in the CI matrix

Add the missing E2E matrix entry so the ported Next.js regression spec
(reload-scroll-backforward-restoration.spec.ts) actually executes in CI,
matching the shape of the sibling single-shard Pages Router entries.

Also document why the soft-popstate scroll fallback differs between
manual restoration on ({ x: 0, y: 0 }) and off (null -> native auto
restoration handles unstamped entries), per review.

* refactor(router): fold App Router gate into manualScrollRestoration constant

The popstate handler's sessionStorage save/read branches gated on the
module-level constant, which lacked the window.next?.appDir check that
installManualScrollRestoration used, leaving those branches relying
solely on the __N state filter to stay inert on App Router documents.
Fold the appDir check into the constant so every consumer is
Pages-Router-only by the same gate; the App Router bootstrap stamps
window.next.appDir before any client module evaluates, so the
module-eval-time check is reliable.

* test(pages-scroll-restoration): default getServerSideProps id to string "0"

Route params always arrive as strings, so the numeric 0 fallback was
inconsistent with the string values the component relies on
(Number(id) + 1 href, id === "error" check). Default to "0" for type
consistency.

* test(pages-scroll-restoration): align fixture deps and vinext import with pages-basic

Import vinext via the workspace package name instead of reaching into
dist by relative path, and declare the same react/react-dom/vinext
dependencies as the other fixtures so the fixture is self-describing
and the pnpm-lock.yaml importer entry is populated.

* fix(router): snapshot outgoing scroll on hash-only pushes

Upstream Next.js snapshots the outgoing entry's scroll into
__next_scroll_<key> inside Router.push() itself, before change()'s
onlyAHashChange short-circuit, so hash-only pushes still capture the
departed entry's position. Our hash-only push branch returned before
saveScrollPosition() ran, so a later back-popstate to that entry
restored {x: 0, y: 0} instead of the position the user had reached.

Save the outgoing scroll before updateHistory mints the new key,
matching the full-navigation push path and upstream ordering.

* docs(router): clarify manual scroll-target fallback chain precedence

* test(shims): tag scrollRestoration App Router test as eval-order guard

* docs(router): note missing-snapshot fallback intent at forcedScroll default

---------

Co-authored-by: James <james@eli.cx>
2026-06-12 11:31:33 +01:00
James Anderson ce29f9c6aa fix(scss): preprocess SCSS CSS-module composes deps via SassAwareFileSystemLoader (#1882)
* fix(scss): preprocess SCSS CSS-module composes deps via SassAwareFileSystemLoader

postcss-modules' built-in FileSystemLoader reads files referenced by
`composes: className from './other.module.scss'` as raw text — without
Sass preprocessing.  For .scss files this leaves SCSS variables and bare
@import directives in the CSS output, causing LightningCSS to crash with
"Invalid empty selector" during production builds.

Fix: introduce SassAwareFileSystemLoader that delegates to Vite's
`preprocessCSS` API for every `composes:` dependency, so Sass compilation
runs before CSS-module scoping.  Register it via `css.modules.Loader` in
vinext's Vite config hook and thread the resolved config through via a new
`setSassLoaderResolvedConfig` call in the `configResolved` hook.

Fixes the following deploy-suite tests (both sass and sass-embedded variants):
- test/e2e/app-dir/scss/nm-module/nm-module.test.ts
- test/e2e/app-dir/scss/nm-module-nested/nm-module-nested.test.ts
- test/e2e/app-dir/scss/composes-external/composes-external.test.ts

Part of #1825 (non-tilde CSS-module/composes cases; complements #1881)

* fix(scss): address review feedback on SassAwareFileSystemLoader

- Guard the css.modules.Loader injection: respect a user's explicit
  css.modules: false (mergeConfigRecursively would otherwise re-enable
  CSS Modules with an object override) and never clobber a
  user-provided custom Loader. The override is precomputed as a typed
  const so the conditional spread doesn't disturb the config literal's
  contextual typing.
- Surface composes preprocessing failures via config.logger.warn
  instead of a bare catch {}, so missing composed classes are
  observable; a missing/unreadable dependency file now rejects like
  postcss-modules' FileSystemLoader did.
- Use Vite's exported preprocessCSS + PreprocessCSSResult type via a
  top-level import instead of the lazy dynamic import and stringly
  typed casts.
- Preserve parity for composes from non-*.module.* files: hand
  preprocessCSS a virtual *.module.* filename so plain '.css' deps are
  still scoped and export tokens extracted, matching the built-in
  FileSystemLoader.
- Port the three failing Next.js fixtures (composes-external,
  nm-module, nm-module-nested) as production-build regression tests in
  tests/scss-composes.test.ts, plus a plain .module.css / non-module
  .css parity test. All three SCSS tests reproduce the original
  "[lightningcss minify] Invalid empty selector" crash when the
  Loader injection is disabled.
- Add sass as a root devDependency so the SCSS tests (including the
  previously always-skipped tests/scss.test.ts) actually run in CI;
  drop the now-redundant knip ignore entry. Installing sass makes
  Vite's SassPreprocessorOptions resolve to the concrete sass types
  (previously any), so the tilde-importer's importers array now
  carries an explicit SassPreprocessorOptions["importers"] cast.

* fix(scss): bind composes-loader config per plugin instance; scope extensionless deps

- Replace the module-level resolved-config singleton with a per-vinext-
  instance binding (createSassAwareFileSystemLoader), so multiple builds in
  one process never preprocess composes deps with another build's config.
- Cover extensionless composes deps in the virtual *.module.* rename
  (append .module.css) so their tokens are scoped instead of silently
  dropped; add a parity test case.
- Pin url() pass-through behaviour for composed deps in the parity test
  (verbatim, never mangled into placeholders — matches the built-in
  FileSystemLoader, verified against a vanilla Vite build).

* docs(scss): document the per-subtree recursion boundary of the composes loader

Notes the two structural consequences of delegating nested composes
recursion to preprocessCSS (per-subtree duplicate inlining collapsed by
LightningCSS, and no cross-boundary cycle short-circuit for invalid
circular composes), per review.

* fix(scss): fail the build on composed-dep preprocessing errors

Only a missing Sass implementation is downgraded to a logged warning;
any other preprocessCSS error (e.g. a Sass syntax error in the composed
dependency) now propagates and fails the build, matching postcss-modules'
built-in FileSystemLoader instead of shipping a green build with
silently-missing classes. Docstrings updated to accurately describe the
silent empty-token path when no resolved config is bound. Adds a
regression test for the fail-loudly path.

* chore(deps): move sass to the workspace catalog; drop the now-dead dynamic skip in scss.test.ts

Review feedback on #1882: sass belongs in the pnpm workspace catalog
rather than pinned inline in the root package.json. With sass now a
guaranteed root devDependency, the runtime describeIfSass skip in
tests/scss.test.ts is dead code — the suite always runs — so make the
unskip explicit by removing the conditional machinery.

* rebuild lockfile
2026-06-11 00:29:51 +00:00
James Anderson 835e6b9943 fix(app-router): suppress redirect console errors in production onCaughtError (root-layout-redirect) (#1878)
* fix(app-router): suppress redirect console errors in production onCaughtError (#1830)

When redirect() is called from a 'use client' root layout (or any client
component), React catches the NEXT_REDIRECT sentinel via RedirectErrorBoundary
and then calls the hydrateRoot onCaughtError callback. In dev mode vinext
already filtered these via devOnCaughtError, but the production path had no
onCaughtError at all — React's built-in fallback calls console.error, producing
a spurious browser console error that fails the Next.js compat test assertion
`expect(foundErrors).toBe(false)`.

Fix: add prodOnCaughtError to app-browser-error.ts that silently drops
navigation signal errors (NEXT_REDIRECT, NEXT_NOT_FOUND, NEXT_HTTP_ERROR_FALLBACK)
and logs all other caught errors, preserving React's default behavior.

Add fixture tests/fixtures/root-layout-redirect/ (dedicated 'use client' root
layout with redirect()) and Playwright project root-layout-redirect (port 4184)
with spec porting the Next.js root-layout-redirect > should work using browser.

Part of #1830

* fix: include result/ page in root-layout-redirect fixture

* fix: add lockfile entry for root-layout-redirect fixture, use prod server, import isNavigationSignalError

Three fixes to unblock catastrophically-failing CI:

1. pnpm-lock.yaml: Add missing importer entry for
   tests/fixtures/root-layout-redirect. The fixture was added without
   updating the lockfile, causing ERR_PNPM_OUTDATED_LOCKFILE on every
   CI job during `vp install`.

2. playwright.config.ts: Switch the root-layout-redirect server from
   `npx vp dev` (dev mode) to `vinext build + vinext start` (production
   mode). The fix under test — prodOnCaughtError — is only active in
   production builds; running in dev mode exercised the pre-existing
   devOnCaughtError path and never touched the new code.

3. app-browser-error.ts: Replace the inline isNavigationSignalDigest
   helper with an import of isNavigationSignalError from
   utils/navigation-signal.ts. The module is dependency-free and
   already in the production browser bundle; inlining it reintroduced
   the drift risk that navigation-signal.ts was created to prevent.

Addresses ask-bonk findings: blocking (test ran in dev mode) and
maintainability (inline classifier duplication).

* test: add prodOnCaughtError unit tests, run root-layout-redirect e2e in CI, scope gitignore negation
2026-06-11 01:03:03 +01:00
James Anderson c669930f56 fix(build): correct CSS ordering for global-not-found (#1549) (#1858)
* fix(build): correct CSS ordering for global-not-found (#1549)

* test(build): assert global-not-found CSS ordering in production (#1549)

Adds a production-build regression test for #1549: builds the global-not-found-css-order fixture, serves it via the Vite preview server, and asserts the route-miss 404 document serves global-not-found's own CSS (red wins) without leaking the root layout's stylesheet (green), while matched routes still serve the layout's CSS.

Verified the test fails without the framework-chunk split in client-build-config.ts (the 404 leaks the layout's green) and passes with it.

* chore: update lockfile for global-not-found-css-order test fixture (#1549)

* refactor(build): migrate RSC framework chunk to codeSplitting; add unit tests

Address bonk review on #1858 (issue #1549):

- Replace the deprecated rolldown `advancedChunks` output config with
  the non-deprecated `codeSplitting` form for Vite 8+, mirroring the
  client build. Stops the per-prod-build deprecation warning.
- Derive both RSC_FRAMEWORK_CHUNK_TEST (regex) and isRscFrameworkModule
  (predicate) from a single FRAMEWORK_PACKAGES list so they can't drift.
- Add focused unit tests for createRscFrameworkChunkOutputConfig (both
  the Vite 7 manualChunks and Vite 8+ codeSplitting branches) and for the
  framework package matching (matches react/react-dom/scheduler/
  react-server-dom-webpack, excludes react-icons / @react-aria/*).
2026-06-09 10:06:14 +01:00
James Anderson 6f689c7b14 chore: rebuild lockfile (#1815) 2026-06-07 22:16:13 +00:00
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 9a721ca287 chore(deps): bump knip and node types (#1806)
* chore(deps): bump knip and node types

Bump Knip to 6.16.0 and @types/node to 25.9.2.

Knip 6.16 reports previously unreported unused exported types, stale ignore entries, and external CLI binaries. Remove unnecessary type exports, refine the script entry coverage, and keep only narrow binary ignores for system or user-project commands.

Validation: vp run knip; vp run check.

* chore: retrigger ci
2026-06-07 21:45:14 +01:00
James Anderson f9d83fd8f2 fix(build): bundle @vinext/cloudflare into vinext to break dependency cycle (#1797)
* fix(build): bundle @vinext/cloudflare into vinext to break dependency cycle

vinext consumed a few runtime helpers (KVCacheHandler, CloudflareCdnCacheAdapter,
ENTRY_PREFIX) from @vinext/cloudflare via a runtime `dependencies` edge. Combined
with @vinext/cloudflare's `peerDependencies` on vinext, this formed a cycle that
forced changesets to force-major @vinext/cloudflare on every vinext release
(mitigated defensively in #1764).

Bundle the small amount of @vinext/cloudflare code vinext actually imports into
vinext's dist via tsdown's deps.neverBundle, and move @vinext/cloudflare to
devDependencies. The published install graph now points one way
(@vinext/cloudflare -> vinext).

@vinext/cloudflare remains a published package: user vite.config files still
import cdnAdapter()/kvDataAdapter(), and the generated worker resolves its
*.runtime.js factories by absolute path. Its bundled-in vinext/shims/* imports
stay external and resolve via vinext's own package exports (Node self-reference).

Drop the now-obsolete create-next-app CI steps that packed and overrode
@vinext/cloudflare (vinext's tarball no longer declares it as a dependency).

* refactor(build): clarify @vinext/cloudflare bundling config

Use a single intent-revealing neverBundle predicate with a BUNDLED_DEPS
carve-out and document why the carve-out must live in neverBundle (tsdown
rejects skipNodeModulesBundle + alwaysBundle as mutually exclusive, and a
catch-all neverBundle takes precedence over alwaysBundle).

* fix(build): bundle @vinext/cloudflare via source alias, not external predicate

The previous approach replaced deps.skipNodeModulesBundle with a custom
neverBundle predicate. That broke tsdown's rewriting of vinext's own
`vinext/shims/*` tsconfig-path self-imports to relative paths, leaving them as
bare `vinext/*` specifiers in 46 files. Across Vite's separate RSC/SSR/client
dev module graphs those bare self-references resolved to distinct module
instances, breaking identity checks (e.g. `instanceof ReadonlyURLSearchParams`).

Keep skipNodeModulesBundle: true untouched and instead bundle @vinext/cloudflare
by aliasing its cache/* subpath to source. skipNodeModulesBundle externalizes
bare package specifiers before tsconfig paths apply, so the alias rewrites the
import to a file path up front; tsdown then treats it as local source and
bundles it. The bundled code's own vinext/shims/* imports still resolve to
vinext's relative output (single module instance).

Output is byte-identical to the pre-change build except the added bundled
cloudflare files and one cosmetic import reorder.
2026-06-06 23:58:51 +01:00
Jerry Zhao c8975a48cf chore(deps): bump playwright to 1.60.0 (#1796)
* chore(deps): bump playwright to 1.60.0

* u
2026-06-06 22:49:23 +01:00
Jerry Zhao fb9151ed9d chore(deps): bump vite-plus toolchain to 0.1.24 (#1767)
* chore(deps): bump vite-plus toolchain to 0.1.24

* bump setup-vp

* .

---------

Co-authored-by: James <james@eli.cx>
2026-06-05 15:36:59 +00: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 61d5fe00a6 feat(release): commit-driven auto-generated changesets (#1753)
* feat(release): commit-driven changesets with auto-generated changesets

Adopt stock Changesets for versioning/changelog/publishing, with the only
bespoke surface being a script that creates changesets automatically from
Conventional Commits. Auto-generated changeset files never live on `main` —
they are written to the CI working tree, consumed by changesets/action into a
rolling Version PR, and discarded. Manually authored changesets still work.

- scripts/create-changeset.mjs: derive per-package bumps from Conventional
  Commits (paths -> package, type -> bump). Includes the version-vs-tag guard
  so a merged Version PR publishes instead of re-opening a PR.
- scripts/version.mjs: `changeset version` + append a `## Contributors` list to
  each bumped package's CHANGELOG.md (idempotent, pure rewrite unit-tested).
- .github/workflows/release-pr.yml: version-only changesets/action (no publish).
- .github/workflows/publish.yml: guarded OIDC publish (preserves
  `vp pm publish --provenance`); version now comes from `changeset version`.
- Reconcile packages/vinext version (0.0.5 -> 0.0.55) so the guard is coherent.

Unit tests for both scripts pass (35). The release orchestration (Version PR
creation, OIDC publish, gh contributor resolution) can only be validated in a
live CI run.

* refactor(release): collapse to a single changesets workflow

Let changesets/action own as much as possible. Delete the separate publish.yml
(guard job, manual bump, manual OIDC/tag/release/notify steps) and the
release-pr.yml split. One workflow now:

- create-changeset.mjs writes auto changesets to the working tree (its
  version-vs-tag guard yields nothing right after a Version PR merges).
- changesets/action maintains the Version PR and, when no changesets remain,
  publishes via `changeset publish` with OIDC trusted publishing + provenance,
  and creates the git tag + GitHub Release.

Removes the unused release:version script. Contributors list still handled by
scripts/version.mjs as the action's version command.

* refactor(release): convert release scripts to .mts, drop .d.mts files

Replace the .mjs + hand-written .d.mts declaration pairs with real TypeScript
(.mts) source. Node >=24 (the setup default) runs .mts directly via native type
stripping; .mts is unambiguously ESM so it needs no "type": "module" and emits
no MODULE_TYPELESS warning. The cross-import uses an explicit .mts specifier
(Node requires it), permitted in tsc via allowImportingTsExtensions (safe: the
project is noEmit).

- scripts/create-changeset.{mjs,d.mts} -> scripts/create-changeset.mts
- scripts/version.{mjs,d.mts}          -> scripts/version.mts
- tsconfig: allowImportingTsExtensions
- release.yml: run node scripts/*.mts

vp check (format + lint + types) clean; 35/35 unit tests pass.

* refactor(release): trim release scripts (~690→~470 source lines)

Cut comment bloat, remove dead code, tighten without dropping behavior:
- Condense verbose JSDoc/@param blocks to one-line purpose comments; keep the
  load-bearing "why" (correctness-rule header, insertContributors idempotency).
- Delete unused `newestChangelogVersion` (+ its tests) — exported/tested but
  never called.
- Tighten run()/insertContributors/latestTagVersion without behavior change.

Multi-package machinery and the bottom `## Contributors` list are retained per
requirements. vp check clean; 33 unit tests pass.

* feat(release): grouped conventional changelog + filter bot contributors

The default changesets changelog groups by bump level (### Minor Changes) and
renders our changeset summary as a nested bullet dump. Replace it with a real
conventional-commits changelog: version.mts now regroups each release's commits
into ### Features / ### Bug Fixes / ### Performance sections (scope bolded, type
prefix dropped) and rewrites the newest CHANGELOG section, then appends the
## Contributors list with [bot] accounts filtered out.

- create-changeset.mts: extract conventionalParts + collectReleaseCommits +
  releaseRangeStart and export the commit-walk so version.mts reuses it (no
  duplicate git logic).
- version.mts: groupedChangelogBody + rewriteReleaseSection (idempotent: only
  `## <digit>` is a section boundary); dedupeSortLogins drops `[bot]` logins.
- Tests updated for the new pure builders.

vp check clean; 33 unit tests pass. Verified end-to-end with a local dry run.

* refactor(release): simplify per PR review

- discoverPublishablePackages: scan only packages/* (publishable packages don't
  live elsewhere), drop the apps/examples/benchmarks scan.
- Delete isReleaseCommit: the "chore: version packages" release commit is a
  `chore`, already excluded by parseBumpFromSubject returning null. No need to
  hardcode release-message formats.

vp check clean; 31 unit tests pass.

* docs(release): clarify why the version-vs-tag guard exists

Per PR review: the guard was confusing. Document inline that it only suppresses
the post-merge/pre-publish window (version bumped, tag not yet created), which
would otherwise re-open a Version PR instead of publishing. Keeping the
never-on-main design per review decision.

* feat(release): sub-group changelog areas and humanize area names

Within each type section (### Features / ### Bug Fixes / …), areas (commit
scopes) with more than 3 items now get their own `#### <Area>` sub-group with
the scope prefix dropped; smaller areas and scopeless commits fall under
`#### Other`. When no area qualifies the list stays flat. Area names are
humanized (app-router → "App Router", css → "CSS", ppr → "PPR", i18n → "i18n")
in headings and in the bold prefixes.

Also: groupedChangelogBody now renders only known release types (GROUPS) — it
no longer leaks non-release types into an "Other Changes" bucket.

vp check clean; 32 unit tests pass. Verified with a local dry run.

* feat(release): rename changelog Other sub-group to Misc

* feat(release): sub-group areas with 3+ items (was >3)

* fix(release): address PR review (stale refs, contributor API, doc)

- Fix stale references to deleted files: .mjs → .mts and release-pr.yml →
  release.yml in .changeset/README.md and the release.yml header comment.
- resolveContributors: one paginated `gh api compare` call instead of one
  `gh api commits/<sha>` per commit (N round-trips → 1). This also removes
  version.mts's local git() helper, so no more `fatal:` probe noise from it.

No behavior change to the changelog/contributor output (verified via dry run:
same 4 contributors resolved). vp check clean; 32 unit tests pass.

The bot's "no git tags" bootstrap concern does not apply: tags v0.0.10..v0.0.55
exist and CI checks out with fetch-depth: 0, so the guard resolves 0.0.55.

* fix(release): run pinned changeset CLI; drop dead affectedPackages branch

Per second bonk review:
- Invoke the pinned, installed @changesets/cli via `vp exec changeset` instead
  of `vp dlx @changesets/cli` (which fetches a floating latest at run time) — in
  release.yml publish and version.mts. Removes the reproducibility gap vs the
  lockfile-pinned 2.31.0.
- affectedPackages: remove the unreachable `dir === "."` / empty-prefix branch
  now that discovery only returns `packages/*` keys.

vp check clean; 32 unit tests pass.

* fix(release): only emit valid GitHub logins in Contributors; doc fix

Per third bonk review:
- resolveContributors: `.author.login // empty` (was `// .commit.author.name`),
  so commits with an unlinked email contribute no entry instead of a raw git
  display name. dedupeSortLogins now keeps only `[a-zA-Z0-9-]+` shapes, which
  also subsumes the previous `[bot]` filter — no more broken `- @Full Name`
  mentions.
- .changeset/README.md: suggest `vp exec changeset` instead of the floating
  `vp dlx @changesets/cli` for manual changeset authoring.

vp check clean; 32 unit tests pass; dry run resolves the same valid handles.
2026-06-05 13:34:34 +00:00
James Anderson 48e932e615 feat(cache): split CDN and data cache adapters; add Cloudflare edge adapter (#1693)
* feat(cache): split CDN and data cache adapters; add Cloudflare edge adapter

Separate two caching concerns behind distinct adapters:

- Data cache handler (existing CacheHandler): fetch, "use cache", unstable_cache. Canonical get/setDataCacheHandler; get/setCacheHandler kept as deprecated aliases.

- CDN cache adapter (new): page-level ISR serving strategy — readPage/writePage, buildResponseHeaders (header map), ownsBackgroundRevalidation, revalidate. DefaultCdnCacheAdapter delegates storage to the data cache, so default behavior is unchanged.

Page/route ISR now routes through the CDN adapter (isr-cache, app-page-cache); revalidateTag/revalidatePath/updateTag invalidate the data cache and purge the CDN adapter (default no-op).

Add CloudflareCdnCacheAdapter (edge-managed): readPage null / writePage no-op, ownsBackgroundRevalidation false, emits CDN-Cache-Control for SWR + Cache-Control: no-store (no browser storage) + Cache-Tag, and purges via the request-context cache. Auto-selected via a global detector when the request context exposes a cache handle; explicit setCdnCacheAdapter wins. The request-context type stays generic (cache: unknown).

Adds next.config cdnCacheHandler (symmetric with cacheHandler) and updates the worker codegen to setDataCacheHandler.

* address review: drop config plumbing; refine Cloudflare cache headers

- Remove the cdnCacheHandler next.config plumbing entirely (deferred); next-config.ts is back to baseline.

- CloudflareCdnCacheAdapter: emit the edge directive on CDN-Cache-Control as 'public, max-age=…, stale-while-revalidate=…' (max-age, not s-maxage, so the edge caches + SWRs), and set the browser-facing Cache-Control to 'public, max-age=0, must-revalidate' so a browser never serves a stored copy without revalidating against the edge.

* chore: fix formatting (vp check) for cache adapter files

* feat(cache): route App Router route handlers + Pages Router ISR through the CDN adapter

Closes the two parity gaps from review: route-handler and Pages Router ISR responses now emit the CDN adapter's headers (CDN-Cache-Control + Cache-Tag on edge adapters) instead of a hardcoded Cache-Control.

- Add shared applyCdnResponseHeaders() in cache-control.ts; app-page-cache now uses it (drops its local helper).

- Route handlers: applyRouteHandlerRevalidateHeader (fresh) and buildRouteHandlerCachedResponse (HIT/STALE) go through the adapter; routeTags hoisted in execution so the fresh response carries Cache-Tag.

- Pages Router: fresh ISR response emits a path-based Cache-Tag (matching revalidatePath); HIT/STALE served response routes through the adapter.

- CloudflareCdnCacheAdapter: guard so non-cacheable policies (no-store/no-cache/private) are never promoted to CDN-Cache-Control.

Default behavior unchanged (adapter yields a single identical Cache-Control).

* address review: simplify applyCdnResponseHeaders + strip CDN headers from stored route values

- applyCdnResponseHeaders now clears only Cache-Control (the header vinext stamps internally); the adapter's own headers are applied via set() which overrides, so pre-clearing adapter-specific headers was redundant and presumptuous (per review).

- buildAppRouteCacheValue strips cdn-cache-control / cache-tag so CDN policy headers are never baked into a stored route value (re-derived from the adapter on every served response).

- pages-page-data buildPagesCacheResponse now uses applyCdnResponseHeaders (Headers) for consistency with every other call site.

* revert presumptuous CDN header strip in buildAppRouteCacheValue

Hardcoding cdn-cache-control/cache-tag in the store denylist presumes a specific adapter's header names (the same presumption rejected for applyCdnResponseHeaders) and is also unnecessary: CDN policy headers never reach a stored route value — the edge adapter's writePage is a no-op and the default adapter never emits them. Back to the original denylist.

* example(workers-cache): add demo app for route-cache testing (#1695)

* example(workers-cache): add demo app for route-cache testing

Adds the examples/workers-cache-cloudflare demo app from
feat/route-cache-request-context so the route-cache CDN adapter
changes on this branch can be tested.

* ci: trigger PR workflows on any base branch, not just main

Drop the `branches: [main]` filter from the pull_request trigger in
ci.yml, deploy-examples.yml, and preview-release.yml so these workflows
run on PRs against any base branch (e.g. stacked PRs).

* update lockfile

* ci(deploy-examples): add workers-cache-cloudflare to deploy matrix

Pull in the deploy matrix + preview-URL comment entry from
feat/route-cache-request-context so the workers-cache demo app gets
built, deployed, and linked on PR previews.

* fix(cache): auto-select edge CDN adapter from request context, no import needed

The edge-managed CDN cache adapter was only activated when a detector got
registered as a side effect of importing `vinext/cloudflare`. Apps with a
hand-written worker entry (e.g. the workers-cache demo) never imported it, so
ISR silently fell back to the origin-managed default — emitting plain
`Cache-Control` instead of `CDN-Cache-Control` / `Cache-Tag`.

The adapter is platform-agnostic (it only touches the generic request-context
cache surface), so move it into core as `RequestContextCdnCacheAdapter` and
have `getCdnCacheAdapter()` select it directly. Resolution is now:

  1. explicit `setCdnCacheAdapter()`            (always wins)
  2. request-context cache present (`ctx.cache`) -> edge adapter
  3. otherwise                                    -> origin-managed default

Drops the detector-registry indirection and the import/registration
requirement. `CloudflareCdnCacheAdapter` is kept as a re-export alias for
backwards compatibility.

* fix(cache): select Cloudflare edge adapter from resolver, keep it in the cloudflare module

Previous commit moved the adapter into core — revert that. The
CloudflareCdnCacheAdapter stays in cloudflare/cloudflare-cdn-cache.ts; the
core resolver imports it and instantiates it as the built-in default when the
request context exposes a host cache (ctx.cache). Drops the detector-registry
side-effect-import requirement; resolution is explicit -> ctx.cache edge
adapter -> origin-managed default.

* example(workers-cache): show CDN-Cache-Control in the probe headers

* example(workers-cache): rename example app from workers-cache-cloudflare to workers-cache

Rename the example directory and update its package name, wrangler worker
name, the deploy-examples matrix + preview-URL list, and the lockfile.

* fix(cache): give bare stale-while-revalidate an explicit window for the CF edge

The framework emits a value-less `stale-while-revalidate` (Vercel's unbounded
extension). Cloudflare follows RFC 5861 and ignores the bare directive, so the
edge had no stale window — entries hard-expired at max-age and the next request
was a MISS instead of UPDATING. Normalize bare SWR to an explicit 1-year window
in the edge adapter's toEdgeCacheControl so Cloudflare actually serves stale
while revalidating.

* use link component

* fix(cache): let the CDN adapter own the default Cache-Control when none is set

Rendered responses that produced no cacheable policy (e.g. dynamic App Router
pages) were going out with no Cache-Control at all, bypassing the CDN adapter —
so on the edge Cloudflare applied its own default caching heuristic instead of
the adapter's policy.

Add a guard in finalizeAppRscResponse (the single App Router egress, already
run for every page/route-handler/metadata/not-found response) that, when no
Cache-Control is present, routes through the adapter to supply the default: the
edge adapter emits no-store (never accidentally edge-cache an unspecified
response), the default adapter leaves it absent (unchanged). Runs only when the
header is absent, so it never clobbers a policy a renderer already applied
(incl. CDN-Cache-Control). Also stop applyCdnResponseHeaders from stamping an
empty Cache-Control value.

* refactor(cdn-cache): align adapter method names with data cache + gate ctx.cache auto-detection

Address PR #1693 review feedback:

- Align CdnCacheAdapter field naming with the data cache adapter
  (CacheHandler): readPage->get, writePage->set, revalidate->revalidateTag.
  buildResponseHeaders / ownsBackgroundRevalidation stay CDN-specific (no
  CacheHandler equivalent). Updated both implementations and all call sites.

- Gate ctx.cache auto-detection behind VINEXT_CDN_CACHE_AUTO_DETECT (default
  off) so edge-managed page ISR is opt-in until deployment skew protection is
  figured out. Removed the dedicated _edgeAdapter variable; the resolved edge
  adapter is now stored on the single active-adapter global slot that
  setCdnCacheAdapter uses. Enable the flag for the workers-cache demo via
  wrangler.jsonc vars.

* test(cdn-cache): update tests for renamed adapter API + flag-gated auto-detect

Align the CDN adapter unit tests with the refactor:
- get/set/revalidateTag method names (was readPage/writePage/revalidate)
- bare stale-while-revalidate now normalized to an explicit window
- auto-detection is gated behind VINEXT_CDN_CACHE_AUTO_DETECT (no detector /
  no vinext/cloudflare side-effect import)

* chore: reconcile pnpm-lock.yaml after merge

The merge auto-resolved pnpm-lock.yaml into a broken state (missing
@vitejs/plugin-rsc entry), so `vp install` failed at CI setup. Regenerated
with pnpm install --no-frozen-lockfile; frozen install now passes.
2026-06-04 12:09:27 +01:00
Nathan Nguyen dec0ee5369 chore: bump Next.js and React patch versions (#1721)
* chore: bump next and react patches

* update other areas and bump down peer dep

* .

---------

Co-authored-by: James <james@eli.cx>
2026-06-03 09:38:21 +00:00
James Anderson 60dfb11c62 chore(deps): bump @vitejs/plugin-rsc to 0.5.27 (#1709)
* chore(deps): bump `@vitejs/plugin-rsc` to 0.5.27

* test(rsc): cover `export * from` in 'use client' modules (#1352)

Ports the build fixture from #1373 to verify the `@vitejs/plugin-rsc`
0.5.27 bump resolves the upstream "unsupported ExportAllDeclaration"
error. Covers bare `export *`, nested/renamed re-exports,
`export * as Ns from`, and destructured re-exported bindings.

The version bump fixes this natively, so the vinext-side workaround
plugin from #1373 is no longer needed.

Refs #1352
2026-06-01 11:01:28 +01:00
James Anderson d03c5aa045 test(msw): introduce MSW infrastructure and migrate font-google-build (#1444)
* test(msw): introduce MSW infrastructure and migrate font-google-build

Replaces ad-hoc `globalThis.fetch` hijacking in tests with Mock Service
Worker. MSW intercepts both `globalThis.fetch` and `node:http`/`https`,
so it covers fetches issued by in-process Vite servers and fixture pages
without each test having to manage its own stub.

The setup file enables `onUnhandledRequest: 'error'` so any future test
that issues an unmocked external request fails loudly instead of
silently hitting the network. Loopback hostnames (localhost, 127.0.0.1,
::1) are bypassed because the integration project deliberately hits
in-process test servers via fetch.

What this PR contains:
- `tests/_msw/{handlers,server,setup}.ts` — shared infra
- `setupFiles` wired into both vitest projects (unit + integration)
- `msw` + `@mswjs/interceptors` added to the workspace catalog
- `msw` postinstall skipped via `allowBuilds` (it only copies a browser
  service worker; we exclusively use `msw/node`)
- `tests/font-google-build.test.ts` migrated as the proof of pattern

Temporary handlers for `httpbin.org/uuid` and
`example.com/not-cacheable` keep the existing fixture pages
(`revalidate-tag-test`, `dynamic-error-fetch`) working under the new
guard. These are placeholders flagged with `TODO(msw-migration)` — a
follow-up will replace those live URLs with test-local endpoints.

This is PR 1 of 3:
- PR 2 will migrate `tests/fetch-cache.test.ts`
- PR 3 will migrate `tests/font-google.test.ts` and the font block in
  `tests/app-router.test.ts`

* test(msw): restrict MSW to the unit project + use passthrough for loopback

Two fixes to the initial MSW wiring that surfaced when CI ran the full
suite:

1. The integration project is now excluded from MSW. Integration tests
   spin up in-process HTTP servers and fixture dev servers and exercise
   them via `fetch("http://127.0.0.1:<port>/...")`. Even with a loopback
   `passthrough()` handler installed, the @mswjs/interceptors layer
   interferes with that traffic in subtle ways:

   - `tests/prerender.test.ts > errors without writing .rsc when the
     middleware short-circuit fallback RSC request fails` timed out at
     30s — the 5xx response body never reached the caller.
   - `tests/ecosystem.test.ts > nuqs` hung in `beforeAll` waiting for
     its fixture process to come up, because the readiness fetch
     stalled inside the interceptor.

   Integration tests already talk to real local servers, not the
   internet, so the unhandled-request guard buys little there. The
   value of MSW for this repo is mocking external HTTP for unit tests
   of fetch wrappers (font-google, font-google-build, fetch-cache),
   which is preserved.

2. The `onUnhandledRequest` callback in `setup.ts` that tried to
   silently let loopback through wasn't sufficient — MSW's interceptor
   still ran for those requests and could stall them. Replaced with a
   default `http.all(LOOPBACK_URL_PATTERN, () => passthrough())`
   handler in `handlers.ts` and a plain `onUnhandledRequest: "error"`
   in `setup.ts`. Unit tests like the middleware-rewrite proxy in
   `tests/shims.test.ts` and the JSX-in-JS suite need this to keep
   their in-process HTTP servers reachable.

* test(msw): address BigBonk review notes

- Update PR description in the commit body to drop the stale mention of
  TODO(msw-migration) handlers — they were removed when the integration
  project was excluded from MSW; the fixture pages run there.
- Tighten the loopback IPv4 octet regex from `\d+` to `\d{1,3}` so it
  doesn't match absurdly long octet strings. `URL.hostname` normalises
  real addresses, so this is purely a readability fix.

* test(msw): address remaining BigBonk notes from the initial review

- Add `0.0.0.0` to the loopback passthrough pattern so test servers that
  bind to the unspecified address keep working without per-test mocks.
- Add a `TODO(msw-3)` comment in `server.ts` flagging the
  `SetupServerApi` deprecation in MSW 2.14.6 — the `FetchInterceptor`-only
  construction will need to migrate to `defineNetwork`'s interceptor
  config when we bump to MSW 3.

Both raised by BigBonk on the first review of this PR (the second review
focused on the stale description and the loose `\d+` octet bound, which
were addressed in the previous commit).

* test(msw): scope server.use() inside the try block in font-google-build

Minor structural fix from BigBonk's review: moves the `server.use()`
handler registration inside the `try` block so all setup and the
matching `fs.unlink` cleanup share the same try/finally scope.

No behaviour change — `server.use()` is synchronous and infallible, so
this is purely a structural tidy-up.

* test(msw): correct comment in font-google-build to match interceptor config

BigBonk caught that the comment described the default `setupServer()`
behaviour (which uses both `FetchInterceptor` and `ClientRequestInterceptor`),
but this project's `server.ts` deliberately constructs `SetupServerApi`
with only `FetchInterceptor`. Update the comment to reflect that — it
intercepts `globalThis.fetch`, not `node:http`/`node:https`.

No code change, comment only.
2026-05-22 13:03:08 +00:00
yyh 371817a07b chore: upgrade vite plus (#1410) 2026-05-21 14:37:09 +01:00
James Anderson c6e8bda55e feat(app-router): support app/global-not-found.tsx (Next.js 16 parity) (#1301)
When users define app/global-not-found.tsx, route-miss 404s should render
that module standalone — it provides its own <html>/<body> and replaces
the root layout, matching the behavior of Next.js 16's
experimental.globalNotFound feature.

  https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-not-found
  https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/app-render.tsx#L495-L520

Implementation:

- Discover app/global-not-found.{tsx,ts,jsx,js} alongside the existing
  global-error scan (src/index.ts).
- Thread the path through generateRscEntry via AppRouterConfig and
  buildAppRscManifestCode so the generated entry imports it and binds
  it as globalNotFoundModule.
- Pass the module into createAppFallbackRenderer; renderHttpAccessFallback
  switches to the global-not-found document when statusCode === 404, the
  route is null (route-miss), and no boundary override was supplied.
- Add a skipLayoutWrapping flag to renderAppPageHttpAccessFallback so
  the standalone document isn't wrapped in the root layout, and so the
  RSC payload omits layout-id entries that would otherwise refer to a
  layout tree the markup doesn't contain.

Page-triggered notFound() calls keep the existing path: they still
resolve through the regular not-found.tsx boundary inside layouts —
global-not-found.tsx only takes over for route-miss 404s.

Tests:

- tests/app-fallback-renderer.test.ts adds four unit tests covering
  the new branch (route-miss with global-not-found, page-call with
  global-not-found, absent global-not-found, 403/401 unaffected).
- tests/entry-templates.test.ts adds four codegen assertions covering
  the manifest import and the generated-entry wiring in both directions
  (present and absent).
- tests/nextjs-compat/global-not-found.test.ts ports the basic Next.js
  fixture as an integration test against a dev server, asserting that
  the route-miss 404 renders global-not-found's <html> standalone with
  exactly one <html>/<body>, and that page-call notFound() does not
  use the global-not-found document.
2026-05-18 11:48:04 +01:00
Ghxst 85f2c10fab chore: update @vitejs/plugin-rsc to 0.5.26 (#1293) 2026-05-18 08:28:33 +00:00
James Anderson 1c0bdacda6 feat(apps/web): /compatibility page backed by D1 + deploy-suite ingest (#1239)
* feat(apps/web): /compatibility page backed by D1 + deploy-suite ingest

Adds a /compatibility page to apps/web that visualises Next.js
compatibility over time, populated by the nightly deploy-suite workflow.

  - D1 binding (`vinext-metrics`) with Drizzle schema and migrations.
    Tables are namespaced (`compat_runs`, `compat_file_results`) so
    future metric kinds can live alongside.
  - POST /api/compatibility ingest endpoint. Authenticates with a
    `COMPAT_INGEST_SECRET` worker secret and upserts by
    (kind, runKey).
  - GitHub-style contribution grid (one dot per test file) and a
    pass-rate-over-time line chart. Both are SVG, no chart libs.
    Pass rate excludes Next.js-skipped tests from the denominator.
  - Shared header/footer extracted into app/_components and rendered
    by the root layout. Nav uses next/link for soft client nav.
  - Workflow change: on top of the existing per-test pass/fail/skip
    aggregation, the report job now POSTs results to the ingest
    endpoint. Guarded to only run when (a) filter=all and
    (b) targeting main, so partial runs and branch spot-checks don't
    pollute the historical record.

The first nightly run after merge will seed the production D1.

* address review + add ISR with KV cache

Review fixes (apps/web/app/api/compatibility/route.ts):
  - constant-time secret comparison via SHA-256 + bytewise XOR
  - single-statement atomic upsert with onConflictDoUpdate (no
    SELECT->INSERT race for concurrent retried runs)
  - top-level try/catch with structured error body so workflow logs
    show why ingest failed instead of a generic 500
  - MAX_FILES=2000 cap in validation as defensive bound

Page perf (apps/web/app/compatibility/page.tsx):
  - latest-run and trend D1 queries run in parallel via Promise.all

ISR + KV:
  - new vinext-web-cache KV namespace bound as VINEXT_CACHE
  - worker installs KVCacheHandler once per isolate
  - both pages opt into ISR with revalidate = 300; compat page drops
    force-dynamic

* address bonk re-review

  - Drop the ?kind= query param. Only one kind exists today; gating it
    behind an unused param invited ISR cache pollution. Hardcoded to
    "deploy" with a comment pointing future kinds at a dedicated route.

  - Wrap the DELETE + chunked INSERTs in a single `db.batch()` so a
    crash mid-write can no longer leave a run with zero file results.
    D1 executes batch statements inside one transaction.

  - Resolve workflow expressions once via step-level `env:` and read
    them as `process.env.*` from the github-script body. Removes the
    fragile splicing of `${{ inputs.next-ref || 'v16.2.6' }}` into JS
    string literals (the nested single quotes were the worst offender).
2026-05-15 15:10:36 +00:00
James Anderson d9906b94d6 chore: add basic vinext site (#1236)
* chore: scaffold new next app with vinext

* basic app

* add to deploy workflow

* fmt
2026-05-15 13:41:10 +00:00