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.
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.
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.
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.
* 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.
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.
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.
* 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.
`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.
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.
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.
* 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>
* 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.
* 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.
* 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.
* 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
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.
* 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
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.
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.
* 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.
* 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).
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
* 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.
* 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.
* 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.
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.
* 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
* 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
* 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.
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.
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.
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.
* 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.
* 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>
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.
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
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
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
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
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).
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
* 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