Commit Graph

1503 Commits

Author SHA1 Message Date
Nathan Nguyen aee62a123a fix(app-router): hydrate initial root in transition (#1243)
App Router hydration currently calls hydrateRoot at urgent priority, unlike Next.js App Router startup. That makes the initial hydration work compete with input that React could otherwise prioritize as urgent.

Move the initial App Router hydrateRoot call behind an explicit transition scheduling helper and keep returning the created root synchronously so existing navigation globals keep their current contract.

Add focused regression coverage that proves hydrateRoot is invoked inside the transition callback.
2026-05-15 17:27:21 +01:00
James Anderson c7453909e2 fix(workflow): use correct workers.dev URL for compat ingest (#1241)
* fix: use vinext-web.vinext.workers.dev as the ingest URL

The default URL I picked (cloudflare.workers.dev) was a guess — workers
*.workers.dev URLs are namespaced under the account subdomain, which for
this account is vinext.workers.dev (see scripts/smoke-test.sh and the
deploy-examples workflow). curl was getting NXDOMAIN.

Still overridable via the COMPAT_INGEST_URL repo variable for custom
domains later.

* fix(workflow): also archive compat-ingest.json in the report artifact

When the live POST to /api/compatibility fails (DNS, expired secret,
rotated secret, etc.) we previously had no way to recover the run
without re-running the 40-minute suite. The ingest payload was being
generated but only the human-readable deploy-suite-report.json was
uploaded.

Add compat-ingest.json to the same artifact so a maintainer can
download it and curl it through manually.
2026-05-15 16:06:59 +00:00
James Anderson 1c0bdacda6 feat(apps/web): /compatibility page backed by D1 + deploy-suite ingest (#1239)
* feat(apps/web): /compatibility page backed by D1 + deploy-suite ingest

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

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

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

* address review + add ISR with KV cache

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

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

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

* address bonk re-review

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

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

  - Resolve workflow expressions once via step-level `env:` and read
    them as `process.env.*` from the github-script body. Removes the
    fragile splicing of `${{ inputs.next-ref || 'v16.2.6' }}` into JS
    string literals (the nested single quotes were the worst offender).
2026-05-15 15:10:36 +00:00
James Anderson 5d5fe32a65 fix(app-router): forward searchParams correctly in probePage() (#1238)
The RSC entry's probePage() body read a non-existent key off
collectAppPageSearchParams's return value, so the page component
received undefined for searchParams. Any page that did
`await searchParams` during probing threw TypeError, the probe
swallowed it as a non-special error, and redirect()/notFound()
calls were dropped from the probe path. The visible render path
was unaffected because app-page-element-builder reads the correct
key independently.

Per AGENTS.md, the generated entry should be thin codegen glue.
Move the probePage() body into a typed probeAppPage() helper in
server/app-page-probe.ts and have the entry delegate to it, so the
search-params wiring is unit-testable in isolation. Add focused
tests covering thenable params, empty-query handling, the null
page-component case, and redirect-throw propagation.

Fixes https://github.com/cloudflare/vinext/issues/1235
2026-05-15 15:10:36 +01:00
James Anderson d9906b94d6 chore: add basic vinext site (#1236)
* chore: scaffold new next app with vinext

* basic app

* add to deploy workflow

* fmt
2026-05-15 13:41:10 +00:00
James Anderson 1ac7b6514b fix(examples): next build failure for app-router-playground (#1234) 2026-05-15 13:40:59 +01:00
James Anderson b4898289e7 fix(app-router): match Next.js RSC Content-Type and 404 plain-text body (#1233)
* fix(app-router): match Next.js RSC Content-Type (no charset suffix)

Next.js sets the RSC (React Server Components / 'flight') response
Content-Type to exactly 'text/x-component' (RSC_CONTENT_TYPE_HEADER in
.nextjs-ref/packages/next/src/client/components/app-router-headers.ts:17,
used by FlightRenderResult in
.nextjs-ref/packages/next/src/server/app-render/flight-render-result.ts:15).
vinext was sending 'text/x-component; charset=utf-8'. The suffix breaks
Next.js's own segment-cache deduplication checks (action-handler.ts uses
.startsWith(RSC_CONTENT_TYPE_HEADER) on the unsuffixed string) and fails
Next.js e2e tests that assert strict equality, e.g.:
  - test/e2e/app-dir/app/index.test.ts L362, L371
  - test/e2e/app-dir/segment-cache/deployment-skew/deployment-skew.test.ts L80

Replace the duplicated literal at seven sites with the existing
VINEXT_RSC_CONTENT_TYPE constant in app-rsc-cache-busting.ts.

* fix(app-router): match Next.js 404 plain-text body 'This page could not be found'

Next.js's plain-text 404 response body is exactly
'This page could not be found' (no trailing period in the response body;
the React-rendered not-found component uses the same text with a period).
vinext was returning 'Not Found' / '404 Not Found' / '404 - Not found',
which fails Next.js e2e tests that assert .toContain('This page could
not be found'), e.g.:
  - test/e2e/app-dir/app/index.test.ts (several cases)
  - test/e2e/app-dir/parallel-route-not-found/parallel-route-not-found.test.ts L52
  - test/e2e/app-dir/prefetching-not-found/prefetching-not-found.test.ts L16

Next.js source citations (in .nextjs-ref):
  - packages/next/src/server/route-modules/pages/pages-handler.ts L121, L535
  - packages/next/src/build/templates/app-route.ts L170, L349
  - packages/next/src/build/templates/app-page.ts L701, L1043
  - packages/next/src/pages/_error.tsx L7
  - packages/next/src/client/components/builtin/not-found.tsx L7 (HTML variant)

Updates the body in notFoundResponse() and the open-redirect / pages-router
SSR-fallback / worker-entry inline 404 sites. The HTTP status reason phrase
('Not Found') is unchanged. Route-specific bodies like '404 - API route not
found' are intentionally retained.
2026-05-15 12:39:05 +01:00
James Anderson efa18a3f80 fix(pages-router): include _app module assets in served page HTML (#1232)
* fix(pages-router): include _app module assets in served page HTML

When a Pages Router page renders, vinext's prod server collects asset
tags only for the matched page module. _app.tsx — which wraps every
page — is omitted, so any CSS/JS it imports is built and emitted but
never <link>ed from the rendered HTML.

For SCSS users this is the LHF-5 cluster from the Next.js deploy suite:
when global.scss is imported via _app.tsx, Vite compiles it correctly
and emits assets/_app-*.css, but the browser never sees a
<link rel="stylesheet"> for it. The page renders with the user-agent
default colour (rgb(0, 0, 0)) instead of the SCSS-defined colour.

Fix: embed the resolved pages/_app file path at build time and include
it in the moduleIds passed to collectAssetTags, so the SSR manifest's
CSS/JS entries for _app are emitted alongside the page's own.

Includes a regression test (tests/scss.test.ts) plus a fixture page
that imports a .scss file with a variable. The test covers App Router
dev, App Router production build, Pages Router dev, and the Pages
Router production server end-to-end (build → start prod server →
fetch page → assert linked stylesheet has the resolved colour).

Skipped automatically when sass is not installed — vinext relies on
Vite's built-in SCSS preprocessor, which requires the user to install
sass (or sass-embedded) themselves. Matches Vite's model.

* fix: isolate SCSS fixture and silence TS check for optional sass import

The first revision of this PR added an SCSS fixture under
tests/fixtures/app-basic/app/nextjs-compat/scss-test/. That directory
is scanned by every test that boots the shared app-basic fixture, so
when sass is not installed (CI default) every page in app-basic fails
the route-graph scan and 500s. jsx-in-js, app-css, node-modules-css,
and ~dozens of integration tests went red as a result (CI run
https://github.com/cloudflare/vinext/actions/runs/25913568939).

The TypeScript check job also failed because `await import("sass")`
produces TS2307 when sass isn't declared as a workspace dep.

Fixes:

- Drop the shared fixture under app-basic. tests/scss.test.ts now
  materialises its Pages Router SCSS fixture in a per-test tmpdir
  (same pattern as the existing Pages Router scenario), so the shared
  fixture's route-graph scan never sees a .scss import. No other test
  is affected.

- The App Router test scenarios from the first revision are removed.
  They required additional node_modules wiring for @vitejs/plugin-rsc
  to resolve from a tmpdir and weren't needed to validate the fix —
  the actual change is in the Pages Router asset-collection path, and
  the Pages Router tests exercise it end-to-end (dev + production
  build + linked stylesheet assertion).

- Replace @ts-expect-error with @ts-ignore on the dynamic sass import.
  sass may or may not be installed depending on the environment; when
  it is, there's no type error to expect and @ts-expect-error itself
  errors as unused. @ts-ignore is the right tool for an optional peer.

The pages-server-entry.ts fix from the first revision is unchanged —
this commit only re-shapes the test surface so it doesn't poison the
shared fixture.
2026-05-15 12:15:54 +01:00
James Anderson 59a2c3bcc8 fix(shims): add unstable_catchError, unstable_rethrow, unstable_isUnrecognizedActionError (#1206)
* fix(shims): add unstable_catchError, unstable_rethrow, unstable_isUnrecognizedActionError

Unblocks 18 build failures in the Next.js deploy suite:

- `unstable_catchError` (12 failures) — added to `shims/error.tsx`. App
  Router error-boundary HOC ported from Next.js's
  `client/components/catch-error.tsx`. Implements the same
  `getDerivedStateFromError` predicate that re-throws Next.js navigation
  signals (redirect, notFound, …) and renders the user fallback with an
  `ErrorInfo` value otherwise. `unstable_retry` throws a clear "not yet
  implemented" error (tracked as follow-up).

- `unstable_rethrow` (3 failures) — added to `shims/navigation.ts` and
  re-exported from `shims/navigation.react-server.ts`. Ported from
  Next.js's `client/components/unstable-rethrow.{ts,server,browser}.ts`.
  Specialized to the internal-error categories vinext actually emits
  (redirect + HTTP access fallback); recurses through `error.cause` for
  wrapped errors.

- `unstable_isUnrecognizedActionError` (3 failures) — added to
  `shims/navigation.ts` along with the `UnrecognizedActionError` class.
  Ported 1:1 from `client/components/unrecognized-action-error.ts`. The
  react-server condition exports a throwing stub matching Next.js's
  `navigation.react-server.ts`.

Also adds `isRedirectError` and `isNextRouterError` helpers to
`navigation.ts` (used internally by `unstable_rethrow`) and updates
`next-shims.d.ts` so the new exports type-check for users.

* fix(shims): document isRedirectError divergence and dedupe error-boundary copy

Addresses two /bigbonk review observations on #1206:

1. **Document the permissive prefix match.** The public `isRedirectError`
   in `shims/navigation.ts` does `digest.startsWith("NEXT_REDIRECT;")`
   while Next.js's `client/components/redirect-error.ts` does full
   4-segment validation (type ∈ {push, replace}, non-empty destination,
   status ∈ {303, 307, 308}). The divergence is intentional — vinext
   emits 3-part and 4-part digests whereas Next.js's validator targets
   its 5-part canary digests — but now that the predicate is reachable
   from the public `next/navigation` surface via `unstable_rethrow`, it
   gets a prominent JSDoc block explaining the difference, the
   consequence (malformed digests return true here, false in Next.js),
   and a link to the Next.js source.

2. **Consolidate duplicate `isRedirectError`.** `shims/error-boundary.tsx`
   had its own private copy with the same prefix-match logic. Replaced
   the duplicate with an import from `./navigation.js`, matching the
   pattern already used by `shims/error.tsx`
   (`import { isNextRouterError } from "./navigation.js"`). The
   `RedirectBoundary` call site casts to the file-local `RedirectError`
   type to preserve access to the optional `handled` field — this is
   safe by construction (every error matching the prefix predicate is
   produced by vinext's `redirect()` / `permanentRedirect()` helpers,
   which yield `Error` instances).

No behavior change. `vp check` + `vp test run tests/shims.test.ts
tests/error-boundary.test.ts` (916 tests) pass.

* fix(shims): drop unused getErrorDigest export

After consolidating shims/error-boundary.tsx onto the public
isRedirectError from shims/navigation.ts, the only remaining caller of
getErrorDigest is inside utils/navigation-signal.ts itself. Knip flags
the export as unused, which fails the Check job in CI.

Convert getErrorDigest to a file-local function. No behavior change.

* fix(shims): extend unstable_rethrow + make unstable_retry functional

Parity audit findings on PR #1206 (post /bigbonk APPROVED) revealed two
gaps from a thorough re-read of Next.js's error-rethrow surface and
`unstable_catchError` test fixtures.

## 1. `unstable_rethrow` — added missing error categories

Next.js's server-side `unstable_rethrow` rethrows seven error categories
(client-side rethrows two). vinext was only handling #1
(`isNextRouterError`). The canonical user-facing fixture
(`test/e2e/app-dir/app-static/lib/fetch-retry.js`) breaks if any of the
other categories are not propagated.

Added the two categories vinext can plausibly encounter:

- `BailoutToCSRError` + `isBailoutToCSRError` — thrown by `next/dynamic`
  with `ssr: false`. Lives in shared (non-server) code so third-party
  libraries and accidentally-bundled Next.js internals can produce it.
  Ported 1:1 from
  https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/lazy-dynamic/bailout-to-csr.ts

- `DynamicServerError` + `isDynamicServerError` — thrown by Next.js's
  `cookies()`/`headers()` when called in a static render context. vinext
  doesn't construct it itself but exposes the class so user code, action
  wrappers, and accidental Next.js-internal bundles can interoperate.
  Ported 1:1 from
  https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/hooks-server-context.ts

The remaining four server-only categories (`isDynamicPostpone`,
`isPostpone`, `isHangingPromiseRejectionError`,
`isPrerenderInterruptedError`) are tied to Next.js's PPR / prerender
controller machinery that vinext does not implement. User code cannot
construct them in normal use; they're deferred as a follow-up
(documented inline). A test pins this intentional gap.

## 2. `unstable_catchError.unstable_retry` — made functional

The previous implementation threw a generic "not yet implemented" error
in both server and client contexts. Now matches Next.js's App Router
branch: on the client, calls `appRouterInstance.refresh()` inside
`React.startTransition` and resets the boundary. On the server, throws
a clear "client-only" error (refresh is meaningless during SSR setup).

Pages Router's exact error message (`unstable_retry() can only be used
in the App Router. Use reset() in the Pages Router.`) is not yet
dispatched because vinext's boundary doesn't read `PagesRouterContext`;
documented in the JSDoc as a parity follow-up.

## Test coverage

Added 8 new test cases covering:
- `BailoutToCSRError` + predicate (canonical digest, foreign-object
  detection, negative cases)
- `DynamicServerError` + predicate (same shape)
- `unstable_rethrow` propagates both new categories (re-throw identity)
- `unstable_rethrow` does NOT match the four uncovered server-only
  categories (pins the intentional gap)
- `unstable_catchError.getDerivedStateFromError` accepts `null` /
  `undefined` thrown values (ported from
  test/e2e/app-dir/catch-error/throw-null and throw-undefined fixtures)
- `unstable_retry` throws "client-only" on the server
- `unstable_retry` on the client calls `appRouterInstance.refresh()` +
  resets via setState

Also added `isRedirectError` / `isNextRouterError` / new predicates
JSDoc note that these are vinext-only extensions (Next.js does NOT
export them from `next/navigation` — confirmed by reading
`packages/next/src/client/components/navigation.ts` end-to-end).

`vp test run tests/shims.test.ts tests/error-boundary.test.ts
tests/app-router.test.ts tests/app-rsc-errors.test.ts` — 1274 tests
pass. `vp check` clean.

* fix(shims): drop redundant typeof check in isDynamicServerError

Bonk-review observation on #1206: the predicate was checking
`typeof digest === "string" && digest === _DYNAMIC_SERVER_USAGE_DIGEST`,
where the `===` against a string literal already requires the operand
to be a string. Drop the redundant guard for consistency with
`isBailoutToCSRError` above.

Non-blocking cleanup; no behavior change.
2026-05-15 12:10:10 +01:00
Nathan Nguyen c3b8948196 feat(compat): support declared artifact compatibility sets (#1230)
Artifact compatibility previously treated every known graph, deployment, root boundary, or render epoch mismatch as incompatible, even when a rolling deploy can prove that two versions belong to an explicit compatibility window.

That preserved safety but left no typed path for #726-COMPAT-06/08 canary, rollback, and old/new client coverage.

Add explicit compatibility sets to the evaluator. Matching remains strict by default, unknown, future, and legacy metadata still fall back to renderFresh, and overlapping pairs are not treated as transitive proof.

Cover rolling deploy, canary/rollback, stale map, old-client/new-server, and new-client/old-server scenarios.
2026-05-15 12:07:46 +01:00
Nathan Nguyen 4aa7663084 fix(app-router): hard navigate stale RSC build payloads (#1178)
* fix(app-router): hard navigate stale RSC build payloads

App Router soft navigation currently accepts any valid RSC payload after deploy. That lets stale tabs decode RSC responses produced by a different build, which can break client navigation when the server and browser artifacts no longer match.

The missing invariant was that every RSC response consumed by the browser must identify the build that produced it, and the browser must reject payloads from a different build before handing them to React.

Emit a vinext-owned build ID header on App Router RSC responses, preserve it in prefetched and visited response snapshots, and hard navigate when a response is missing or mismatches the current client build ID.

* chore: fix e2e fixture hook lint suppression

* chore: avoid hook-like fixture callback name

* fix(app-router): preserve build IDs for cached RSC payloads

* fix(app-router): use public RSC compatibility IDs
2026-05-15 12:01:33 +01:00
Nathan Nguyen 1d01d2989a feat(cache): record app render observations (#1198)
* feat(cache): record app render observations

App Router cache artifacts and payloads did not carry the render-observation metadata required by #726-CACHE-05/07. That left later cache-proof work without an attached record of request APIs, dynamic fetches, output scope, cache tags, and boundary outcome.

The missing boundary was that render-scoped observations lived only as dynamic/cache state, not as metadata owned by each produced payload or artifact.

Record render request API usage and dynamic fetch observations in request-scoped state, build redacted RenderObservation payloads for AppElements and APP_PAGE cache writes, and preserve complete observations for HTML/RSC ISR artifacts including stale regeneration.

Targeted coverage asserts payload metadata, artifact metadata, redaction, and existing cache-proof behavior.

* test(cache): cover search param render observation usage

The CI unit shard failed because app-page-element-builder tests fully mocked the headers shim and omitted the new markRenderRequestApiUsage export. The production path now records searchParams as a render observation whenever populated search params make the page dynamic.

Update the mock to expose the new shim export and assert that populated search params mark both dynamic usage and render-observation usage, while empty search params mark neither.

* refactor(cache): tighten render observation review handling

Render observation metadata now reuses the app page observation state shape instead of duplicating cache-local types. The deferred cache writes also document the stream-consumption boundary that preserves late request API observations.

Dynamic fetch observations are now stored in a Set so repeated no-store fetches do not inflate metadata, and the collector has focused regression coverage. The Link transition test also patches and restores the runtime React default so the full unit project is not order-sensitive when other files instantiate the module graph first.

* fix(cache): preserve parent render observations
2026-05-15 11:51:50 +01:00
James Anderson c5cebaa496 refactor(prerender): extract shared static-paths normalization (#1231)
* refactor(prerender): extract shared static-paths normalization

PR #1227 added the same getStaticPaths-entry normalization logic to
three files: packages/vinext/src/build/prerender.ts,
packages/vinext/src/server/pages-page-data.ts, and
packages/vinext/src/server/dev-server.ts. Each site handled the
string-vs-object branch, the missing-params guard, and the query-string
+ trailing-slash trimming on its own.

Hoist the shared logic into packages/vinext/src/routing/route-pattern.ts
alongside the existing matchRoutePattern helper. Two new exports:

- normalizeStaticPathname(pathname): the shared query-strip and
  trailing-slash-strip used by every call site for path comparison.
- normalizeStaticPathsEntry(entry, routePattern): the build-time helper,
  returning { params } | { error }; reused by prerenderPages.

Both server-side matchers (matchesPagesStaticPath in pages-page-data.ts
and the dev-server's request handling) now share the normalizeStaticPathname
helper and the StaticPathsEntry type. Their runtime matching behaviour is
preserved exactly, including the missing-params -> false (404) guard.

Pure refactor: no behaviour changes. Existing tests in
tests/prerender.test.ts (the safety net from #1227) still pass; new
focused unit tests added to tests/route-pattern.test.ts cover the helper
shape, string and object entries, catch-all patterns, percent-encoded
segments, and every error path.

* fix: drop unused NormalizedStaticPathsEntry export

knip flagged the type as an unused exported type. It's only referenced
inside route-pattern.ts as the return-type alias of normalizeStaticPathsEntry,
so make it module-local.
2026-05-15 09:51:49 +00:00
James Anderson 600580eec0 ci(e2e): cache WebKit apt archives and skip browser install on cache hit (#1228)
WebKit E2E jobs were dominated by two costs:

1. `playwright install --with-deps webkit` re-downloaded the browser
   binaries (or at least re-validated them) on every run even when the
   browser cache was hit, because the install step was unconditional.
2. The `--with-deps` flag always ran `apt-get install` for WebKit's
   system libraries (~30+ packages), and apt re-downloaded the .deb files
   on every run since runner archives aren't preserved across runs.

This commit:

- Splits browser install from system dep install so each can be cached
  on its own schedule.
- Gates the browser install on `cache-hit`, so cached runs skip the
  Playwright CLI entirely on the hot path.
- Adds a second cache for `/var/cache/apt/archives` (mirrored through
  `~/.cache/apt-archives`) so apt finds previously downloaded .deb files
  and skips redownloading them. The install itself still runs every
  time, but the slow network portion goes away.
- Keys both caches on the resolved Playwright version from pnpm-lock.yaml
  rather than the full lockfile hash, with a fallback `restore-keys` so
  unrelated lockfile churn doesn't invalidate the cache.

Chromium jobs also benefit from skipping the Playwright CLI on cache hits.
2026-05-15 10:39:18 +01:00
James Anderson 617605b925 fix(prerender): support string paths and missing params in getStaticPaths (#1227)
Next.js allows getStaticPaths to return paths as either
Array<{ params }> or Array<string> (see Next.js docs and
.nextjs-ref/packages/next/src/build/static-paths/pages.ts). vinext only
handled the object shape, so a fixture returning string paths crashed
the prerender phase with:

  TypeError: Cannot read properties of undefined (reading 'slug')
    at buildUrlFromParams (packages/vinext/src/build/prerender.ts:311)

This blocked ~213 Next.js deploy-suite tests
(see https://github.com/cloudflare/vinext/actions/runs/25897889733 —
middleware-general index/node-runtime, middleware-rewrites, and
middleware-trailing-slash all gated on this single build crash).

Changes:
- buildUrlFromParams: defensively throw a descriptive error when
  params itself is null/undefined, instead of crashing with a cryptic
  TypeError pointing at a dist line.
- prerenderPages (Pages Router): normalize each paths entry. Strings
  are run through the route pattern matcher to extract params, mirroring
  Next.js _routeMatcher(cleanedEntry). Missing/null params surface as
  per-route error results instead of crashing the whole prerender.
- prerenderApp (App Router): defensively guard each entry from
  generateStaticParams() against missing params with a clear error
  message in the existing try/catch.
- pages-page-data.ts (prod SSR): matchesPagesStaticPath now handles
  string entries via URL comparison and guards undefined params.
- dev-server.ts (dev SSR): same string-path + missing-params handling
  for the dev request path.
- Tests: add tests/fixtures/pages-basic/pages/string-paths/[slug].tsx
  and missing-params/[slug].tsx, with two new vitest cases asserting
  successful prerender and a per-route error result.
2026-05-15 09:34:01 +00:00
James Anderson 457b7dcf8d fix(image): exclude ipaddr.js from SSR dep optimizer (#1226)
The next/image client shim imports ipaddr.js for private-IP validation.
It's already in ssr.resolve.external, but the SSR dep optimizer would
still pre-bundle it on first request, producing

  (ssr) ✨ new dependencies optimized: ipaddr.js

and the accompanying full reload. Add ipaddr.js to optimizeDeps.exclude
for the App Router and Pages Router SSR environments so the optimizer
skips it; runtime resolution still works via resolve.external on Node
and via the worker bundle on Cloudflare/Nitro. The client environment
intentionally keeps pre-bundling ipaddr.js since next/image is a 'use
client' component and needs the CJS→ESM conversion in the browser.
2026-05-15 09:06:58 +00:00
James Anderson 9277b101b2 feat(dev): add dev server lock file (#1193)
* feat(dev): add dev server lock file

Write the running dev server's PID, port, and URL into
`node_modules/.cache/vinext/dev-lock.json`. When a second `vinext dev`
process starts in the same project directory, vinext reads the lock file
and prints an actionable error:

  Another vinext dev server is already running.

  - Local:        http://localhost:3000
  - PID:          12345
  - Dir:          /path/to/project

  You can access the existing server at http://localhost:3000,
  or run `kill 12345` to stop it and start a new one.

This is especially useful for AI coding agents, which frequently attempt
to start `vinext dev` without knowing a server is already running. The
structured error gives the agent the PID to kill the existing process or
the URL to connect to it — no manual intervention required.

If the recorded PID is dead (stale lock from a crashed run), the new
process takes over the lock automatically. Set `VINEXT_NO_DEV_LOCK=1`
to opt out entirely.

Behavior modeled on Next.js' dev server lock file:
https://github.com/vercel/next.js/blob/canary/packages/next/src/build/lockfile.ts

vinext uses a JSON file + PID liveness check (`process.kill(pid, 0)`)
rather than a native `flock()`. The race window on acquisition is
small and benign: at worst, two dev servers race and one fails to bind
its port.

* chore(dev-lockfile): unexport types only used internally

knip flagged FormatErrorOptions, AcquireOptions, AcquireSuccess,
AcquireFailure, and AcquireResult as unused exports. These are only
referenced inside dev-lockfile.ts itself, so drop the `export`
keyword to keep the public surface minimal.

* review(dev-lockfile): address self-review and bot feedback

- Capture ownerPid at acquire time and use it in release() instead of
  reading info.pid. Decouples release from update() so a future caller
  passing a different PID through update() can't trick release() into
  deleting another process's lock. Added a regression test.

- Document the TOCTOU window between readLockfile() and writeLockfile()
  inline so future contributors don't try to 'fix' the intentionally
  accepted race.

- Document the unreachable existing: undefined branch in
  formatAlreadyRunningError as a defensive fallback.

- Document the process.on('exit', ...) signal semantics — exit fires on
  graceful shutdown and after default SIGINT/SIGTERM handlers, but not
  on hard crashes. The next vinext dev will take over the stale lock.

- Set lock file mode to 0o600. Defense-in-depth: the PID is also
  discoverable via ps, so this isn't load-bearing.

- Use Vite's server.resolvedUrls.local[0] in cli.ts when building the
  post-listen appUrl. Vite substitutes 'localhost' for wildcard binds
  (0.0.0.0) so the URL is actually clickable. Also substitute 'localhost'
  in the pre-listen initial info for the same reason.

- Add tests:
  * exit listener is registered/deregistered correctly
  * update() with a different PID doesn't affect release() ownership
  * lock file is created with 0o600 on POSIX

* review(dev-lockfile): release on listen failure, preserve startedAt

Two issues caught in the bot re-review:

1. If `server.listen()` throws (e.g. strictPort and the port is taken),
   the dev() function previously threw without releasing the lock. The
   process.on('exit') handler still cleaned up afterwards, but in the
   brief window between listen failure and process exit, the lock file
   claimed a server was running at a port nobody was listening on. A
   concurrent `vinext dev` in that window would have shown a misleading
   'already running' error.

   Wrap createServer + listen in try/catch and call lockfile.release()
   on failure. The exit listener is still registered as a safety net
   for unexpected exit paths.

2. update() after server.listen() was setting `startedAt: Date.now()`,
   which overwrote the original acquisition timestamp. `startedAt` is
   meant to reflect when the process started (for 'how long has this
   server been running?' debugging), not when the URL was resolved.

   Capture `startedAt` once at acquisition time and pass the same value
   into update(). Added a regression test.

* feat(dev-lockfile): move lock file to .vinext/dev/lock.json

Previously the lock file lived at node_modules/.cache/vinext/dev-lock.json.
.vinext/ is already the established convention for project-local vinext
state — the fonts plugin uses .vinext/fonts/ to cache self-hosted Google
Fonts, and the monorepo's own .gitignore already excludes .vinext.

Benefits:
- Doesn't pollute node_modules (which package managers may aggressively
  clear during install / pnpm prune).
- Co-located with other vinext state, so users find it where they
  expect.
- Mirrors Next.js' .next/dev/lock layout more closely.

Follow-up: vinext init does not currently add .vinext to user
gitignores. That's a separate hygiene improvement also relevant to the
fonts cache.
2026-05-15 09:53:02 +01:00
Nathan Nguyen b46e9b8c44 fix(font): match Next.js font result style semantics (#1186)
* fix(font): match Next.js font result style semantics

Font loaders returned incomplete style exports, emitted Google variable classes unconditionally, and accepted adjustFontFallback without producing a metric-adjusted fallback face. Apps that read next/font results or rely on the generated class rule diverged from Next.js.

The shim assumed font-family was the only exported style concern and that the runtime Google loader should always synthesize a variable class. Reuse the existing static Google font transform boundary to inject fallback CSS, share the single-face style decision between Google and local loaders, and only return variable classes when requested.

Regression coverage ports the relevant Next.js font result expectations and checks adjusted fallback injection, disabled fallback behaviour, and single local source style exports.

* fix(font): avoid fallback metrics output collision

The package build emits imported JSON as JavaScript beside compiled TypeScript. Naming the data file fallback-metrics.json let the JSON artifact collide with fallback-metrics.ts on Windows, so the installed dist module could lack the named fallback helper exports.

Rename the generated data file to fallback-metrics-data.json and update the import so the packed output has distinct fallback-metrics.js and fallback-metrics-data.js modules.

* fix(font): preserve validated Google face metadata

Static Google font calls validated and fetched one concrete face, but the runtime shim recomputed exported style fields from raw options. Italic-only families could therefore load an italic face while returning a normal class rule.

Thread validated single-face weight and style through internal transform options, prefer them in the runtime shim, and harden emitted font descriptor values before class-rule and local font-face CSS generation.

Regression tests cover italic-only Google metadata handoff and unsafe font-style values for Google and local font shims.

* fix(font): omit ambiguous Google font style

Google style resolution treated a multi-style array the same as an omitted style option. That made ambiguous requests such as normal plus italic export fontStyle normal, even though no single face was selected.

Only default to normal when the style option is actually omitted. Multi-style arrays now produce no exported fontStyle, with a regression covering the class rule output.

* fix(font): tighten Google font runtime payload

* test(font): expect internal font payload in build output

* refactor(font): consolidate duplicated CSS sanitization functions into font-utils

Address review feedback:
- Remove sanitizeCSSValue wrapper in font-local.ts, use sanitizeFontDescriptorValue directly
- Add cross-module invariant comments for '{family} Fallback' naming convention
- Consolidate escapeCSSString, sanitizeCSSVarName, and sanitizeFallback into
  font-utils.ts (previously duplicated across font-google-base.ts, font-local.ts,
  and fallback-metrics.ts)

---------

Co-authored-by: James <james@eli.cx>
2026-05-15 09:48:42 +01:00
James Anderson 2acec092be chore: add next-env.d.ts to .gitignore (#1224) 2026-05-15 08:31:54 +00:00
James Anderson 8418ad7829 chore: update oxc editor settings (#1223) 2026-05-15 08:27:11 +00:00
James Anderson d5d837b720 fix(deploy-suite): handle __dirname, __filename, and require.resolve in CJS→ESM converter (#1217)
The CJS→ESM converter extracted in #1209 only handled require("mod") in
declaration and expression contexts plus module.exports = X. Real-world
next.config.{js,ts} fixtures in the deploy suite also use:

- require.resolve('./my-adapter.mjs') — cache-components, edge-pages-support,
  adapter-dynamic-metadata
- __dirname references in the config body — webpack-loader-set-environment-variable,
  next-config-ts/node-api-cjs
- __filename references (rarer)

These were left untransformed, producing ReferenceError at config load time
and failing the entire test app's deploy.

Fix: after running the existing regex pipeline, detect any remaining
__dirname / __filename / require.* references and prepend a small ESM
prelude that polyfills them via fileURLToPath / createRequire(import.meta.url).
The prelude is only injected when actually referenced, so simple configs
stay unchanged.

Affected suites:
- test/e2e/app-dir/cache-components/cache-components.server-action.test.ts
- test/e2e/edge-pages-support/index.test.ts
- test/e2e/edge-pages-support/edge-document.test.ts
- test/e2e/app-dir/app-prefetch-static/app-prefetch-static.test.ts
- test/e2e/app-dir/webpack-loader-set-environment-variable/...test.ts
- test/e2e/app-dir/next-config-ts/node-api-cjs/...test.ts
- test/e2e/app-dir/adapter-dynamic-metadata/...

The 'Cannot find module @next/bundle-analyzer' subset of the cluster
(converter rewrites require('@next/bundle-analyzer') to ESM but the package
isn't installed) is intentionally left to the missing-deps agent.

Refs run 25870737355.
2026-05-15 08:26:37 +00:00
James Anderson 875f314c11 fix(deploy-suite): handle missing optional deps in test fixture configs (#1213)
Three classes of missing-dep build failures in the Next.js deploy harness:

1. `@next/bundle-analyzer` (test/e2e/app-dir/metadata-{font,edge,dynamic-routes})
   These fixtures wrap the require in `if (process.env.ANALYZE)`, but our
   CJS→ESM converter hoisted `require('mod')(args)` to a static top-level
   `import`, which unconditionally tried to resolve the package and failed
   the build even when ANALYZE was unset.

   Fix: rewrite `const X = require('mod')(args)` to an inline
   `(await import('mod')).default(args)` instead of hoisting. This preserves
   CJS lazy semantics — the import only runs when the surrounding code does.

2. `webpack` (test/e2e/app-dir/next-config/next.config.js)
   Top-level `require('webpack').sources.RawSource` — Next.js's own monorepo
   has webpack at the workspace root, but the test fixture's package.json
   doesn't declare it. Inject `webpack@^5.99.0` into the harness-deps block.

3. `ipaddr.js` (test/e2e/type-module-interop, test/e2e/app-dir/app-middleware)
   vinext's image-config shim imports `ipaddr.js`, which is in vinext's
   `dependencies`. But vinext is installed via `file:.vinext-local-package`,
   so pnpm keeps its transitive deps under `.vinext-local-package/node_modules`
   and Node's ESM resolver can't see them from `<test-app>/dist/...` at
   prerender time. Inject `ipaddr.js` into the harness-deps list (spec sourced
   from the vinext catalog).

Affected suites:
- test/e2e/app-dir/metadata-font
- test/e2e/app-dir/metadata-edge
- test/e2e/app-dir/metadata-dynamic-routes
- test/e2e/app-dir/next-config
- test/e2e/type-module-interop
- test/e2e/app-dir/app-middleware (prerender failure)

Verified:
- `bash -n scripts/*.sh`
- `node --check scripts/cjs-to-esm-config.mjs`
- Round-tripped each of the four fixture next.config.js files through the
  patched converter; output is syntactically valid and loads without error
  when ANALYZE is unset (and fails with the expected ERR_MODULE_NOT_FOUND
  when ANALYZE=1, matching CJS lazy semantics).

Refs: workflow run 25870737355
2026-05-14 19:24:06 +00:00
James Anderson f91d478d9f test(link-navigation): sequence link.js and importActual to fix flake (#1218)
The "clicking an RSC Link starts app-router navigation inside a React
transition" test ran link.js's dynamic import and vi.importActual("react")
in parallel via Promise.all. When importActual won the race, "react" got
cached as the actual module before the doMock factory ran, so link.tsx's
transitive `import React from "react"` resolved to the unmocked module.
That made React.startTransition the real implementation rather than the
spy, causing toHaveBeenCalledTimes(1) to occasionally see 0.

Sequencing the imports — load link.js first (with the doMock factory in
effect), then grab React via importActual — eliminates the race.

Refs: https://github.com/cloudflare/vinext/actions/runs/25878626751/job/76052351860
2026-05-14 19:01:55 +00:00
James Anderson 040eef55ae fix(deploy-suite): inject jiti when test app uses TypeScript config files (#1211)
Test apps using `next.config.ts`, `postcss.config.{ts,mts,cts}`, or
`.postcssrc.ts` fail at `vinext build` time with:

  Failed to load PostCSS config: 'tsx' or 'jiti' is required for the
  TypeScript configuration files. Make sure it is installed
  Cannot find package 'jiti' imported from
    .../vite/dist/node/chunks/node.js

Next.js auto-installs one of jiti/tsx as part of its own build pipeline
when it detects a TypeScript config file. Once vinext replaces Next.js
as the deploy target, the test app's package.json no longer lists
either, and Vite's bundled PostCSS / Tailwind / etc. config loaders
fail to load `*.config.ts` files.

Fix: extend the harness-deps injector in scripts/e2e-deploy.sh to scan
the test app root for `*.config.{ts,mts,cts}` files (and the dotfile
`.postcssrc.ts` variants) and inject `jiti` into devDependencies
when present. jiti is the lighter option (no native deps) and matches
what Vite's internal loader prefers.

Pinned to `^2.6.1` to match the version already resolved transitively
in this workspace via `@tailwindcss/node`. Both postcss-load-config and
Vite require `jiti >=1.21.0`; `^2.6.1` satisfies that.

Affected suites (~20 build-time failures in run 25870737355):
- test/e2e/app-dir/next-config-ts/*
- test/e2e/app-dir/next-config-ts-native-mts/*
- test/e2e/app-dir/next-config-ts-native-ts/*
- test/e2e/postcss-config-ts/

Verified locally:
- `bash -n scripts/*.sh` passes
- Manually exercised the embedded Node block against synthetic test
  apps with next.config.ts, postcss.config.mts, .postcssrc.ts, and a
  negative control (next.config.js only). jiti injection fires for all
  three positives and stays out for the negative.

Refs: https://github.com/cloudflare/vinext/actions/runs/25870737355
2026-05-14 18:46:22 +00:00
James Anderson eccee1843e fix(deploy-suite): apply tsconfig compilerOptions.paths when loading next.config.ts (#1215)
Next.js's own loader (packages/next/src/build/next-config-ts/transpile-config.ts)
reads compilerOptions.paths + baseUrl from tsconfig.json and passes them to SWC
so next.config.ts can import via tsconfig aliases like:

    import { foo } from '@/foo'

vinext's loadNextConfig() calls Vite's runnerImport(), but did not configure any
resolve.alias entries, so the same imports fail with:

    [vinext] Failed to load next.config.ts:
      Cannot find module '@/foo' imported from '<dir>/next.config.ts'

This is a parity gap surfaced by the Next.js deploy test suite.

Fix: extract a small tsconfig-paths loader (config/tsconfig-paths.ts) that
reads compilerOptions.paths (following extends, honoring baseUrl) and returns
absolute Vite resolve.alias entries. loadNextConfig now passes those aliases
into runnerImport's inlineConfig.

Affected suites (from run 25870737355):

  - e2e/app-dir/next-config-ts/import-alias-paths-only/
  - e2e/app-dir/next-config-ts/import-alias-paths-with-baseurl/
  - e2e/app-dir/next-config-ts/import-js-extensions-cjs/
  - e2e/app-dir/next-config-ts/import-js-extensions-esm/
  - e2e/app-dir/next-config-ts-native-ts/dynamic-import-esm/
  - e2e/app-dir/next-config-ts-native-mts/import-from-node-modules/

Verification:
  - 9 new unit tests for loadTsconfigPathAliasesForRoot
  - 4 new unit tests covering loadNextConfig + tsconfig paths (ported from
    Next.js's import-alias-paths-only / import-alias-paths-with-baseurl
    fixtures), including extends + jsconfig fallback
  - Reproduced against .nextjs-ref/test/e2e/app-dir/next-config-ts/
    import-alias-paths-only/ — config now loads with `foo === "foo"`
  - vp check (fmt + lint + types) clean
  - bash -n scripts/*.sh clean

Refs: cloudflare/vinext#actions/runs/25870737355
2026-05-14 19:36:21 +01:00
James Anderson 289ab18b14 fix(deploy-suite): bump pinned sass below ^1.70.0 for Vite 8 (#1214)
The Next.js scss e2e fixtures pin `sass: '1.54.0'` (and some scss-modules
fixtures pin `sass-embedded: '1.75.0'`) via createNextDescribe's
`dependencies` option. Vite 8's built-in `vite:css` preprocessor calls
`sass.initAsyncCompiler()`, an API added in dart-sass 1.70.0 (Feb 2024).
Vite 8 declares `sass@^1.70.0` / `sass-embedded@^1.70.0` as optional
peers, but the fixture's older pin wins during install.

Result: every test/e2e/app-dir/scss/* and test/e2e/app-dir/scss-modules/*
suite fails at vinext build time with:

  TypeError: [sass] sass.initAsyncCompiler is not a function

(see run 25870737355, ~31 deploy-script failures from this cluster).

Fix: in the deploy harness's dependency-injection step, after a test
app's package.json has been augmented with vinext + Vite plugins, check
for a `sass` or `sass-embedded` entry pinned below `^1.70.0` (parsed
from the leading `major.minor` of common semver specs) and rewrite it to
`^1.70.0`. Non-version specs (workspace:, file:, git+, tags) are left
alone. This is a no-op for test apps that don't depend on sass.

Verified locally by running the patched harness against the multi-page
scss fixture: the resolved pnpm path goes from `sass@1.54.0` to
`sass@1.99.0`, and the build advances past the SCSS preprocessor (a
separate JSX-in-Pages-Router error then trips it, tracked as its own
cluster).
2026-05-14 18:32:14 +00:00
James Anderson aa5ec73ada docs(vinext): clarify middleware.ts deprecation warning is non-fatal (#1212)
The deploy-suite investigation in run 25870737355 flagged this warning
as a build-failure root cause. It is not. middleware.ts is still fully
supported in Next.js 16 and in vinext; the message is purely a
deprecation notice (console.warn), and none of the 26 build failures in
the cluster are caused by it. The real failures are unrelated (wasm
imports, missing CSS modules, URLPattern, server-only validation, and
prerender param bugs) and belong to other clusters.

This commit only updates the JSDoc to:

- Explain why the warning may appear 2-3 times per build (Vite calls
  the plugin config hook once per environment).
- Note that Next.js itself emits the same message via Log.warnOnce, and
  point to where parity would live.
- Add an inline comment warning future readers not to change this
  console.warn to a throw / process.exit.

No behaviour change. The warning is intentional and stays a warning.

Refs: https://github.com/cloudflare/vinext/actions/runs/25870737355
2026-05-14 19:31:02 +01:00
James Anderson bc8cee2385 fix(deploy-suite): extract CJS->ESM converter to standalone script (#1209)
* fix(deploy-suite): extract CJS->ESM converter to standalone script

The CJS->ESM next.config converter was inlined into scripts/e2e-deploy.sh
via 'node -e ...' with bash single-quote escapes ('"'"'). The escaping
rebalanced quotes such that later code (a comment containing '(e.g.') sat
outside any single-quoted bash region, causing 'bash -n' to fail with:

  scripts/e2e-deploy.sh: line 490: syntax error near unexpected token `('

The script died before doing anything in every deploy-suite shard, producing
2480 identical 'Custom deploy script failed: undefined (2)' failures and a
misleading 9.5% pass rate (almost all 'passes' were 'should skip next deploy'
no-ops). See run 25869359604.

- Extract the converter to scripts/cjs-to-esm-config.mjs (same regex
  pipeline, no behavioural change).
- Replace the inlined 'node -e' block with a plain 'node ./script.mjs'
  invocation.
- Add a 'bash -n scripts/*.sh' gate to the Check job so a future regression
  here can't ship green.

* Apply suggestion from @james-elicx
2026-05-14 16:04:09 +00:00
James Anderson 843df83de9 ci(deploy-suite): default to all suites and disable test retries (#1208)
The Next.js deploy suite defaulted to only running app router tests with
3 attempts per test (run-tests.js DEFAULT_NUM_RETRIES = 2). Switch the
default suite-filter to 'all' so nightly runs cover pages router too,
and pass --retries 0 so failures surface on the first attempt instead
of masking flakes behind retries.
2026-05-14 15:27:24 +00:00
James Anderson a6d9826613 fix(shims): add runtime default export to next/app (#1204)
* fix(shims): add runtime default export to next/app

Pages Router fixtures using the canonical `pages/_app.js` pattern
(`import App from 'next/app'; export default class MyApp extends App`)
currently fail to build under vinext with:

  [MISSING_EXPORT] "default" is not exported by
  "node_modules/.../vinext/dist/shims/app.js"

The existing shim only re-exported a type as default. This adds a real
runtime class component matching Next.js's behaviour:

- `App` extends React.Component, renders `<Component {...pageProps} />`.
- `App.getInitialProps(appContext)` returns `{ pageProps }`, invoking the
  wrapped page's `getInitialProps` if defined.
- `App.origGetInitialProps` is preserved alongside `getInitialProps`.
- AppProps, AppContext, AppInitialProps types are exported.

Ported from:
  https://github.com/vercel/next.js/blob/canary/packages/next/src/pages/_app.tsx

This unblocks 51 build failures in the Next.js deploy suite that all
trace back to the missing default export.

* fix(shims): expose App class + types in next-shims.d.ts; document null coercion

Address two nits from the PR #1204 bonk review:

1. `next-shims.d.ts` still declared the old minimal `AppProps`-only shape.
   External `.d.ts` consumers (editor, type checks against the published
   package) couldn't see the new runtime class, `AppContext`, or
   `AppInitialProps`. Update the `declare module 'next/app'` block to
   declare the full surface added in the shim:
     - default `class App<P, CP, S>` extending `React.Component` with
       `static origGetInitialProps` / `static getInitialProps` and a
       `render(): React.ReactNode` method
     - `AppContext` and `AppInitialProps<P>` type aliases
     - Expanded `AppProps<P>` shape (router, __N_SSG/__N_SSP flags)

2. `appGetInitialProps` silently coerces a null/undefined return from
   `Component.getInitialProps` to `{}`. Next.js's `loadGetInitialProps`
   throws in that case. Add an inline comment at the coercion site
   noting the divergence, linking to the upstream source, and giving
   future maintainers a bread crumb when they're debugging Pages Router
   pages that render with mysteriously empty props.
v0.0.50
2026-05-14 16:14:44 +01:00
James Anderson 36d2ec560d fix(shims): add withRouter HOC to next/router (#1203)
* fix(shims): add withRouter HOC to next/router

Add the `withRouter` higher-order component export to
`packages/vinext/src/shims/router.ts`, ported from
`packages/next/src/client/with-router.tsx` in Next.js.

`withRouter` is used primarily by class components (which cannot call
hooks) to receive the Pages Router `router` instance as a prop. Real
Next.js apps and the Next.js deploy test suite import it from
`next/router`; without this export, bundlers fail with
`[MISSING_EXPORT] "withRouter" is not exported by ".../shims/router.js"`,
which is currently the cause of 9 build failures in the deploy suite.

The implementation mirrors Next.js's HOC: it calls `useRouter()` and
forwards the router as a `router` prop, forwards `getInitialProps` and
`origGetInitialProps` from the composed component, and sets a
`withRouter(<Name>)` displayName in development.

Also exports the `WithRouterProps` and `ExcludeRouterProps` types
(commonly imported by typed Pages Router apps) and adds matching
`declare module "next/router"` entries.

* fix(shims): match Next.js spread order in withRouter and tidy tests

Address bonk review on PR #1203:

1. (Correctness) Match the Next.js prop-spread order in `withRouter`.
   Next.js's with-router.tsx renders
     <ComposedComponent router={useRouter()} {...props} />
   so the injected `router` is placed FIRST and `{...props}` is
   spread after — a user-passed `router` prop wins by last-spread.
   The previous implementation spread `props` first and then
   re-assigned `router`, which silently swallowed any user-passed
   `router` prop. Now matches Next.js exactly.

   Added a regression test that passes a sentinel `router` prop and
   asserts the user-passed value reaches the composed component.

2. (Cleanup) Extracted the repeated globalThis.window mock and
   vi.resetModules() teardown in the withRouter describe block into
   beforeEach/afterEach. Cuts ~60 lines from tests/shims.test.ts.
2026-05-14 16:13:18 +01:00
James Anderson a713463894 fix(shims): add unstable_cacheLife and unstable_cacheTag to next/cache (#1202)
Next.js exports unstable_cacheLife and unstable_cacheTag as deprecation
aliases that emit a one-time console.error and delegate to the stabilized
cacheLife / cacheTag. Several Next.js apps in the deploy suite still
import the unstable_ names, so vinext apps fail to build with
[MISSING_EXPORT] errors. Match Next.js's behavior exactly: same warning
text, same one-time-per-process gating, same delegation.
2026-05-14 16:12:21 +01:00
James Anderson a39047432d fix: expose window.next global for Next.js parity (#1201)
* fix: expose window.next global for Next.js parity

Next.js publishes window.next from both the Pages Router client bootstrap
(packages/next/src/client/next.ts) and the App Router bootstrap
(packages/next/src/client/app-bootstrap.ts). Userland code and the Next.js
deploy test suite read window.next.router directly (push, replace,
events.on, etc.), and Next.js's own client code reads
window.next.__pendingUrl and window.next.__internal_src_page for App
Router navigation bookkeeping.

vinext did not install this global, which surfaced as ~422 console errors
and 30+ runtime failures in the Next.js deploy test suite, with
"TypeError: Cannot read properties of undefined (reading 'router')" the
most cited.

Adds an installWindowNext helper in client/window-next.ts that both router
bootstraps invoke:

- Pages Router (shims/router.ts) installs window.next.router pointing at
  the Router singleton at module load.
- App Router (server/app-browser-entry.ts) installs
  window.next = { appDir: true, router: appRouterInstance } before
  hydration starts, mirroring app-bootstrap.ts.

The helper merges fields on repeat calls so hybrid Pages+App setups
behave like Next.js (the App Router bootstrap wins for router/appDir,
matching Next.js's load order). __pendingUrl and __internal_src_page
remain undefined until the navigation paths populate them; that matches
Next.js's behavior on initial load.

* fix(window-next): address review — drop unused exports, wire real version, remove module fallback

- Drop `export` from `AppRouterPublicInstance` and `WindowNext` types
  (knip flagged them as unused; they're only used within window-next.ts).
- Drop `getWindowNext()` and its export — tests read `window.next`
  directly, which is the same surface the runtime callers use.
- Wire up `process.env.__NEXT_VERSION` via Vite `define` in index.ts,
  reading the vinext package version from packages/vinext/package.json
  at plugin load. This mirrors Next.js's own DefinePlugin substitution
  (packages/next/src/client/next.ts line 5) so library code that branches
  on `process.env.__NEXT_VERSION` sees a real value and
  `window.next.version` lands the package version instead of the
  `"vinext"` fallback.
- Drop the module-level `installedNext` fallback. `installWindowNext`
  now reads and writes through `window.next` directly so deleting
  `window.next` from tests/userland fully resets state — no stale
  reference can be resurrected on the next call.
2026-05-14 15:08:48 +01:00
James Anderson 3f4e594e1c fix(deploy-suite): create .next/trace before build to avoid ENOENT noise on build failure (#1200)
The Next.js deploy test harness (test/lib/next-modes/base.ts) calls
lstat('.next/trace') during destroy() and throws ENOENT if the file
doesn't exist. vinext doesn't produce a .next/trace (it's a Next.js
telemetry file), so the deploy script creates an empty one as a stub.

That stub was created AFTER 'vinext build' (line ~520), so any run where
the build (or any earlier step like 'pnpm install' / 'vinext init')
failed left .next/trace missing. The test harness then logged ENOENT for
every failed test — 303 lines per deploy-suite workflow run.

Move the stub creation to immediately after the cleanup trap is
installed, before any step that can fail. The cleanup trap runs on EXIT,
so debug artifacts are still preserved; this just ensures .next/trace is
present for the test harness regardless of whether the build succeeded.

Note: VITE_GIT_HOOKS=0 used because vp check --fix errors out when no
JS/TS files are staged (only a .sh file is changed here).
2026-05-14 13:31:08 +00:00
James Anderson 747bb0ec76 ci: add Next.js deploy suite harness (#1189)
* ci: add Next.js Pages Router deploy suite harness

Add the workflow, scripts, and manifest generator for running the
Next.js Pages Router deploy test suite against vinext.

Triggers:
- Nightly cron (06:00 UTC) against main
- Manual dispatch with configurable vinext-ref, next-ref, and concurrency

Extracted from #922.

* refactor: align deploy suite harness with Next.js adapter testing docs

- Rename scripts to match Next.js convention: e2e-deploy.sh, e2e-logs.sh,
  e2e-cleanup.sh
- Split workflow into build + test jobs (build once, shard 16 ways) per
  the documented adapter testing pattern
- Generalize workflow from pages-only to configurable: add suite-filter
  input (pages/app/all, defaults to pages)
- Add --filter flag to manifest generator (replaces the pages-hardcoded
  version)
- Accept ADAPTER_DIR (Next.js docs convention) alongside VINEXT_DIR
- Rename workflow file to nextjs-deploy-suite.yml
- Use matrix.group strings ("1/16") instead of shard integers, matching
  the docs example

* ci: default suite-filter to app

* ci: bump Next.js default to v16.2.6, move cron to 02:00 UTC

* address review: add report job, clean up cache paths, document compat shims

- Add a report job that aggregates .results.json across all shards into
  a GitHub Actions summary (pass/fail/skip counts, failed test details,
  passed suite list) and uploads a JSON report artifact
- Upload test results from every shard (not just on failure)
- Remove redundant next.js/ from cache paths (already under .)
- Add comment explaining why IS_TURBOPACK_TEST=1 is needed
- Clean up stale PID file in e2e-cleanup.sh
- Add header comments to all scripts explaining their purpose and contract
- Document the hardcoded warning messages and .next/trace shim
- Document the YAML catalog parser assumptions
- Fix [[ ]] -> [ ] inconsistency in run-nextjs-deploy-suite.sh

* ci: add temporary PR trigger to test workflow [remove before merge]

* fix: address deploy script failures found in CI and local testing

Bugs found and fixed:

- IMMUTABLE_ASSET_TOKEN marker renamed to NEXT_SUPPORTS_IMMUTABLE_ASSETS
  to match what next-deploy.ts parseIdsFromCliOuput() actually parses.
  Without this, every test fails with 'Failed to get supportsImmutableAssets'.

- Deploy script now injects "type": "module" into test app package.json
  and generates a vite.config.ts. Without type:module, Rolldown emits .mjs
  files but the RSC plugin's cross-environment imports expect .js. This
  mirrors what `vinext init` does (steps 3 and 5).

- Added vp (Vite+) fallback to run_pnpm() for environments where pnpm
  and corepack are not available but vp is.

- Cleanup script now uses process group kills (kill -TERM -PID) and a
  port-based lsof fallback to handle orphaned child processes from
  vp/pnpm exec wrappers.

- Error cleanup trap now dumps the last 80 lines of the build log and
  last 40 lines of the server log to stderr, so failures are visible
  in CI output (previously only showed file sizes).

- Test job uses run-install: false since the workspace is already built
  in the cache from the build job.

Tested locally: deploy script succeeds, curl returns valid HTML, logs
script outputs all three required markers (BUILD_ID, DEPLOYMENT_ID,
NEXT_SUPPORTS_IMMUTABLE_ASSETS), cleanup kills the server.

* fix: use vinext init in deploy script, add next.config.js to CJS rename list

Replace the manual type:module / vite.config.ts / CJS rename logic in
e2e-deploy.sh with a single `vinext init --skip-check --force` call.
This runs after pnpm install (so deps are available) and before
vinext build.

Also add next.config.js to the CJS_CONFIG_FILES list in project.ts.
Without this, vinext init adds type:module but doesn't rename CJS
next.config.js files, causing 'module is not defined in ES module
scope' errors. This was the root cause of the CI failures — Next.js
test fixtures use module.exports in next.config.js.

All 61 init tests pass.

* fix: convert CJS next.config.js to ESM instead of renaming to .cjs

Next.js doesn't support next.config.cjs, so we can't rename it. Instead,
convert module.exports/require() to export default/import in-place after
vinext init adds "type": "module".

Also revert adding next.config.js to CJS_CONFIG_FILES — vinext init
should not rename it since it's not a generic config file.

Tolerate pnpm 10+ ERR_PNPM_IGNORED_BUILDS exit code 1 on install (the
install completes, packages are in node_modules, but pnpm exits non-zero
due to unapproved build scripts). Verify node_modules/vinext exists
as a safety check.

Tested against the real Next.js actions-streaming fixture: CJS
next.config.js converted, app builds, server starts, HTTP 200.

* fix: emit both IMMUTABLE_ASSET_TOKEN and NEXT_SUPPORTS_IMMUTABLE_ASSETS

v16.2.x parses IMMUTABLE_ASSET_TOKEN, canary renamed it to
NEXT_SUPPORTS_IMMUTABLE_ASSETS. Emit both for cross-version compat.

* fix: enable JSX in .js files, convert CJS next.config.ts to ESM

Add vinext:jsx-in-js transform plugin to handle JSX in plain .js files.
Next.js allows JSX in .js files (Babel/SWC handle it transparently),
but Vite 8's built-in vite:oxc plugin excludes .js files by default
(exclude: /\.js$/) AND infers lang: 'js' from the extension (which
disables JSX parsing). Neither can be fixed via config alone.

The plugin runs with enforce: 'pre' before vite:oxc, transforming .js
files with OXC using lang: 'jsx' so JSX is compiled before vite:oxc
sees the code. This unblocks ~60 test suites in the deploy suite.

Also extend the deploy script's CJS-to-ESM config converter to handle
next.config.ts files (not just .js), fixing ~6 more test failures.

Tested: 1284 tests pass (jsx-in-js + shims + routing + app-router),
zero regressions.

* ci: trigger clean workflow run

* fix: improve CJS-to-ESM converter for next.config files

The naive regex converter missed two patterns:
- require('mod')(args) produced invalid ESM (import X from 'mod'(args))
- require() calls inside function bodies were not converted

New converter handles:
- module.exports = X → export default X
- const X = require('mod') → import X from 'mod'
- const X = require('mod')(args) → import _X from 'mod'; const X = _X(args)
- require('mod') in expressions → (await import('mod')).default

Tested against bundle-analyzer, path, and inline require patterns.

* .

* Apply suggestion from @james-elicx

* fix: handle destructured requires in CJS converter, read port from file in cleanup

- Add pattern for const { a, b } = require('mod') → import { a, b } from 'mod'
- Read port from PORT_FILE in cleanup_on_error trap instead of using $PORT
  variable which may be unset if the script fails before port allocation
- Add TODO noting remaining CJS edge cases (dynamic require, require.resolve)

* Apply suggestions from code review

Co-authored-by: James Anderson <james@eli.cx>

* Apply suggestion from @james-elicx
2026-05-14 13:01:01 +00:00
James Anderson 81d5426ee6 fix: enable JSX in plain .js files (Next.js compat) (#1199)
* fix: enable JSX in plain .js files (Next.js compat)

Add vinext:jsx-in-js transform plugin to handle JSX in plain .js files.
Next.js allows JSX in .js files (Babel/SWC handle it transparently),
but Vite 8's built-in vite:oxc plugin excludes .js files by default
(exclude: /\.js$/) AND infers lang: "js" from the extension (which
disables JSX parsing). Neither can be fixed via config alone.

The plugin runs with enforce: "pre" before vite:oxc, transforming .js
files with OXC using lang: "jsx" so JSX is compiled before vite:oxc
sees the code.

Tested: 972 tests pass (jsx-in-js + shims + routing), zero regressions.

* fix: suppress rules-of-hooks lint for Playwright fixture use()

The oxlint-disable-next-line only covered the function signature line,
not the use() call deeper in the function body. Use block-scoped
disable/enable to suppress the entire fixture definition.
2026-05-14 13:49:33 +01:00
Stephen Zhou 9126344cd8 fix: use loader for client reference (#1141)
* fix: split client chunk

* add test

* fix ci deps

* install deps

* use loader

* tweaks
2026-05-14 13:18:58 +01:00
Nathan Nguyen 6cf4148ffd fix(link): keep auto prefetch from fetching dynamic app routes (#1177)
* fix(link): keep auto prefetch from fetching dynamic app routes

App Router Link treated null, undefined, and auto prefetch as the same full RSC prefetch used by prefetch={true}. That violates Next.js's public prefetch contract and over-fetches dynamic route payloads as soon as links enter the viewport.

The broken assumption was that prefetch only needed a boolean enabled state. Split Link prefetch into disabled, auto, and full modes, emit a small browser route manifest, and allow automatic full RSC prefetch only for matched static app routes while preserving explicit full prefetch.

Add focused tests for the mode mapping and generated route manifest.

* test(link): opt interception prefetch fixture into full mode

The interception-context E2E asserts that full RSC prefetch cache keys stay separate by source context. After auto prefetch stops full-fetching dynamic routes, that fixture needs to request the full prefetch mode explicitly.
2026-05-14 09:25:53 +01:00
한만욱 275f8bdd73 fix(shims): add placeholder useRouter bfcacheId (#1192) 2026-05-14 08:21:28 +00:00
Nathan Nguyen e534e9bdc3 fix(app-router): promote mounted parallel slot preservation (#1191)
* fix(app-router): promote mounted parallel slot preservation

Mounted slots were still preserved by treating missing AppElements slot keys as proof during promoted same-root commits. That kept legacy wire absence as semantic authority after layout persistence moved into the planner.

The planner snapshot now carries mounted parallel slot ownership and approves preservation only when the slot owner layout is in the retained same-root layout prefix. Approved commits pass that decision to mergeElements, while unknown-root fallbacks keep the legacy absent-slot path fenced.

* test(app-router): lock traverse slot cleanup precedence

Traverse commits already clear absent slots after copying planner-approved preserved ids. That ordering means a mounted parallel slot can be approved by the planner and still be removed when the traverse payload omits it.

Cover that precedence explicitly so the mounted-slot preservation slice keeps navigation slot preservation and traversal cleanup as separate contracts.

* fix(app-router): align traverse slot preservation metadata

Traverse commits clear absent slots, so a planner proposal should not name mounted slot ids that the visible reducer will remove. That made preserveElementIds semantically wider than the commit contract.

Split current-root commit preservation by lane so traverse commits preserve the retained layout prefix only, while ordinary current-root commits still preserve mounted slots owned by retained layouts. Cover both the pure planner decision and browser-state approval boundary.
2026-05-14 09:21:12 +01:00
MK (fengmk2) 9d5a2e9a77 chore(deps): bump vite-plus to 0.1.21 (#1190)
- Bump vite, vite-plus, vitest catalog entries to 0.1.21
- Refresh pnpm-lock.yaml
- Regenerate AGENTS.md via vp config postinstall
- Drop unused imports (PHASE_PRODUCTION_BUILD, patternToNextFormat) flagged by upgraded lint rules
- Move react-hooks/rules-of-hooks suppression in tests/e2e/fixtures.ts to the use(errors) call site
- Add react/no-children-prop and react/no-danger-with-children suppressions on the intentional precedence test in tests/head.test.ts
2026-05-14 09:14:31 +01:00
Arpit Jain e33feeb946 ci(benchmarks): pin contents: read (#1188)
The benchmarks workflow uploads the artifact via
actions/upload-artifact (writes only to the run's artifact store, not
the repo) and POSTs results to benchmarks.vinext.workers.dev using
secrets.BENCHMARK_UPLOAD_TOKEN. The GitHub token is only used for the
checkout, so contents: read is sufficient. Top-level style matches
the rest of the repo.
2026-05-13 12:10:46 +01:00
Nathan Nguyen ee261e33eb fix(head): preserve dangerous html during client sync (#1180)
* fix(head): preserve dangerous html during client sync

Client-managed next/head tags currently drop dangerouslySetInnerHTML when hydration or navigation rebuilds vinext-owned head nodes. That diverges from Next.js and can leave SSR-injected script, style, or noscript content empty after the client projection runs.

The client path treated raw HTML as unsafe even though the SSR path already accepts the developer opt-in. Reuse one typed prop application helper for client head nodes and set innerHTML from dangerouslySetInnerHTML with the same precedence as Next.js.

Add regression coverage for client raw HTML application, empty __html fallback, and precedence over children.

* fix(head): handle array children and add children-only test coverage

Address review feedback:
- Handle Array.isArray(children) in _applyHeadPropsToElement by joining
  array elements, matching Next.js reactElementToDOM behavior
- Add test for children-only path (no dangerouslySetInnerHTML) to ensure
  the extracted helper didn't regress the common case
- Add test for array children joining

* fix(head): resolve SSR/client parity gap for empty dangerouslySetInnerHTML

Address second review: dangerouslySetInnerHTML: { __html: '' } was
handled inconsistently between SSR and client. The SSR path used a
truthiness check (if (html)) which skipped empty strings, falling
through to children. The client path checked the prop object (truthy),
so empty __html correctly took precedence.

Fix: restructure headChildToHTML to check dangerouslySetInnerHTML before
children using != null, making both paths consistent with Next.js
semantics where dangerouslySetInnerHTML always wins when present.

* fix(head): unify SSR/client content handling via getDangerouslySetInnerHTML

Address third review round:
- SSR: add Array.isArray(children) handling to headChildToHTML, matching
  the client path for cases like createElement('title', null, 'A', 'B')
- Client: replace truthiness check with getDangerouslySetInnerHTML as
  the gatekeeper (matching SSR pattern). This fixes silent content wipes
  for malformed props like dangerouslySetInnerHTML: 'string' or
  dangerouslySetInnerHTML: {} (missing __html key)
- Both paths now use the exact same entry point and condition shape:
  getDangerouslySetInnerHTML() != null
- Update tests: malformed dangerouslySetInnerHTML: {} now correctly
  falls through to children on both SSR and client

---------

Co-authored-by: James <james@eli.cx>
2026-05-13 11:34:32 +01:00
Nathan Nguyen 2d6978f1f9 fix(link): align app-router prefetch scheduling with intent (#1149)
* fix(link): align app-router prefetch scheduling with intent

Link currently schedules viewport RSC prefetches outside production and does not promote user mouse or touch intent into an immediate prefetch. That diverges from Next.js Link behavior and can waste dev-server work during rapid iteration while missing an important production navigation warmup path.

The shim now gates automatic prefetching to production, shares same-origin prefetch normalization, and schedules high-priority RSC prefetches from mouse and touch intent while preserving user handlers. Focused Link navigation tests cover development suppression, intent prefetch, prefetch=false, and dangerous-link handler passthrough.

* fix(link): align app-router prefetch scheduling with intent

Link currently schedules viewport RSC prefetches outside production and does not promote user mouse or touch intent into an immediate prefetch. That diverges from Next.js Link behavior and can waste dev-server work during rapid iteration while missing an important production navigation warmup path.

The shim now gates automatic prefetching to production, shares same-origin prefetch normalization, and schedules high-priority RSC prefetches from mouse and touch intent while preserving user handlers. Focused Link navigation tests cover development suppression, intent prefetch, prefetch=false, and dangerous-link handler passthrough.

* test: tighten link prefetch coverage

* test(link): tighten prefetch decision coverage

* test(link): address prefetch review notes
2026-05-13 11:33:30 +01:00
James Anderson a27193fcaf chore: upgrade pnpm 10 to 11 and enable frozenLockfile (#1185)
* chore: upgrade pnpm 10 to 11 and enable frozenLockfile

- Bump packageManager from pnpm@10.32.1 to pnpm@11.1.1
- Migrate .npmrc settings to pnpm-workspace.yaml (pnpm 11 only reads auth/registry from .npmrc)
  - auto-install-peers=false -> autoInstallPeers: false
  - Remove playground save-exact=true (minor pnpm add preference, not needed for builds)
- Convert onlyBuiltDependencies list to allowBuilds map (pnpm 11 format)
- Add frozenLockfile: true and preferFrozenLockfile: true
- Delete .npmrc files (contained no auth settings)

* fix: handle pnpm 11 strictDepBuilds, preserve autoInstallPeers, remove minimumReleaseAgeExclude

- Add unrs-resolver: false to allowBuilds (reviewed, deny build scripts)
- Keep autoInstallPeers: false to match existing lockfile
- Remove minimumReleaseAgeExclude entirely
- Append allowBuilds to scaffolded project's pnpm-workspace.yaml after
  create-next-app so subsequent pnpm steps don't abort
2026-05-13 09:15:02 +00:00
Nathan Nguyen d85791c2fb feat(app-router): promote segment reset semantics (#1187)
* feat(app-router): promote segment reset semantics

Implements #726-CORE-11/12 from #726.

Add typed App Router segment state-key derivation, wire template/loading/error/access boundaries to semantic reset keys, and preserve pathname fallback semantics for legacy boundary callers.

Cover dynamic params, route groups, catch-all keys, template reset ownership, and boundary reset behavior.

* fix(app-router): treat empty boundary reset keys as absent

Empty segment identities can be produced for root, route-group-only, and default slot paths. Normalize empty reset keys to null so pathname fallback continues to clear caught boundary state.

* fix(app-router): scope route reset keys to branches

Use typed segment state identities and full visible route-state paths for route-wide wrappers so unrelated branches do not collide on the same leaf value.

Add hostile unit and browser tests for dynamic and static branch collisions.
2026-05-13 10:02:02 +01:00
Yoinky3000 493e72d256 fix(app-router): catch redirect diges, render loading.tsx during cross-route navigation and invalid with calling refresh() (#1152)
* fix(app-router): catch server redirect digest, render loading.tsx across navigation

* Update page.tsx

* fix(app-router): tighten RedirectBoundary digest handling and dedupe nav-signal check

Addresses code-review findings on PR #1152:

- RedirectBoundary.reset() now clears both `redirect` and `redirectType`
  so the state never sits in a half-cleared (`null`, "replace") shape.
- getDerivedStateFromError re-throws on malformed `NEXT_REDIRECT;` digests
  (empty URL segment) instead of silently swallowing the error. Mirrors
  the server-side guard in parseNextRedirectDigest (next-error-digest.ts:51).
- Document the `error.handled` branch as a Next.js parity placeholder so
  it's clear it is not dead code, just unused by vinext today.
- Extract `getErrorDigest` / `isNavigationSignalError` into a shared
  utils/navigation-signal.ts, removing the duplicated digest classification
  in shims/error-boundary.tsx and server/dev-error-overlay.tsx.
- Update stale `snapshotRscResponse` reference in the cached-route comment
  to the current body.tee + createFromFetch flow.
- Strengthen the delayed-redirect-under-loading E2E test to also assert
  the loading.tsx fallback paints during the 50ms server delay.
- Format fix on tests/fixtures/app-basic/app/page.tsx that was failing
  the CI Check job.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(app-router): invalidate client RSC caches on router.refresh()

router.refresh() previously only re-fetched the current URL via the
"refresh" navigationKind, leaving the visited-response and prefetch
caches untouched for sibling routes. After a session change (e.g.
authClient.signOut() → router.push("/") → router.refresh()), a
subsequent <Link> click to a previously visited auth-gated route
would render the stale cached RSC payload and bypass the server's
redirect() entirely.

Matches Next.js's refresh-reducer.ts, whose header comment is
explicit: "During a refresh, we invalidate the segment cache but not
the route cache. The segment cache contains the actual RSC data
which needs to be re-fetched." Next does this via an O(1) version
counter (invalidateSegmentCacheEntries → currentSegmentCacheVersion++);
vinext's bounded LRU is small enough that Map.clear() on the
visited-response + prefetch caches is equivalent in observable
behavior.

Pre-existing in upstream vinext (verified against 8edc010); only
surfaced after the redirect-digest fix earlier in this PR, because
before that the unhandled NEXT_REDIRECT digest aborted the page
render outright and masked the stale-cache reuse.

- Expose clearClientNavigationCaches as window.__VINEXT_CLEAR_NAV_CACHES__
- router.refresh() calls it synchronously before scheduling the RSC
  re-fetch inside React.startTransition. Synchronous ordering matters:
  if the clear ran inside the transition, prefetches kicked off during
  the transition's renders could repopulate stale entries into the
  just-cleared cache.
- Unit test pins the clear-before-navigate ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(app-router): address review — remove HandleRedirect reset, fix boundary nesting, add malformed digest test

Address ask-bonk review comments from all three rounds:

1. Remove reset() from HandleRedirect — calling reset() inside startTransition
   clears the boundary state, re-rendering children and re-mounting the page
   that threw redirect(). For deterministic redirects (auth guards) this
   creates an infinite loop. Next.js intentionally does not call reset() in
   HandleRedirect (redirect-boundary.tsx); the boundary stays in its 'caught'
   state rendering null until router.push()/replace() mounts fresh children.

2. Move RedirectBoundary inside Suspense (loading boundary) — Next.js nesting
   is Error > Loading > AccessFallback > Redirect > content. The previous
   placement had RedirectBoundary outside Suspense, which unmounted the
   loading fallback during redirect transitions. Now RedirectBoundary wraps
   the page slot directly, and Suspense wraps both.

3. Add comment documenting dual-format digest parsing — the parts.length >= 5
   branching handles both vinext 3/4-part and Next.js 5-part digests, which
   was non-obvious to reviewers.

4. Add test for malformed-digest re-throw guard — verifies that
   getDerivedStateFromError re-throws when the URL segment is empty
   (e.g. NEXT_REDIRECT;push;) rather than silently swallowing the error.

* fix(app-router): correct nesting-order comment, add error.handled test

Address ask-bonk round-4 review:

1. Fix the boundary nesting comment in app-page-route-wiring.tsx — it
   incorrectly claimed the order matches Next.js's
   Error > Loading > AccessFallback > Redirect > content. The actual
   vinext order is Error > AccessFallback > Loading > Redirect > content;
   the AccessFallback positioning is a pre-existing divergence.

2. Add test for the error.handled branch in RedirectErrorBoundary —
   verifies the Next.js parity placeholder returns null state when a
   redirect error is already marked as handled by an outer boundary.

* refactor: use shared header constants instead of string literals

Replace two hardcoded header strings in the visited-response cache
snapshot with the already-imported VINEXT_MOUNTED_SLOTS_HEADER and
VINEXT_PARAMS_HEADER constants from server/headers.ts.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
2026-05-12 16:48:15 +00:00
James Anderson 996d9b4f3d refactor: extract internal HTTP header names into shared constants (#1184)
* refactor: extract internal HTTP header names into shared constants

Internal header name strings (x-vinext-*, x-middleware-*, x-action-*,
RSC protocol headers, etc.) were scattered across ~30 files as raw
string literals. This made typos possible and renames difficult.

Add packages/vinext/src/server/headers.ts as the single source of
truth for all ~40 internal header name constants, organized into
categories: vinext-proprietary, RSC protocol, Next.js compat,
server action response, middleware protocol, flight headers, and
the INTERNAL_HEADERS security blocklist.

Update all consumers to import from the shared module. Existing
re-exports (VINEXT_RSC_MOUNTED_SLOTS_HEADER, VINEXT_RSC_RENDER_MODE_HEADER
from app-rsc-cache-busting.ts; FLIGHT_HEADERS from app-middleware.ts;
INTERNAL_HEADERS from request-pipeline.ts) are preserved for backward
compatibility.

* refactor: un-export header constants only used in INTERNAL_HEADERS array

knip flagged 6 constants as unused exports. They are only referenced
within the same file by the INTERNAL_HEADERS array, so drop the export
keyword and keep them as module-scoped const declarations.

* refactor: address PR review comments

- server-action-not-found.ts: use rename-import instead of intermediate
  const alias (import { X as Y } pattern)
- request-pipeline.ts: use 'export { INTERNAL_HEADERS } from' re-export
  pattern instead of aliased import + re-assignment, matching the style
  used by other re-exports in this PR

* refactor: import VINEXT_RSC_RENDER_MODE_HEADER from headers.js directly

Move the import in app-rsc-request-normalization.ts from the re-export
in app-rsc-cache-busting.js to the canonical headers.js module.

* refactor: migrate shims/link.tsx to import from headers.js directly

Move VINEXT_MOUNTED_SLOTS_HEADER import from the deprecated re-export
in app-rsc-cache-busting.ts to the canonical headers.ts module. This
was the last production consumer of the VINEXT_RSC_MOUNTED_SLOTS_HEADER
alias, so remove that deprecated export entirely.
2026-05-12 17:17:10 +01:00
dependabot[bot] e9d1749b95 chore(deps): bump next from 16.2.5 to 16.2.6 (#1183)
* bump nextjs

* .

---------

Co-authored-by: James <james@eli.cx>
2026-05-12 14:18:00 +00:00
Nathan Nguyen ac75d43e7e fix(app-router): preserve UI during action refresh (#1147)
* fix(app-router): preserve UI during action refresh

Server action responses currently always render a page payload, and router.refresh can put BrowserRoot into a pending state that mounts loading.tsx while the same route refresh resolves. That diverges from Next.js, where non-revalidating actions can skip page rendering and refresh/revalidation should preserve the current page UI.

The server action path now omits the root payload when no data was invalidated, suppresses loading boundaries for action rerenders, and carries the same suppress-loading variant through RSC request, cache-busting, and ISR cache keys. The browser action callback now resolves no-root action payloads directly and avoids pending router state for refresh.

Regression coverage includes the no-rerender action payload, loading-boundary suppression, cache key variants, cache-busting compatibility, request normalization, and an app-router Playwright case that catches route loading mounts after action plus router.refresh.

* fix(app-router): carry semantic RSC render modes

Replace the cross-layer suppress-loading boolean with AppRscRenderMode so browser requests, request normalization, rendering, cache-busting, Vary, and ISR keys all carry the same semantic payload variant.

Decode server action responses before clearing client navigation caches so no-root return-only action results keep visited response and prefetch caches intact while redirects, root payloads, and legacy bare payloads still invalidate.

Add coverage for refresh() inside server actions and for normal versus preserve-current-UI ISR RSC cache keys.

* fix(app-router): default helper render mode to navigation

* docs: add review-requested comments for structural discriminator and ISR key format

* fix: add explicit null case to parseAppRscRenderMode switch for exhaustiveness check

---------

Co-authored-by: James <james@eli.cx>
2026-05-12 15:09:45 +01:00