Commit Graph

1925 Commits

Author SHA1 Message Date
James Anderson 746d6b5970 chore(ci): bump Node to 24 for native URLPattern support (#1283) 2026-05-16 19:33:46 +01:00
Nathan Nguyen aec23e3b68 fix(image): preserve fill positioning for remote images (#1265)
Remote Image with fill=true currently routes through Unpic fullWidth, which renders a flowing image without data-nimg="fill" or absolute positioning. That violates Next.js's fill invariant for parent-filling layouts.

Centralize fill styles and render remote fill with the same DOM contract as local fill and getImageProps. Add SSR regression coverage for remote fill.
2026-05-16 19:29:15 +01:00
Nathan Nguyen 7d1f482050 fix(link): preserve unsafe href click handlers (#1264)
Vinext strips dangerous Link href values before rendering the anchor. That keeps javascript: URLs inert, but the unsafe render branch also dropped the developer-provided onClick handler, so links used as click targets stopped working after hydration.

The dangerous-href branch now preserves the anchor ref, link status provider, and user click handler while continuing to omit href. The regression fixture verifies that the click handler runs and the browser URL stays unchanged.
2026-05-16 19:13:44 +01:00
Nathan Nguyen 25ac4db7f9 fix(link): preserve native download clicks (#1262)
Link currently treats anchors with the HTML download attribute like normal same-origin SPA navigations. That prevents the browser from performing its native download behavior and can also run onNavigate for a click that should never become client-side navigation.

The click handler now bails out after user onClick and before preventDefault whenever the anchor has a download attribute, matching Next.js Link semantics. A focused regression test covers the default action, onNavigate, transition, and RSC navigate boundaries.
2026-05-16 19:12:30 +01:00
James Anderson 7790a0a1ef fix(edge): expose AsyncLocalStorage as a global in edge runtime (#1276)
Next.js's edge sandbox installs `AsyncLocalStorage` on the runtime's
global context (see `packages/next/src/server/web/sandbox/context.ts` —
`context.AsyncLocalStorage = AsyncLocalStorage`). vinext executes user
code directly on the Cloudflare Workers runtime, which only exposes
`AsyncLocalStorage` via `import { AsyncLocalStorage } from "node:async_hooks"`
under the `nodejs_compat` flag, not as a global.

Edge fixtures like `.nextjs-ref/test/e2e/edge-async-local-storage/` do
`new AsyncLocalStorage()` without an import and were failing with
`ReferenceError: AsyncLocalStorage is not defined`.

Install `AsyncLocalStorage` on `globalThis` (idempotently) from every
entry point that may evaluate user edge code: the App Router worker
entry, the middleware runtime, and the Pages API route runtime.

A TODO marks the spot for future consolidation with the parallel
`URLPattern` work into a single `installEdgeGlobals()` helper.
2026-05-16 19:08:32 +01:00
Nathan Nguyen 496a0247af fix(app-router): emit streamed redirect and not-found meta tags (#1261)
* fix(app-router): emit streamed redirect and not-found meta tags

Streaming App Router SSR currently returns a 200 once the shell commits, but redirect() or notFound() discovered inside a later Suspense boundary never emits the browser-visible meta tag that Next.js relies on. That leaves the control-flow digest only in the RSC payload until client hydration handles it.

The SSR onError path now captures digest-bearing render errors and a request-local flusher injects noindex or refresh meta tags through the existing server-inserted HTML stream hook. The app page pipeline also forwards basePath into SSR so streamed redirect meta tags match early redirect responses.

* fix(app-router): avoid double-prefixing basePath root redirects

Streamed redirect meta tags checked basePath against the full redirect location string. Already-prefixed root URLs with a query or hash, such as /docs?from=checkout, failed the pathname-shaped basePath check and were prefixed a second time.

Add a shared pathname-only basePath prefix helper and use it from both early redirect responses and streamed redirect metadata. The streamed path now splits query/hash before checking the pathname.
2026-05-16 19:07:27 +01:00
Nathan Nguyen 4988436b6c fix(app-router): preserve history metadata for external state updates (#1259)
External history.pushState and history.replaceState currently store caller data as-is. That drops vinext's App Router previousNextUrl marker when apps perform shallow URL updates inside an intercepted route, so later traversal can lose the interception source.

The History API wrappers now copy the current App Router metadata onto caller-provided state before delegating to the browser history method. A focused shim regression covers object, null, and undefined caller state against the same contract Next.js preserves.
2026-05-16 18:57:11 +01:00
James Anderson 3e8db450f4 ci(nextjs-deploy): add test-path filter input (#1280)
Lets workflow_dispatch runs target a single suite (e.g.
test/e2e/edge-async-local-storage) instead of the full ~16-shard run,
shortening the iteration loop when investigating a specific failure.
The compat-ingest step is gated off for path-filtered runs so partial
snapshots don't drag the historical pass-rate trend.
2026-05-16 17:47:22 +00:00
Nathan Nguyen 042ac438b0 fix(link): align visible and intent prefetching (#1258)
* fix(link): reschedule visible prefetches

Visible App Router links were prefetched as one-shot observer callbacks. That lost the mounted visibility state needed to re-prefetch after cache invalidation or router state changes, and automatic dynamic links could not be upgraded to a full prefetch on hover.

The Link shim now keeps mounted prefetch instances, tracks the visible set, exposes an internal ping hook for cache and router commits, and persists the full-prefetch strategy after unstable_dynamicOnHover intent.

Regression coverage verifies visible-link cache invalidation and dynamic-on-hover strategy upshift.

* fix(link): preserve pages intent prefetch

Pages Router Link prefetch=false should only disable viewport prefetch. The shared gate also blocked hover and touch intent prefetches, which matches App Router but diverges from the Pages Router contract.

Split intent eligibility by router mode so App Router keeps the stricter gate while Pages Router intent still prefetches in production. Pages viewport prefetch remains one-shot per mounted link to avoid duplicate document prefetch nodes under the visible-link registry.

Regression coverage verifies App Router prefetch=false intent suppression, Pages Router hover and touch intent prefetch, and Pages viewport re-entry dedupe.
2026-05-16 18:42:01 +01:00
James Anderson 1f2030b8d2 fix(config): load CJS next.config.js under "type": "module" (#1275)
`createRequire` of a `.js` file in a `"type": "module"` package still gets
resolved as ESM by Node (extension + parent type wins over the require()
caller), so the existing CJS fallback in loadNextConfig threw `require is
not defined` for every plain `module.exports = {}` config after `vinext
init`. Worked around in the Nextjs deploy suite by rewriting every
fixture's config from CJS to ESM in-place via scripts/cjs-to-esm-config.mjs.

Copy the source to a sibling temp `.cjs` (extension overrides the parent
type field) and require that instead. Relative imports inside the config
still resolve against the original directory. Drop the deploy-suite
rewrite step and its 145-line shim.
2026-05-16 18:36:22 +01:00
James Anderson eac3ef3e8a refactor(router): dedupe push/replace logic in next/router shim (#1274)
Both useRouter() and the Router singleton carried near-identical push/replace
implementations (~250 lines combined). Extract a shared performNavigation()
helper plus small primitives (updateHistory, dispatchNavigateEvent,
extractHash, prefetchUrl). Behaviour and event ordering are preserved.
2026-05-16 17:21:40 +00:00
Nathan Nguyen b69844ec3f fix(metadata): apply ancestor templates to title defaults (#1256)
Metadata title defaults currently render without the active ancestor template. That diverges from Next.js when a child layout or page provides title.default under a parent layout title.template.

The merge path resolved only the final title after collecting templates, so object defaults skipped the stashed template used for that segment. Resolve each title as it is encountered against the current ancestor template, then stash the current layout template for descendants.

Covers the child-layout default regression and updates page-default expectations to match Next.js resolveTitle semantics.
2026-05-16 18:08:40 +01:00
Nathan Nguyen c84e383ccd fix(server): render progressive action not-found HTML (#1254)
* fix(server): render progressive action not-found HTML

Progressive multipart server actions returned an empty status response when an action called notFound() or another HTTP fallback. That diverged from Next.js' MPA action path, where the action error re-enters app rendering so the not-found boundary produces full HTML.

The root cause was treating HTTP fallback action throws as terminal responses in the action execution helper. Pass action failures through the progressive form-state path, preserve redirects as 303 responses, and rethrow action failures during page element construction so existing special-error rendering handles the response body.

Regression coverage asserts the action helper handoff and the rendered not-found HTML body.

* chore: retrigger CI

* fix(server): forward progressive action failures in RSC entry

* refactor(server): make progressive action failure state explicit

* Apply suggestion from @james-elicx

---------

Co-authored-by: James Anderson <james@eli.cx>
2026-05-16 17:04:15 +00:00
Nathan Nguyen 6663dd10d8 fix(router): decode hash scroll targets (#1255)
* fix(router): decode hash scroll targets

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

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

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

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

Add regression coverage for hash-only Router.push with scroll disabled.
2026-05-16 18:03:35 +01:00
Nathan Nguyen 2468ae5fba fix(app-router): guard stale popstate scroll restoration (#1253)
* fix(server): progressive actions trigger error boundaries on HTTP fallbacks

* fix(server): type progressive action errors

* fix: guard stale popstate scroll restoration with active nav id

* revert(server): drop action error changes from popstate PR

The popstate race branch included server action commits from a separate branch. That made the PR review surface change progressive action error semantics unrelated to browser history restoration.

Restore those files to main's behavior in this branch and keep only the popstate scroll restoration fix. Also correct the new popstate tests so targeted check and unit coverage pass.

* test(app-router): wait for RSC cache seeding

The same-build visited payload E2E asserted cache replay after visible commit from a void router.push. The visited-response cache is populated when the internal RSC navigation promise resolves, so the test could navigate away before the cache entry existed and count a second fetch.

Wrap the internal navigation function in the test and wait for the captured promise before asserting the replay path.
2026-05-16 17:54:34 +01:00
Nathan Nguyen 26030210b0 fix(app-router): throw a typed error for unrecognized server actions (#1250)
* refactor(navigation): extract UnrecognizedActionError into a shared module

`UnrecognizedActionError` and `unstable_isUnrecognizedActionError` were
defined inline in the `next/navigation` shim. The client server-action
dispatcher needs to throw that exact class for the predicate's
`instanceof` check to recognize it, but `instanceof` is identity-based
per module instance, and importing the whole navigation shim into the
dispatcher would be wrong layering.

Move both into a dependency-free `shims/unrecognized-action-error.ts`,
mirroring Next.js's own `unrecognized-action-error.ts`. The navigation
shim re-exports them, so the public `next/navigation` surface is
unchanged.

No behavior change; existing predicate tests still pass.

* fix(app-router): throw a typed error for unrecognized server actions

When a server action POST returns the `x-nextjs-action-not-found`
response header, the App Router client dispatcher threw a plain `Error`.
`unstable_isUnrecognizedActionError` is an `instanceof UnrecognizedActionError`
check, so it returned `false` for the framework's own action-not-found
path. Client `catch` blocks and error boundaries that rely on the
predicate to detect client/server deployment skew never fired, which is
the exact case the predicate exists for.

Next.js's server-action reducer throws `new UnrecognizedActionError(...)`
on this same header. vinext exposed the class and predicate but never
constructed the error from its dispatcher.

Add `throwOnServerActionNotFound`, the client-side counterpart of
`createServerActionNotFoundResponse`, and call it from the client
server-action callback so the thrown error is recognizable. The two
internal helpers it subsumes are no longer exported.

Tests feed the real server-emitted response through the helper and
assert the public predicate recognizes the result.
2026-05-16 17:29:20 +01:00
Nathan Nguyen 4e558621df feat(cache): classify private and dynamic render downgrades (#1247)
* feat(cache): classify private and dynamic render downgrades

Render observations recorded dynamic request usage but did not carry an owned cacheability downgrade decision. That left future cache proof consumers to infer privacy from raw request API observations and cacheability strings.

Add pure downgrade classification for public variants, private request state, auth/session dimensions, uncacheable draft state, dynamic fetches, and incomplete observations. Bump the cache proof schema for the serialized observation shape and keep the disabled proof model as the only reuse decision.

* chore(cache): rename public cache candidate flag
2026-05-16 17:27:07 +01:00
Nathan Nguyen 5d622369dc fix(rsc): preserve binary inlined Flight chunks (#1245)
Inlined App Router Flight data could only travel through string chunks. That corrupted arbitrary binary RSC chunks during SSR hydration and static prerender extraction because non-UTF-8 bytes were decoded to replacement characters before React read them.

Encode non-UTF-8 embedded chunks as base64 binary records, decode those records back to bytes in the browser, and write prerendered .rsc payloads as bytes instead of UTF-8 text.

Regression coverage exercises server embedding, browser reconstruction, and prerender extraction for binary RSC chunks.
2026-05-16 17:21:10 +01:00
Nathan Nguyen 7d299088b8 fix(app-router): promote default slot persistence through route state (#1229)
* fix(app-router): promote default slot persistence through route state

Default and unmatched parallel slots were still inferred from AppElements transport markers. That made soft navigation persistence depend on wire shape instead of planner-owned route state, which breaks the #726 invariant that AppElementsWire is transport only.

Thread slot binding metadata through AppElements, browser state, and the navigation planner. The commit boundary now preserves previous slot content and binding proof only when target route-state marks the slot default or unmatched and the visible state proves a mounted/default value exists.

Tests cover the new planner contract, metadata validation, route wiring, mergeElements behavior, and browser commit lifecycle for default and unmatched slot targets.

* fix(app-router): align intercepted slot binding metadata

Intercepted slot payloads could render active modal content while their route-state metadata still described the slot as default. The browser planner could then preserve the previous default slot for an ID that the fresh payload should replace, breaking intercepted navigations in app-router E2E.

Derive slot IDs through one helper for both metadata and rendering, assert graph/wire ID divergence, and compute binding state from the same resolved override default export used by the render path. Move the app hydration marker to the committed root layout effect so E2E link clicks wait for a hydrated tree.

* fix(app-router): harden slot binding preservation

Review follow-up for #726-CORE-14. Centralize AppElements slot binding normalization so duplicate slot ids and stale owner layout ids fail at the wire boundary instead of creating quiet last-write-wins behavior.

Alias planner slot binding snapshots to the AppElements binding shape, share slot id ordering helpers, document the no-proof compatibility law, and add negative planner/wire tests plus a browser-visible soft navigation E2E. Refs #726.

* fix(app-router): align slot binding proof with rendering

* docs(app-router): clarify slot preservation invariants
2026-05-16 01:02:56 +01:00
Nathan Nguyen a5805b655a fix(navigation): support prefetch invalidation callbacks (#1244) 2026-05-15 21:52:16 +00:00
Nathan Nguyen 1e7005bbb6 fix(app-router): refresh after discarded revalidating actions (#1240) 2026-05-15 21:19:35 +01:00
Nathan Nguyen d3f028206f fix(navigation): throw when app router context is missing (#1242)
next/navigation useRouter returned the module-level app router singleton even when no AppRouterContext provider was mounted. That masked components rendered outside the App Router boundary, diverging from Next.js and hiding integration bugs.

The shim now reads the mounted AppRouterContext and throws the Next.js invariant when it is absent. The App Router browser and SSR roots provide the existing appRouterInstance so legitimate app renders keep the same public router surface.
2026-05-15 17:33:16 +01:00
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