Commit Graph

1503 Commits

Author SHA1 Message Date
Nathan Nguyen c28098694a test(pages-router): wait for hydration after middleware redirect (#1789)
The middleware redirect E2E test placed a reload marker immediately after the redirected page heading appeared. That proved the document rendered, but not that the Pages Router Link handler had hydrated before the click.

Wait for the shared Pages Router hydration marker before clicking the redirected page's home link so the marker assertion tests client-side navigation instead of racing anchor fallback behavior.
2026-06-06 13:51:13 +01:00
Divanshu Chauhan (divkix) 330de786c5 test(interception): fixtures and E2E specs for 4 dynamic interception suites (#1364 Part B) (#1785)
* test(interception): add fixtures and E2E specs for 4 dynamic interception suites

Adds fixture pages and Playwright E2E specs for the 4 Next.js interception
route test suites tracked in issue #1364 (Part B):

- interception-dynamic-segment: @modal/(.)[username]/[id] and
  @modal/(.)test-nested/@sidebar — 6 tests (all pass)
- interception-dynamic-single-segment: @modal/(.)groups/[id]/new
  from a multi-segment source route — 5 tests
- interception-dynamic-segment-middleware: locale-rewrite middleware +
  (.)[username]/p/[id] interception — 4 tests
- parallel-routes-and-interception-from-root: (...)[locale]/intercepted
  from the app root — 3 tests

Suite 3 middleware rewrite is tightly scoped to /interception-mw/* in
the shared app-basic middleware.ts to avoid interfering with other tests.

Tests for suites 2/3/4 that depend on the computeInterceptTarget fix
(fix/interception-compute-target, Part A) will fail until that branch
is merged, which is expected.

* test(interception): mark 7 known-failing tests with test.fail() pending Part C

The 7 tests across suites 2-4 exercise behavior that Part C (#1364) will
fix. Marking them test.fail() keeps CI green while preserving the specs
as executable documentation of the expected behavior.
2026-06-06 13:36:42 +01:00
Jerry Zhao a75ae51ef6 fix(image-imports): normalize meta specifier separators on Windows (#1791) 2026-06-06 13:36:09 +01:00
Nathan Nguyen d89f611277 chore(test): package workspace deps in deploy harness (#1786)
The Next.js deploy-suite harness installs vinext into isolated temp apps outside the vinext workspace. That breaks when the packaged vinext manifest still contains workspace dependencies, because pnpm cannot resolve workspace:* specs without the monorepo package graph.

Copy the local @vinext/cloudflare build output into a sibling throwaway package and rewrite workspace dependencies in the generated vinext manifest to point at that package. This preserves the package boundary while keeping deploy-suite fixture installs self-contained.
2026-06-06 09:28:58 +01:00
James Anderson 726452ceb1 fix(font): resolve next/font/local paths inside node_modules packages (#1780)
* fix(font): resolve next/font/local paths inside node_modules packages

The vinext:local-fonts transform excluded node_modules files, so npm
packages that wrap next/font/local (e.g. geist) never had their relative
font src paths promoted to Vite asset imports. The raw relative path
(e.g. ./fonts/geist-mono/GeistMono-Variable.woff2) leaked into the
runtime @font-face src and 404'd at request time.

Remove the node_modules exclusion from the filter and defensive guard so
package files are transformed like user source, matching Next.js's font
loader. The code filter plus the required default-import check keep the
transform from touching unrelated modules, and the font-local shim guard
still skips vinext's own shim when installed as a dependency.

* fix(font): tighten local-font shim guard to a precise shimsDir prefix

Now that node_modules is no longer excluded, the shim guard runs against
arbitrary third-party package paths. Replace the loose
`id.includes("font-local")` substring check with a precise
`id.startsWith(shimsDir)` prefix check, mirroring createGoogleFontsPlugin,
so legitimate packages whose path contains "font-local" (e.g.
font-local-loader) are still transformed instead of silently skipped.

Pass _shimsDir into createLocalFontsPlugin and add a regression test for a
third-party font-local-named package that must still be transformed.
2026-06-06 00:59:36 +01:00
James Anderson 77cc46bc43 fix(check): prevent regex stack overflow / hang on very large files (#1776)
* fix(check): prevent regex stack overflow / hang on very large files

The check command had two file-content regexes that fail badly on very
large inputs:

- cjsGlobalScanRegex used a string-body sub-pattern (?:[^"\\]|\\.)*,
  a star over an alternation group that V8 cannot compile into a tight
  loop. It pushes one backtrack frame per character and overflows the
  regex stack ("Maximum call stack size exceeded") on large files such
  as a multi-MB minified bundle or a long/unterminated string literal.
  Replaced with hasFreeCjsGlobal(), a single-pass O(n)-time,
  O(template-nesting)-stack scanner that preserves existing behavior.

- stringPluginRegex (PostCSS string-form plugin detection) used two lazy
  [\s\S]*? quantifiers around a capture group, causing quadratic
  catastrophic backtracking on a config with no closing ]. Replaced with
  a linear anchored regex matching the same configs.

Adds regression tests covering multi-MB unterminated/escape-heavy inputs
and the PostCSS backtracking case, plus behavior tests for the scanner.

* style(check): apply formatter fixes

* fix(check): make __dirname scanner regex-literal aware

Addresses Bonk review feedback. The single-pass scanner had no concept
of regex literals, so an unpaired quote or backtick inside one (e.g.
/'/ or /`/) hijacked the string/template state and could swallow real
code after it, hiding a later __dirname (a false negative the old regex
did not have).

The scanner now tracks whether a / is in expression position (regex
literal) vs after a value (division), and skips regex-literal bodies
honoring escapes and [...] char classes. Also:

- replace magic-number offsets (i+9/i+10) with full-identifier matching
  against a CJS_GLOBALS set
- correct the string-literal comment re: line continuations

Adds tests covering regex literals hiding/​not-hiding __dirname, division
not being mistaken for a regex, and __dirname inside a regex literal.

* fix(check): treat } and postfix ++/-- as value position

Addresses Bonk re-review. After } or a postfix ++/--, the scanner set
prevType to "op", so a following division / was misread as a regex
literal and swallowed the rest of the line, hiding a same-line __dirname
(e.g. "i++ / 2; const d = __dirname;").

Fix: treat } as value-producing (the common { … } / x object-literal
division), and consume ++/-- without changing expression position
(postfix keeps value, prefix keeps op). Biasing ambiguous cases toward
division is safe because a misread division never consumes a following
identifier, whereas a misread regex swallows the line.

Adds tests for division after }/postfix ++/-- and confirms a real regex
after a prefix ++ / keyword still parses.

* docs(check): pin value-position regex limitation; clarify PostCSS comment

Addresses Bonk round-3 review (both notes non-blocking).

- Document the residual division-vs-regex limitation on hasFreeCjsGlobal:
  a value-position regex literal after a block } (e.g.
  "function f(){} /'/.test(x)") is read as division, so a stray quote in
  its body can mask a same-line __dirname. Fully resolving this needs a
  parser; accepted for an advisory check. Added tests pinning the gap and
  confirming the multi-line variant still works.
- Soften the PostCSS comment: the anchored regex matches the same
  string-form configs but intentionally no longer matches the
  require()-form (a false positive in the old regex).
2026-06-06 00:14:11 +01:00
Divanshu Chauhan (divkix) cf04ca63e3 fix(form): DISALLOWED_FORM_PROPS stripping, file input warning, viewport prefetch, pages-router E2E (#1752)
* fix(form): implement DISALLOWED_FORM_PROPS, file input warning, viewport prefetch, and pages-router E2E

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

Closes #1355

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: James <james@eli.cx>
2026-06-06 00:04:08 +01:00
Divanshu Chauhan (divkix) 3873545104 fix(middleware-runtime): normalize trailing slash on plain-URL redirect locations (#1750)
* fix(middleware-runtime): normalize trailing slash on plain-URL redirect locations

When middleware does NextResponse.redirect(new URL('/x', req.url)), the
Location header bypasses NextURL._applyTrailingSlash() and was emitted
verbatim even when trailingSlash config required normalization.

After relativizeLocation(), apply normalizeTrailingSlashPathname() to the
pathname of same-origin redirect Locations when trailingSlash is configured.
File-extension paths (e.g. /file.css) are correctly exempted by the existing
FILE_LIKE_PATHNAME_RE guard inside normalizeTrailingSlashPathname.

Adds 3 new tests covering the plain-URL redirect gap (trailingSlash: true,
trailingSlash: false, file-extension exemption).

Fixes #1332

* fix(tests): use concise arrow functions to satisfy arrow-body-style lint rule

* ci: retrigger CI

* ci: retrigger CI

* fix(middleware-runtime): use plain add/strip for redirect Location trailing slash

Replaces normalizeTrailingSlashPathname (the filesystem-routing normalizer,
which exempts /api, file-extension paths, and /.well-known) with the same
plain add/strip rule used by NextURL._applyTrailingSlash and Next.js's
formatNextPathnameInfo: only the root path is exempt.

This makes plain-new-URL() redirects consistent with NextURL-based redirects
and matches Next.js behavior, where a trailingSlash: true redirect to /file.css
produces /file.css/ rather than keeping the path unchanged.
2026-06-05 23:27:43 +01:00
Divanshu Chauhan (divkix) b25be5e167 fix(routing): correct (.) interception target for nested slot subdirectories (#1751)
* fix(routing): correct (.) interception target for nested slot subdirectories

When the (.) interception marker lives inside a subdirectory of the @slot
dir (e.g. app/@modal/sub/(.)target/[id]/page.tsx), baseParts was computed
from routeDir (the slot owner) instead of the directory containing the
marker. This caused visible subdirectory segments to be dropped, producing
/target/:id instead of /sub/target/:id.

Fix: derive baseParts from path.dirname(interceptRoot) relative to appDir,
stripping invisible segments via convertSegmentsToRouteParts — the same
pattern used by computeInterceptSourceMatchPattern and the (..) / (...)
convention branches.

* ci: retrigger to clear flaky test failures

* fix(routing): drop double-conversion of (.) interception baseParts to preserve dynamic ancestor params
2026-06-05 23:26:18 +01:00
James Anderson e40870e114 fix(og): lazy-load @vercel/og to keep it out of the main worker entry (#1774)
* fix(og): lazy-load @vercel/og to code-split it out of the worker entry

The next/og shim statically imported @vercel/og, so a top-level
`import { ImageResponse } from "next/og"` inlined ~800 KB of satori + resvg +
wasm/fonts into the always-loaded server entry. Import it via dynamic import()
inside the async stream callback so it is always a separate chunk.

app-router-cloudflare dist/server/index.js: ~1.67 MB -> ~875 KB.

* chore: drop changeset

* fix(og): copy resvg.wasm from split chunk + drop example workaround

Address review feedback on the lazy @vercel/og change:

- vinext:og-assets scanned only index.js for the resvg.wasm reference to
  decide whether to copy the Node.js disk-read fallback asset. Now that
  @vercel/og is code-split into its own chunk, that reference is no longer in
  index.js, so the copy was skipped (breaking the Node target's OG fallback).
  Scan all emitted chunks via the writeBundle bundle arg instead.
- app-router-playground used a manual `await import("next/og")` workaround
  that is no longer needed; switch it to the idiomatic static import.
2026-06-05 23:23:12 +01:00
James Anderson 79d4c28a00 docs(agents): instruct agents never to create changesets manually (#1775)
Changesets are auto-generated from Conventional Commits in CI, so agents
should write a proper commit message instead of hand-authoring
.changeset/*.md or running pnpm changeset.
2026-06-05 22:10:18 +00:00
Divanshu Chauhan (divkix) 5a1ea0d967 fix(config): avoid duplicate CJS global shims (#1771)
* fix(config): avoid duplicate CJS global shims

* fix(config): guard require injection too

The createRequire ESM polyfill (const require = createRequire(import.meta.url))
commonly appears alongside the __dirname/__filename polyfills and hits the same
duplicate-const Rolldown parse error. Skip the createRequire import and the
const require line when the user already declares require. Add a regression
test covering the scenario.

---------

Co-authored-by: James <james@eli.cx>
2026-06-05 21:21:56 +00:00
August Cayzer db353fca92 fix(deploy): respect --env flag when invoking build (#1694)
* fix(deploy): respect --env flag when invoking build

- Add withCloudflareEnv helper to set CLOUDFLARE_ENV around builder.buildApp
- Thread --env through runBuild in deploy command
- Add --env parsing to parseArgs so vinext build --env also works
- Document --env in vinext build --help
- 12 new unit tests across deploy and cli-args

Fixes #1210.

* fix(deploy): scope --env fix to deploy command only

Drop the --env addition for vinext build (cli-args.ts, cli.ts, and the
cli-args build tests). The deploy-side fix in deploy.ts remains.

vinext build should remain platform-neutral; Cloudflare-specific concerns
belong in the deploy path.
2026-06-05 22:08:14 +01:00
Nathan Nguyen 16cbac3053 fix(build): emit Next client runtime manifests (#1735)
* fix(build): emit Next client runtime manifests

Next.js serves /_next/static/<buildId>/_buildManifest.js as a build asset, including when basePath is folded into assetPrefix. vinext already routed prefixed static assets correctly, but the production client build never wrote the low-priority runtime manifest files, so valid Next.js probes 404ed under basePath.

Emit _buildManifest.js and _ssgManifest.js during the client build under the resolved assets directory and build ID. Keep the manifest content scoped to the client-visible rewrite shape vinext supports and add regression coverage for the upstream basePath test case.

* fix(build): preserve all rewrite fields except external destination in client manifest

* fix(build): restore Next.js rewrite manifest shape

Tighten ClientRuntimeRewrite to { source, destination?, has? } so the
emitted client manifest matches Next.js's normalizeRewrite output
exactly (drops missing, locale, basePath). Drop the path.basename
clientDir fallback in the runtime manifests plugin so client-env
detection matches every other hook in the file. Extend the basePath
end-to-end test to also fetch _ssgManifest.js.
2026-06-05 21:50:01 +01:00
Nathan Nguyen 494e8d2d5b fix(pages): render custom errors for notFound results (#1737)
* fix(pages): render custom errors for notFound results

Pages getStaticProps and getServerSideProps notFound HTML results returned the built-in 404 response directly. That skipped the configured pages/404 or pages/_error render path, so _error.getInitialProps never received ctx.req.url or ctx.asPath for the original request.

The page-data resolver now returns a typed notFound signal for HTML requests while preserving JSON-shaped 404 data responses. The generated Pages entry rerenders the configured not-found route with the original asPath, and Pages getInitialProps is loaded through a shared typed helper for prod and dev parity.

* fix(pages): address PR review feedback on getInitialProps binding, err context, and response-sent handling

- Call getInitialProps with component as this receiver
- Thread caught errors through to _error.getInitialProps(ctx.err)
- Check headersSent || writableEnded in dev server after getInitialProps
- Add tests: gSSP notFound, explicit 404, data JSON 404, err on throw, this binding

* refactor(pages): use isResponseSent helper in resolvePagesPageData for sent-check consistency

The prod block in resolvePagesPageData checked only res.headersSent, while
the helper's isResponseSent and the dev server check both headersSent and
writableEnded. The prod PagesReqResResponse only declares headersSent (its
getter is backed by the ended flag), so this is functionally correct in
prod, but the asymmetry is hard to read next to the helper and a future
change to the prod response shape would silently break sent-detection.

Export isResponseSent from pages-get-initial-props and use it at both
gSSP and gIP call sites so the prod resolver reuses the same
sent-detection logic as the helper.

Also adds tests for the writableEnded-only path and a focused unit test
for isResponseSent covering both prod-shaped and node-shaped res mocks.

* perf(pages): gate getInitialProps req/res allocation behind presence check

The getInitialProps prod block in resolvePagesPageData unconditionally
called createGsspReqRes() for every static page without gSP/gSSP, even
when the page had no getInitialProps either. This allocated a req/res
pair and an unresolved responsePromise that was immediately discarded.

Export hasPagesGetInitialProps from pages-get-initial-props.ts and gate
the prod block on it, keeping the hot path for plain static pages
allocation-free.
2026-06-05 21:48:31 +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
Jerry Zhao ab4f2dc5f3 perf(utils): skip path separator replace on POSIX (#1766) 2026-06-05 16:34:50 +01:00
Nathan Nguyen e372ff336a test: rebalance app router integration shards (#1765)
Vitest integration CI shards by test file, so the 340-test App Router integration file becomes one oversized scheduling unit. That makes the slowest shard depend on one monolithic file even after adding native shards.

Split the App Router integration coverage into focused files and increase the integration matrix to four native Vitest shards. This keeps fileParallelism disabled for shared-fixture safety while giving CI smaller scheduling units to distribute.

Validation: vp check tests/app-router-*.test.ts vite.config.ts .github/workflows/ci.yml; vp test run --project integration tests/app-router-client-preloading.test.ts tests/app-router-dev-server.test.ts tests/app-router-external-rewrite.test.ts tests/app-router-font-google-prod.test.ts tests/app-router-isr-codegen.test.ts tests/app-router-malformed-url.test.ts tests/app-router-metadata-routes.test.ts tests/app-router-middleware-next-request.test.ts tests/app-router-next-config-codegen.test.ts tests/app-router-next-config-dev.test.ts tests/app-router-origin-check.test.ts tests/app-router-production-build.test.ts tests/app-router-production-server.test.ts tests/app-router-rsc-flight-hint.test.ts tests/app-router-rsc-plugin.test.ts tests/app-router-static-export.test.ts tests/app-router-worker-entry.test.ts
2026-06-05 15:48:54 +01:00
James Anderson 584661f37f chore(release): stop changesets force-majoring @vinext/cloudflare on every vinext bump (#1764)
Changesets' shouldBumpMajor rule force-bumps a dependent to MAJOR when a
peerDependency receives any non-patch bump. Combined with workspace:* being
resolved as an exact version pin (always out of range on any vinext change),
@vinext/cloudflare was majored on essentially every release (e.g. 0.0.1 ->
1.0.0 in PR #1759), even with zero cloudflare changes.

Fix is two parts:
- .changeset/config.json: enable onlyUpdatePeerDependentsWhenOutOfRange so
  peer-dependent bumps respect the declared range instead of force-majoring.
- packages/cloudflare/package.json: widen the published peer range off the
  exact-pin (workspace:* -> >=0.0.0). devDependencies.vinext: workspace:*
  is kept so local linking is unaffected.
2026-06-05 15:37:35 +01:00
James Anderson 8f5e1a3c87 chore(release): scope changelog contributors to each package's own commits (#1763) 2026-06-05 14:37:28 +00:00
Stephen Zhou 5a0fd69b37 chore: disable package sourcemaps (#1762) 2026-06-05 15:33:21 +01:00
James Anderson 639a8922b8 chore(release): resolve changelog range from latest tag so new packages get entries (#1761)
version.mts computed the changelog/contributor commit range from a tag ref
derived from the package's pre-bump package.json version (`tagRefFor(name,
before[name])`). For a brand-new package that has never been released there is
no matching tag, so this produced a non-existent ref (e.g. `v0.0.1`);
`git log v0.0.1..HEAD` throws, the catch returns [], and the package gets an
empty `## <version>` changelog section with zero entries and zero contributors.

This happened for real in #1759: the new @vinext/cloudflare package got an
empty `## 1.0.0` heading while vinext (with a real tag) got a full changelog.

Reuse `releaseRangeStart(name)` from create-changeset.mts, which resolves the
range from the latest *existing* tag (scoped `<name>@<version>` or legacy global
`v<version>`) and falls back to the first commit when none exists. This makes
version.mts consistent with the changeset generator and supports new/untagged
packages. The now-unused `tagRefFor` import is dropped.

Extracts the pure tag-selection logic into `latestTagVersionFromTags` and adds
focused unit tests for the scoped/global/new-package/no-tag cases.
2026-06-05 14:18:49 +00:00
Nathan Nguyen 5ce51a1269 refactor(plugins): share small AST helper utilities (#1754)
* refactor(plugins): share small AST helper utilities

AST-heavy plugin transforms currently duplicate loose node guards, child traversal, binding-pattern extraction, and Identifier or Literal name extraction. That makes PR 1736 larger than it needs to be and increases the chance that each transform grows its own slightly different syntax helpers.

Extract the small parser-shape helpers into a local plugin utility module and migrate import-meta-url plus optimize-imports to use it. Scope-aware transform logic remains local to each plugin, so this preserves runtime behaviour while creating a narrow reuse point for the dynamic-request transform.

* chore: retrigger CI
2026-06-05 15:16:48 +01:00
Stephen Zhou 06f37e9c1b fix: client HMR dev overlay recovery (#1758)
* Fix client HMR dev overlay recovery

* Stabilize dev overlay build error recovery test

* Harden dev overlay recovery handling
2026-06-05 15:11:15 +01:00
James Anderson d98fc06aa5 chore(release): keep changelog Contributors in the GitHub Release (h3) (#1760)
changesets/action builds the GitHub Release body via getChangelogEntry(), which
slices from the `## <version>` heading to the next *same-depth* (`##`) heading.
A `## Contributors` (h2) was acting as that boundary and getting excluded from
the release notes — present in CHANGELOG.md but dropped from the GitHub Release.
Demote to `### Contributors` (h3) so it stays inside the extracted body. The
in-file boundary logic (/^##\s+\d/) is unaffected.

Follow-up to #1753 (merged before this fix landed).
2026-06-05 13:57:37 +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
Nathan Nguyen 0ecd05dfe1 test(app-router): remove live network from ISR revalidation fixtures (#1756)
* test(app-router): remove live network from ISR revalidation fixtures

The App Router ISR revalidation fixtures fetch httpbin.org only to collect next.tags before rendering request ids. That makes CI depend on external network latency even though the tests never assert the remote response body.

Use a deterministic data URL for the tagged fetch in the parent and nested fixtures so tag collection still exercises the ISR invalidation path without waiting on an external service.

* test: remove incidental external fetches

A layout observation unit test and a dynamic-error fixture used external URLs even though their contracts only need fetch observation or no-store policy detection. Those URLs can start unnecessary network work when the test behavior does not depend on a remote response.

Use deterministic data URLs so the same fetch branches and cache options are exercised without relying on external network availability.
2026-06-05 14:38:01 +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
Nathan Nguyen f3f120eb5b fix(server): define CJS path globals in bundled modules (#1740)
* fix(server): define CJS path globals in bundled modules

Server-side user modules that referenced __filename or __dirname ran inside vinext's ESM bundle without CommonJS path globals. Proxy files that used __filename, including Next.js's proxy-nfc-traced fixture, threw before redirecting or continuing.

The missing compatibility boundary was the source-identity transform: it preserved import.meta.url but did not provide Next-compatible CJS path globals for server modules. The transform now rewrites free server-side __filename and __dirname reads to canonical source paths while preserving local bindings, object keys, and client modules.

Regression coverage ports the upstream proxy-nfc-traced scenario at the transform boundary and through a production App Router build.

* fix(server): use binding injection instead of identifier rewriting for __filename/__dirname

Replaces the custom free-identifier AST walker with a simpler top-level
var injection approach. This fixes two review blockers:

1. Var hoisting bug: the old walker discovered nested var declarations
   only after entering their block, so reads before the block could be
   incorrectly rewritten even though they were shadowed by a hoisted local
   var.

2. TS type-space awareness: by injecting module-level bindings instead of
   rewriting individual identifiers, we no longer need to distinguish
   type-space from value-space usage. JavaScript scope rules naturally
   handle params, nested locals, object shorthand, assignment targets,
   etc.

We only need a lightweight top-level binding scan to avoid duplicate
 declarations with let/const/class/function/value imports. All other
 shadowing is handled by JS semantics automatically.

Per-review feedback on PR #1740.

* fix(server): detect destructuring patterns and for-var declarations in top-level binding scan

Adds hasBindingInPattern() to recurse through ObjectPattern, ArrayPattern, RestElement, and AssignmentPattern so destructuring declarations like const { __filename } = source correctly block injection.

Also handles top-level ForStatement/ForInStatement/ForOfStatement with var declarations, which create top-level bindings that must block injection. let/const in for-loop init are block-scoped and correctly ignored.

* fix(server): insert CJS globals after directive prologue

Changes output.prepend to output.appendLeft(findDirectivePrologueEnd(ast)) so that use server, use client, and use strict directives remain the first statement in the program body.

Adds findDirectivePrologueEnd() helper that scans leading ExpressionStatement(Literal<string>) nodes and returns the end position of the last directive.

* refactor(server): condense CJS path-global injection (variant C)

Keep PR #1740's baked-literal value strategy (var __filename/__dirname
computed in the plugin) but adopt the condensed binding-collision
helpers. Drops ~31% of the added lines vs the original PR with
byte-identical generated output and no new runtime surface.

* refactor(server): unify test path with plugin transform; drop dead TS branches

- rewriteServerCjsGlobals now delegates to rewriteCanonicalSourceIdentity
  (the same function the plugin runs), removing the parallel
  rewriteCanonicalServerCjsGlobals implementation so the unit tests
  exercise the production code path.
- Drop the TSEnumDeclaration/TSModuleDeclaration/TSImportEqualsDeclaration
  branches and importKind/declare guards from declaresBinding: Vite's
  parseAst (rolldown/oxc) rejects TS syntax and the plugin runs after TS
  is stripped, so those nodes/fields are unreachable.

* fix(server): inject CJS globals only on genuine read references

Replace the substring `code.includes(name)` injection gate with a
syntactic read-reference check (hasReadReference): an Identifier in
value position, excluding non-computed member properties
(obj.__filename), non-computed object/class keys, and lookalikes
(__filenameFoo). Object shorthand, computed keys/members, default
values, and assignment targets still count as reads.

Collision safety is unchanged (hasTopLevelBinding still blocks a
`var` that would redeclare a top-level let/const/class/import). This
fixes baking an unused `var __filename = "/abs/path"` into modules
that only mention the name in a member access, key, comment, or a
lookalike identifier. Adds 6 false-positive/read tests.

* fix(server): add typeof guards for switch-exhaustiveness-check; auto-format

* test(server): rename stale assignment/update test to match assertions

* refactor(server): consolidate CJS-global analysis into a single pass

Replaces the two name-parameterised predicates (hasReadReference and
hasTopLevelBinding) with a single analyzeServerCjsGlobals() function that
walks the AST once and returns { reads, topLevelBindings }.

injectServerCjsGlobals() now reads from the analysis instead of asking
the same questions in two separate functions. The injection rule stays
the same:

  inject iff reads.has(name) and not topLevelBindings.has(name)

Behaviour is unchanged: all 30 import-meta-url tests and the proxy
build integration test pass.

* fix(server): handle module-scoped var bindings

* refactor(server): simplify module-scoped var binding scan

* fix(server): inject CJS globals after a shebang line

findDirectivePrologueEnd returned 0 for a module with no directive
prologue, so appendLeft(0, ...) inserted the var before a leading
`#!` shebang (which oxc stores in ast.hashbang, outside ast.body),
producing output like `\nvar __filename=...;#!/usr/bin/env node` that
fails to parse. Start the injection floor after the hashbang.

* fix(server): don't count import/export specifier names as CJS-global reads

recordReads' generic walk visited ImportSpecifier.imported,
ExportSpecifier.exported, and ExportAllDeclaration.exported identifiers,
so `export * as __filename from`, `export { foo as __filename }`, and
`import { __filename as foo }` each recorded __filename as a read with no
module binding, injecting a spurious unused `var __filename = "<path>"`.
Add explicit cases: imports read nothing; export-namespace aliases read
nothing; named-export specifiers reference only `local` (and only without
a re-export source).

* refactor(server): route test-only rewriteServerCjsGlobals through the production gate

The test-only entry re-implemented a subset of eligibility (within-root +
build-output exclusion) but omitted the node_modules and script-extension
guards that the plugin applies via transformableModuleCanonicalId, so tests
could pass while the real plugin path behaved differently. Delegate to
transformableModuleCanonicalId so tests exercise the same boundary.

* refactor(server): delete duplicate rewriteCanonicalImportMetaUrl

rewriteCanonicalImportMetaUrl re-implemented the import.meta.url branch
already in rewriteCanonicalSourceIdentity (parse, collect ranges, overwrite,
generateMap) and was reached only by the test-only rewriteImportMetaUrl, so
the test path could drift from the production path. Delegate
rewriteImportMetaUrl to rewriteCanonicalSourceIdentity and drop the duplicate.

* perf(server): skip import.meta.url AST walk when the token is absent

The CJS-global gate matches __filename/__dirname anywhere, admitting many
modules with no import.meta.url. collectImportMetaUrlRanges walked the full
AST for all of them; guard it behind the cheap mayContainImportMetaUrl
substring check (a range can only exist when the substring is present).

* style(server): wrap rewriteImportMetaUrl delegation to satisfy formatter

The single-line delegation introduced in ad0000a5 was 104 chars, over the
100-col print width, failing `vp check`. Wrap the call.

---------

Co-authored-by: James <james@eli.cx>
2026-06-05 13:22:30 +01:00
Stephen Zhou fb233b0a34 feat: improve dev error overlay source frames (#1746)
* Improve dev overlay stack source mapping

* Polish dev error overlay stack display

* Add dev overlay code frames

* Align dev overlay message spacing

* Add dev overlay error copy action

* Fix dev overlay knip exports

* Polish dev overlay layout

* Polish dev error overlay spacing

* Fix dev overlay coverage and source frame metadata

* Fix dev overlay recovery for RSC HMR errors

* Handle Vite HMR build errors in dev overlay

* Fix knip

* Use Shadow DOM for dev error overlay
2026-06-05 12:49:11 +01:00
Nathan Nguyen cb27f3a682 fix(link): full-prefetch dynamic routes without loading shells (#1734)
* fix(link): full-prefetch dynamic routes without loading shells

Automatic App Router prefetch skipped dynamic routes unless they exposed a loading boundary. That diverged from Next.js segment-cache behavior for client-param routes, where making the link visible must initiate a prefetch and a click to the exact URL can reuse the prefetched payload without another request.

The bad assumption was that every dynamic route needs shell-only learning or no prefetch. Vinext keys the prefetched RSC snapshot by the exact target URL, so dynamic routes without a loading shell can safely seed navigation cache while dynamic routes with loading.tsx keep the existing shell-only path.

Port the focused Next.js client-params regression into the Link decision and scheduling tests.

* fix(link): skip shell-first inlining for client-param prefetches

Automatic dynamic routes without loading shells now seed the navigation cache, but the prefetch-inlining branch still treated those cacheable requests like static route payloads and issued a loading-shell request before the full fetch.

That shell request has no loading boundary to learn from for client-param routes, so it is a wasted roundtrip. Split cache eligibility from shell-first transport eligibility, document the route-shape heuristic, and derive the test-only full-prefetch helper from the same decision.

Add coverage that dynamic no-loading routes remain single-fetch under prefetch inlining.
2026-06-05 11:25:17 +01:00
James Anderson f0f6aa72e0 feat(cache): configure cache adapters from vite plugin config (#1733)
* feat(cache): configure cache adapters from vite plugin config

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

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

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

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

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

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

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

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

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

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

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

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

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

Addresses review feedback:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* review: address PR #1733 feedback

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

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

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

* tidy

* .

* .

* .

* .

* .

* Move apps/web cache to plugin config

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

---------

Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
2026-06-04 23:30:33 +00:00
Nathan Nguyen b8cecf8b46 fix(skip): centralize final static-layout skip rejection (#1722)
* feat(skip): reject runtime-unsafe layouts from static transport skip

The enable slice omits a static layout from RSC transport once it passes the
cache cross-check. The cross-check proves artifact and payload identity, but it
does not, on its own, assert that the layout's render was free of per-request
behaviour. A layout that reads params, calls a request API, sets a finite
revalidate, opens a cacheLife scope, reads unstable_cache, or issues cache-tagged
or dynamic fetches must never be reused across a sibling-route navigation.

This adds an explicit observation gate at the skip decision point. Before a
static layout is omitted, its per-layout observation is checked: an incomplete
probe, observed param keys, structural param scope, request APIs, finite
revalidate, cacheLife, unstable_cache, cache tags, cacheable fetches, or dynamic
fetches each reject the entry with a specific SKIP_LAYOUT_* code and fall the
layout back to render-and-send.

Scope note: the enable base already classifies most runtime-unsafe layouts as
dynamic (so they never enter the static skip set) and the cache cross-check
rejects payload-divergent reuse. This gate is a deliberate last line of defence
at the omission boundary, with diagnostic rejection codes. The tests assert the
end-to-end contract (unsafe layouts are never omitted); they pass on the enable
base alone, so this PR is kept as a draft pending rebase once the enable PR
merges.

* fix(skip): centralize final static-layout skip rejection

* refactor(skip): table-drive static layout rejection rules

* docs(skip): clarify !observation guards missing-tracker, not missing observation
2026-06-04 23:17:35 +01:00
Stephen Zhou e3090b08fa fix: dev overlay browser sourcemap stacks (#1731)
* Add browser runtime sourcemap regression test

* Update pnpm lockfile

* Fix dev overlay browser sourcemap stacks

* Move dev stack sourcemap resolution to server

* Fix knip unused stack endpoint export

* Fix dev stack sourcemap proxy host handling

* test: cover dev stack sourcemap helpers

* fix: remove unused sourcemap type export

* refactor: share dev stack sourcemap endpoint

* chore: trigger ci rerun

* fix: cap dev stack sourcemap request body
2026-06-04 16:09:44 +01:00
Nathan Nguyen 610cf2e63a fix(app-router): honor per-response dynamic stale times on the client (#1712)
* fix(app-router): honor per-response dynamic stale times on the client

App Router navigation reused and evicted cached RSC responses using one global prefetch TTL, so pages with different unstable_dynamicStaleTime values could not stay fresh independently. Traversal also needed a route instance boundary so browser history restores hidden state instead of reusing a forward-navigation component instance.

The client cache now snapshots dynamic stale-time response metadata, computes expiry per entry, preserves staleAt across cache replays, and derives mounted-slot cache variants from the decoded payload. Browser traversal keeps route instances under React Activity while normal navigations allocate fresh route instances, matching Next's BFCache/stale-time contract.

* fix(link): make prefetch dedupe freshness-aware for navigation prefetches

The old prefetched.has(cacheKey) gate in prefetchUrl returned early for
any exact key match, including stale entries. This meant an expired
prefetch with the same cache key would never reach
hasPrefetchCacheEntryForNavigation, so it would never be deleted and
never refreshed.

The new helper is built to check resolvePrefetchCacheEntryExpiresAt,
delete stale entries, and return false. But the Link path bypassed it
when the old prefetched Set contained the exact key.

Fix: gate the exact-key check on the helper's freshness decision. When
the helper reports the exact entry is stale and deletes it, also remove
the key from the prefetched Set so the fall-through path can issue a
fresh fetch. Keep the second helper call for equivalent _rsc variants.

Add an integration test proving visible Links re-prefetch when their
exact cache entry goes stale, not just that the helper reports stale
entries as unavailable.

* refactor(app-router): collapse redundant prefetch/visited-cache gates

Address review feedback on the client dynamic-stale-time changes:

- link.tsx: the prefetch dedupe path called
  hasPrefetchCacheEntryForNavigation twice when an exact entry already
  existed. Collapse to a single freshness-aware gate that covers both the
  exact entry and equivalent _rsc variants.
- app-browser-entry.ts: drop the redundant early refresh return in
  getVisitedResponse; isVisitedResponseCacheEntryFresh already bypasses
  the cache for refresh navigations (and the miss path evicts the entry).

* docs(app-router): document intentional refresh eviction in visited cache

Per review feedback: getVisitedResponse now routes refresh navigations
through isVisitedResponseCacheEntryFresh (the single source of truth),
which returns false for refresh and therefore falls through to the read
eviction. Document at the fall-through delete that a refresh intentionally
drops any prior snapshot, since the navigation re-fetches and re-stores a
fresh one.

* docs(navigation): clarify load-bearing prefetch-cache dual checks

Per review feedback, document two checks that look superficially redundant
but preserve observable contracts:

- isPrefetchCacheEntryCompatibleWithMountedSlots: the two clauses accept
  either the entry's pinned request-time slot context or the server-declared
  snapshot header; they diverge in the prefetchRscResponse case.
- consumePrefetchResponse expiresAt guard: only synthesize expiresAt onto the
  returned snapshot when the entry already carried one, so entries without an
  explicit expiry round-trip unchanged for callers/tests.

---------

Co-authored-by: James <james@eli.cx>
2026-06-04 16:01:43 +01: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 1cb89fc196 fix(pages-router): collapse doubled basePath in client asset URLs (#1730)
A Pages Router app built with a `basePath` emits its page and `_app`
chunk URLs with the basePath applied twice, e.g.
`/docs/docs/_next/static/_slug_.js`. Those URLs 404, so the page never
hydrates, `next.router` never initializes, and any client-side
navigation (router.push to /404, /_error, etc.) silently does nothing.

Vite+ bakes the configured `base` into emitted chunk fileNames on disk
(`dist/client/docs/_next/static/...`) so the Cloudflare ASSETS binding
can serve them directly. Vite then prepends `base` a second time when it
writes `ssr-manifest.json`, producing `docs/docs/_next/static/...`
entries. `augmentSsrManifestFromBundle` preserved those doubled entries
verbatim (it only stripped a leading slash), while its own bundle
backfill produced the correct single-prefix path. The merged manifest
held both, and `resolveClientModuleUrl` returned the doubled one.

The on-disk chunk fileName carries the basePath exactly once, so the
public URL must too. Collapse an exact `<base>/<base>/` prefix when
normalizing the Vite-sourced manifest entries so they match the
single-base paths the backfill produces. A correctly single-prefixed
entry is left untouched.

Regression covered with a focused unit test on the manifest builder and
verified end to end against the Next.js basepath/error-pages deploy
suite (previously 2 failing client-navigation cases, now all green).
2026-06-04 10:16:45 +01:00
James Anderson d8ca7e8177 test(i18n): align shallow redirect-skip assertion with Next.js history state (#1729)
The i18n sticky-locale change (f4d13e25) made router pushState write a
Next.js-shaped history state ({ url, as, options, __N, key }) instead of
{}, and updated 6 matching assertions in shims.test.ts. The shallow
Router.push redirect-skip test (#1522) carried the same {} assertion but
was missed, breaking the unit suite on main.

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

Refs #1336 (item 2).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The previous "treat unprefixed path as default locale" assertions in
tests/link.test.ts and tests/shims.test.ts contradicted the intentional
sticky-locale flip in ab662c07 (getCurrentBrowserLocale now prefers
__VINEXT_LOCALE__ over __VINEXT_DEFAULT_LOCALE__). Update both tests to
assert the new behavior: a default-locale path served under a non-default
locale keeps its active locale for client-side root navigations.
2026-06-03 23:12:43 +01:00
James Anderson a8b3a5e394 fix(pages-router): make req async-iterable for bodyParser: false (#1479) (#1678)
Next.js Pages Router API handlers using `export const config = { api:
{ bodyParser: false } }` drain the raw bytes by iterating `req` directly
(`for await (const chunk of req)`), matching Node's IncomingMessage
contract. vinext's Workers-path req was a plain object with `body` set
to the underlying ReadableStream, so the idiom threw with 500 — breaking
webhook handlers and the `middleware-fetches-with-body` deploy suite.

Surface an `[Symbol.asyncIterator]` on the synthetic `PagesReqResRequest`
that proxies the underlying Web ReadableStream reader (with a `return()`
cleanup so `break` propagates a cancel). Leave `req.body` undefined when
`bodyParser: false`, matching Next.js (`parse-body.ts` only assigns
`req.body` when `bodyParser !== false`).

Ports the relevant assertion from
test/e2e/middleware-fetches-with-body/index.test.ts in the Next.js repo.
2026-06-03 23:07:19 +01:00
James Anderson 7f0b7ee9f7 test(routing): regression for /api-… page prefixes (#1542) (#1662)
* test(routing): regression for /api-… page prefixes (#1542)

Adds a focused unit test in tests/routing.test.ts that creates a
fixture with `pages/api-docs/[...slug].tsx`, `pages/api-info/index.tsx`,
and a real API route at `pages/api/hello.ts`, then asserts:

- `/api-docs/:slug+` and `/api-info` are discovered as page routes,
  not API routes (matches the Next.js fixture at
  `.nextjs-ref/test/e2e/prerender/pages/api-docs/[...slug].js`).
- The real `/api/hello` API route is still discovered correctly.
- `matchRoute` resolves `/api-docs/first` and `/api-info` against the
  page route table, pointing at the correct file paths.

The existing API-route detection already requires the trailing slash
(`startsWith("/api/")` or `=== "/api"`) in every code path, and the
`pages/` scanner only excludes the literal `"api"` directory — so the
test passes on `main`. This test pins that behaviour in place to
prevent a future regression of the substring-`api-` false positive
called out in #1542.

Refs #1542.

* style: apply biome formatter to routing.test.ts
2026-06-03 23:03:17 +01:00
James Anderson 6dd6e76228 fix(pages-router): run instrumentation-client.ts before hydration (#1474) (#1671)
The generated Pages Router client entry imported `vinext/instrumentation-client`
which transitively loads the user's `instrumentation-client.ts` via the
`private-next-instrumentation-client` alias. That indirection left the
contract implicit and the side effects vulnerable to aggressive tree-shaking.

Side-effect-import the user's file directly from the generated client entry
when it exists, ahead of `hydrateRoot()`. Mirrors Next.js's
`page-bootstrap.ts`, which side-effect-imports `require-instrumentation-client`
before `initialize`/`hydrate`.

Adds focused unit tests in `tests/entry-templates.test.ts` asserting that the
generated client entry imports the user's `instrumentation-client.ts` before
`hydrateRoot(` is called, and that nothing spurious is emitted when no file
is present.

Refs #1474.
2026-06-03 23:02:38 +01:00
James Anderson 4fefbbce84 fix(cache): attach path tags to prerender-seeded entries so revalidatePath invalidates them (#1486) (#1688)
Build-time prerender seeding (`isrSetPrerenderedAppPage`) and TPR upload were
writing cache entries with `tags: []`. Tag-based invalidation could never
reach them, so `revalidatePath('/foo')` left the seeded HTML/RSC stale until
the natural `revalidateAt` expired.

Compute the path-derived implicit tags via `buildAppPageCacheTags(pathname, [])`
in both seed paths and forward them through to the cache handler.

Closes (in part) #1486 — plain path invalidation. Rewrite-aware
invalidation (canonical-vs-rewrite key mismatch) is left for a follow-up.
2026-06-03 22:55:16 +01:00
James Anderson f8481ac04d fix(css): preserve distinct media filenames for CSS url() assets (#1725)
* fix(css): preserve distinct media filenames for CSS url() assets

Match Next.js `asset/resource` behaviour for CSS `url()` dependencies:
emit them as files under `/_next/static/media/` instead of inlining, and
keep byte-identical sources (e.g. `dark.svg` / `dark2.svg`) as separate
output files instead of letting Vite/Rolldown's content-based asset
dedupe collapse them onto one filename.

There is no config switch to disable that dedupe (vitejs/vite#8632). The
native escape hatch is `this.emitFile({ fileName })`, which is never
deduped. A build-only, client-scoped pair of plugins:

  - mark: during the client CSS transform, tag each relative asset
    `url()` with a private `?vinext_css_url_asset=<source-basename>`
    query — the only durable carrier of per-reference provenance, since
    once Rolldown dedupes, the bundle can't tell which reference came
    from which source.
  - restore: at `generateBundle`, read the marker back, emit a sibling
    file under the wanted source name via the `fileName` escape hatch,
    rewrite the reference, and strip the marker.

Each reference resolves from its own marker, so split CSS chunks stay
correct without a shared cursor or bundle-iteration-order dependence.

This is a slimmer reimplementation of the approach explored in #1703:
same mark/restore mechanism and public surface, but the restore side
drops the AssetIndexes machinery, filename-reservation loop, and
multi-fallback lookups in favour of a single basename map + emitted-set,
since the sibling-filename construction (source-stem + content-hash +
ext) makes those guarded cases unreachable.

Tests port the Next.js scss/url-global e2e into the committed
`pages-basic` and `app-basic` fixtures (built in isolation), covering
the single-stylesheet two-identical-urls case and cross-chunk provenance.

Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>

* fix(css): merge duplicate vite import; drop ReDoS-flagged regex; clarify comments

- tests/css-url-assets.test.ts: merge the two `vite` imports into one
  (import/no-duplicates lint error that failed CI Check).
- client-build-config.ts: replace `/\/+$/` trailing-slash strip with a
  linear loop so CodeQL stops flagging a (false-positive) ReDoS.
- index.ts: reword the css-url mark transform's `map: null` comment — the
  marker shifts columns transiently but is stripped before final output.
- css-url-assets.ts: note the basename-uniqueness assumption in assetsByBase.

Addresses review feedback on #1725.

Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>

* test(css): use dedicated minimal fixtures for url() asset tests

The url() asset tests previously added routes to the shared app-basic /
pages-basic fixtures and built those whole apps in isolation. That made
the tests CPU-heavy and, run concurrently under CI, contended with
app-router.test.ts's static-export test enough to push it past its 30s
timeout (it passes comfortably locally and on main; the extra parallel
load was the trigger). The added app-basic route also slightly inflated
every app-basic-wide test (static export renders all routes).

Move the fixtures into dedicated minimal committed apps
(tests/fixtures/css-url-assets-{app,pages}) that build in ~1-2s. Same
observable contract (byte-identical dark.svg/dark2.svg referenced from
one stylesheet, plus the split-chunk provenance case), now isolated from
the heavyweight shared fixtures and fast enough not to contend.

Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>

* docs(test): update css-url-assets header to reference the dedicated fixtures

The file header still described the pre-refactor layout (pages-basic /
app-basic paths that no longer exist). Point it at the dedicated
css-url-assets-{pages,app} fixtures. Comment-only.

Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>

---------

Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
2026-06-03 22:46:04 +01:00
James Anderson 4ffd5bf225 fix(app-router): ensure streamed SSR body ends with </body></html> (#1532) (#1624)
* fix(app-router): ensure SSR stream ends with </body></html> suffix (#1532)

Trailing flight chunks and preinit scripts emitted by `rscEmbed.finalize()`
in the tick-buffered transform's `flush()` were being appended after React
Fizz's `</body></html>` closing tags, leaving the streamed document body
ending in `<script>...</script>` rather than a well-formed close.

Strip the first `</body></html>` we see in the buffered HTML chunks and
re-emit it at the very end of `flush()`, after the final RSC scripts.
Mirrors Next.js's `createMoveSuffixStream` behaviour
(packages/next/src/server/stream-utils/node-web-streams-helper.ts).

Ported from Next.js test:
test/e2e/app-dir/app/index.test.ts — "should ensure the </body></html>
suffix is at the end of the stream".

Fixes #1532.

* test(rsc-streaming): adjust progressive-delivery assertion for #1532

The 'delivers RSC chunks progressively' case still asserted the old order
where the navigation-runtime done signal trailed </html>. After #1532
suffix-moved </body></html> to the very end of the stream, the done
signal sits *between* the body content and the document close. Update
the assertion to match: done > last body content, and </html> > done.

* style: apply biome formatter to rsc-streaming.test.ts
2026-06-03 22:42:26 +01:00
James Anderson 51690038d8 fix(pages-router): cancel in-flight nav on gSSP/gSP data redirect (#1465) (#1691)
* fix(pages-router): return __N_REDIRECT JSON for gSSP/gSP data redirects (#1465)

When getServerSideProps/getStaticProps returns `{ redirect }` on a
client-side `_next/data` navigation, vinext emitted a real HTTP redirect.
The client's fetch transparently followed it to the destination's HTML
(not a valid data envelope), forcing a hard reload with console errors and
leaving navigation on the wrong page.

Mirror Next.js: for data requests, reply 200 with `__N_REDIRECT` /
`__N_REDIRECT_STATUS` in pageProps. The client router now detects these
markers and re-dispatches a fresh client navigation to the destination,
which increments the navigation id and supersedes (cancels) the in-flight
navigation so the intermediate page never commits. HTML requests still emit
a real HTTP redirect.

Fixed in both server paths to keep dev/prod parity (see AGENTS.md):
- prod: buildPagesRedirectResponse() in pages-page-data.ts branches on isDataReq
- dev: writeGsspRedirect() in dev-server.ts branches on isDataReq
- client: navigateClientData() handles pageProps.__N_REDIRECT via
  handleDataRedirect(), preserving push/replace mode
- tests: data-endpoint redirect shape (vitest) + client cancellation (e2e)

* fix(pages-router): hard-navigate to external __N_REDIRECT destination (#1465)

Address review: handleDataRedirect() passed the originating page URL to the
hard-navigation fallback for external/non-absolute __N_REDIRECT destinations,
which would loop back to the redirecting page (e.g. an OAuth callback at
https://example.com/...). Hard-navigate to the destination instead, matching
Next.js's handleHardNavigation({ url: destination }). Drop the now-unused
fallback parameter and add a server test asserting an external destination is
preserved verbatim in the data envelope.
2026-06-03 22:34:36 +01:00
Nathan Nguyen 65eaea49a4 feat(skip): omit proven static layouts from RSC transport (#1437)
* feat(skip): omit proven static layouts from RSC transport

App Router RSC responses re-send every layout payload on each navigation, even
when the client already holds an identical static layout from a sibling route.
That repeats transport bytes on the common sibling-page navigation path.

When the client sends a verified reuse manifest, the render lifecycle now runs a
skip-transport planner. Each manifest entry is cross-checked against the
static-layout cache proof (exact artifact compatibility, variant cache key,
payload hash, and invalidation). Layouts that pass are omitted from the outgoing
RSC payload, recorded under __skippedLayoutIds, and their per-element render
dependencies are released. The skip disposition also bypasses the RSC cache so a
partial payload never enters a shared cache, and it is suppressed for HTML
responses.

This is the enable slice. Layouts are gated on the cache proof, not yet on
per-layout runtime observations. The observation-based safety gates (param
scope, request APIs, finite revalidate, cacheLife, unstable_cache, cache-tagged
and dynamic fetches) land in the stacked hardening follow-up.

* fix(skip): preserve parallel slots owned by skipped static layouts

When the skip-transport merge restores a server-omitted static layout into
preserveElementIds, it must also restore the default/unmatched parallel slots
that layout owns. Otherwise the planner invariant ("every preserved slot's owner
layout is present in preserveElementIds, and vice versa") is violated: in the
topology-unknown path the planner returns empty slot persistence, so a
slot-owning layout skipped server-side commits with a missing slot
(mergeElements starts from the next payload and, with preserveAbsentSlots:
false, never restores it).

mergeSkippedLayoutPreservation now derives the owned slots for newly-preserved
skipped layouts via the planner's resolveDefaultOrUnmatchedSlotPersistenceForLayouts,
covering both the topology-unknown path and the known-topology past-divergence
case. Added a regression with a mounted @slot default that fails without the fix.

Also hardcode the static-layout cache-proof's observed request-API set to []
(with a comment naming the invariant) instead of reading it back from the
per-layout observation, so a future reordering of the classification gate cannot
feed stale request-API reads into the synthetic proof. Reaching that point
already requires staticLayoutIds membership, which excludes any layout that
observed a request API.

* docs(skip): explain broader-than-planner layout membership guard

Per review: clarify that the set-membership check in
mergeSkippedLayoutPreservation is intentionally broader than the planner's
prefix-based persistence, and why preserving a proven-identical layout past the
ancestor divergence point is sound. Comment-only.

* refactor(skip): drop redundant cacheable-fetch observation record

Per review: the post-key-generation recordCacheableFetchObservation in
fetch-cache.ts was strictly redundant. The synchronous record before the first
await already covers every path that reaches it (the only intervening early
return records a dynamic observation instead), and the store is an idempotent
set, so the second call added no coverage.

* fix(test): match real RSC request URL in client-reuse-manifest e2e

The 'sends a client reuse manifest for retained static layouts on soft
navigation' e2e hand-rolled its RSC-request matcher as
url.pathname === '/client-nav-test.rsc', but App Router soft-navigation RSC
requests use the bare pathname with a _rsc query param and an rsc: 1 header
(e.g. GET /client-nav-test?_rsc), so the predicate never matched and
page.waitForResponse timed out at 30s. The manifest header was being attached
correctly the whole time. Use the shared isAppRouterRscRequestForPath helper
(as the sibling test already does) so the matcher tracks the real request shape.

---------

Co-authored-by: James <james@eli.cx>
2026-06-03 16:10:25 +01:00
Nathan Nguyen 49b45b9891 feat(ppr): add encodePrerenderRouteParams and match kind exact payload tests (#1714)
* feat(ppr): add fallback shell payload identity

* refactor(ppr): address review feedback on payload identity helpers

- normalizePregeneratedPathname: document the deliberate non-strict vs
  strict normalization choice so it isn't "fixed" into a build-time throw
- addPregeneratedConcretePath: normalize the pathname internally as the
  single source of truth, removing the caller pre-normalize footgun
- export PrerenderRouteParamsRouteMatch for #1716 consumers
- cover direct un-normalized recording with a test

* fix(ppr): mark PrerenderRouteParamsRouteMatch @public for knip

The type is exported for #1716's serving consumers but has no in-repo
reference yet, so knip's no-unused-exports gate flags it. Knip excludes
exports tagged @public from that report; this keeps the reviewer-requested
export without failing CI.

* docs(ppr): document live-Set and dedup contracts on concrete-path registry

Address bonk review notes:
- getRenderedConcreteUrlPathsForRoute returns the live backing Set for
  allocation-free hot-path lookups; document that callers must not retain
  the reference across a re-seed (clear empties the map, stranding it).
- parsePregeneratedConcretePaths intentionally does not dedupe repeated
  route patterns; document that they merge additively and value-dedup in
  addPregeneratedConcretePath makes the result equivalent.

---------

Co-authored-by: James <james@eli.cx>
2026-06-03 12:20:32 +01:00
dependabot[bot] b16b9ed055 chore(deps): bump actions/checkout from 6.0.2 to 6.0.3 (#1720)
* chore(deps): bump actions/checkout from 6.0.2 to 6.0.3

Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Apply suggestion from @james-elicx

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: James <james@eli.cx>
2026-06-03 10:10:19 +00: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