* fix(pages-router): load custom _app/_document via resolved file paths in dev
The dev server imported pages/_app and pages/_document by extensionless id
and relied on the module runner applying custom resolve.extensions (e.g.
".page.tsx" from pageExtensions). The runner no longer resolves those, so
apps using pageExtensions silently lost their custom App/Document (the load
failure is swallowed). Import the file path resolved by findFileWithExts
instead, at all three call sites.
* test(e2e): add pages-router-complex example app and behaviour suite
A deliberately convoluted Pages Router app that serves as a compatibility
target: the kinds of patterns that only surface in large, long-lived
enterprise pages-router codebases, each pinned by a Playwright spec whose
behaviour is verified against real Next.js (73/73 under next dev --webpack).
Highlights: custom pageExtensions everywhere (middleware.page.ts,
instrumentation.page.ts), app-shell getInitialProps with a TTL-memoised
chrome fetch and embedded-shell/draft bypasses, class _document with its own
GIP, a zone route dimension driven by middleware rewrites and a real
i18next/react-i18next runtime, catch-all routes with static-sibling
precedence and cacheable-404/redirect hygiene, record-driven template
branching on a dynamic/static/dynamic route, an urql-style server-snapshot
data layer, shallow routing + router.events + next/compat/router +
next/navigation hooks in pages, next/image with a custom loader, draft-mode
gateway APIs, a function-form next.config, and a Cloudflare setup matching
the vinext init scaffold.
The playwright project (pages-router-complex, port 4199) is intentionally
not in the CI e2e matrix: vinext currently passes 59/73, and the failures
are the compat backlog documented in the example README.
* fix(pages-router): cover compound page extensions in CI
* fix(isr): preserve cache headers on initial render
* fix(isr): preserve streaming on initial cache misses
* test(isr): run production lifecycle coverage in CI
* test(isr): wait for asynchronous cache writes
---------
Co-authored-by: James <james@eli.cx>
* fix(perf): preserve permissioned cache roots
* fix(perf): run trusted same-repo harness
* fix(perf): ignore reaped benchmark zombies
* fix(perf): profile the build process tree
* fix(perf): wait for benchmark process cleanup
* fix(perf): profile the real build command
* fix(perf): profile the Vite Plus build entry
* fix(perf): avoid nested profiling sudo
* fix(perf): validate traces across profiles
* fix(pages): honor basePath in dev module loaders
* fix(pages): preserve basePath in ISR loaders
* style(pages): format dev module loader changes
* fix(pages): preserve dynamic route module paths
* test(pages): run basePath dev coverage in CI
* fix(app-router): prerender cacheComponents root-param fallback shells
PR 1 of 4: core model + build integration.
Model: app-ppr-fallback-shell, pregenerated-concrete-paths, prerender-manifest
Build: prerender/run-prerender fallback shell artifact generation
Tests: createAppPprFallbackShells, pregenerated-concrete-paths core
* feat(ppr): add fallback shell payload identity
* feat(ppr): safely serve fallback shell cache entries
* refactor(ppr): extract fallback shell render pipeline and dedupe getViteMajorVersion
* fix(ppr): restore safe-serving CI checks
The safe-serving branch failed CI because a client-side server-only guard rejected valid 'use server' action modules, a fallback-shell dependency cast no longer matched its helper type, apps/web lost the Cloudflare cache adapter source path during typecheck, and Knip could not resolve the documented Cloudflare cache adapter subpaths.
The server-only guard now reads the directive prologue and skips top-level 'use server' modules, fallback-shell regeneration passes an explicit typed dependency object, apps/web gets the workspace Cloudflare cache path, the Cloudflare package exports explicit adapter subpaths, Knip uses a relative source import in the dispatch unit test, and the readiness test awaits the actual cache-ready promise.
* fix(ppr): guard fallback shell reads for queries
* fix(ppr): normalize pregenerated concrete paths
* fix(ppr): address review comments on fallback shell serving and react runtime check
* fix(ci): restore knip ignores for unlisted binaries and prune dependencies
* fix(ppr): restrict bracket check in fallback detection to legacy manifests
* fix(ppr): prefer stable ESM react-dom/static.edge before CJS fallback
The loadStaticPrerender dev-mode path hard-codes react-dom's internal
CJS file layout (cjs/react-dom-server.edge.development.js). A React
upgrade could silently break fallback-shell rendering in dev without
affecting production because the function never tried the stable ESM
entry in development.
Change the order so that react-dom/static.edge is attempted first in
all environments. Only fall back to the CJS path when the ESM entry
does not export prerender() and we are in a development runtime. This
lets future React dev builds that expose prerender() from the ESM entry
work automatically, and it surfaces the CJS-path failure only when the
stable path is genuinely unavailable.
Also handle the CJS default-export interop shape that Node.js ESM
produces when dynamically importing a CommonJS module.
* refactor(ppr): address four review cleanup items
1. Remove redundant double-normalization in seed-cache.ts
addPregeneratedConcretePath already normalizes internally, so the
call-site normalizePregeneratedPathname() was redundant. Dropping it
gives a single source of truth for pathname normalization.
2. Lazily construct FallbackShellRenderDeps in tryServePprFallbackShell
Extract the large FallbackShellRenderDeps object into a top-level
buildFallbackShellRenderDeps helper. The closure in the probe loop
now just delegates, so the heavy object is only created when a STALE
entry actually triggers regeneration, not on every HIT probe.
3. Add dev-only warning for legacy bracket heuristic
When isFallbackShellArtifactPath falls back to the bracket substring
scan (route.fallback === undefined), emit a console.warn in dev so
developers know a legacy manifest is being used and concrete URLs
with literal brackets may be misclassified.
4. Update pregenerated-concrete-paths.test.ts to colon notation
The production manifest writer uses colon notation (/:slug) for the
route field, but the test fixtures used bracket notation (/[slug]).
Update the fixtures so the test format matches production, exercising
the real key format that the safe-serving guard depends on.
* test(ppr): compact fallback shell dispatch fixtures
* refactor(ppr): name fallback shell render phases
* refactor(app-router): deduplicate PPR fallback shell and app page cache render paths
* fix(ppr): avoid serving incomplete fallback shells
* chore: drop redundant cloudflare cache aliases
* fix(ppr): track fallback navigation hooks
* test(ppr): render navigation hook probes
* test(ppr): type navigation hook probes
* refactor(ppr): remove unused shell regeneration path
* refactor(ppr): remove unused shell result type
* fix(ppr): safely gate fallback shell prerendering
* fix(ppr): keep fallback shell gate internal
* fix(ppr): mark generated dynamic fallback shells
* test(ppr): align fallback fixture and search params
---------
Co-authored-by: James <james@eli.cx>
* fix(pages-router): restore scroll across reload history traversal
Pages Router pages with experimental.scrollRestoration currently expose window.next.router before the popstate runtime is installed and only keep Vinext scroll coordinates in the in-memory history state. After a reload, a back or forward traversal can run before the runtime is ready and has no durable scroll snapshot for the target history entry.
The violated invariant is that the Pages Router owns browser scroll restoration whenever the Next.js scroll restoration flag is enabled, with one stable history key per entry and routeChangeComplete emitted after the new page commits.
Resolve the Next.js config flag into process.env.__NEXT_SCROLL_RESTORATION, install the Pages Router runtime during next/router evaluation, persist Next-style sessionStorage scroll snapshots per history key, and render navigations through a stable commit boundary before emitting routeChangeComplete.
Regression coverage ports Next.js reload-scroll-backforward-restoration and verifies manual restoration plus exact scroll positions across reload, back, and forward traversal.
* fix(pages-router): sequence routeChangeComplete after scroll restoration and settle on render failures
* fix(router): guard against missing window.history in stampInitialHistoryState
* fix(router): defer hash scroll until after routeChangeComplete
Move hash scrolling out of renderPagesRouterElement's commit callback
and into the post-routeChangeComplete path, matching Next.js ordering:
x/y restoration happens during render commit, hash scrolling happens
after the completion event.
Also fix E2E test expectRouteChangeComplete to use one-shot event
listeners instead of checking for stale text, and add listener cleanup
to the fixture to prevent accumulation.
* fix(test): remove post-push deadlock and throw on missing events in E2E helper
expectRouteChangeComplete() now throws if window.next.router.events is
absent instead of silently resolving. Remove the redundant
expectRouteChangeComplete call after pushWithPagesRouter in the third
test — the awaited router.push already resolves after routeChangeComplete,
so registering a listener after it would deadlock.
* fix(test): replace as any casts with type-safe window.next narrowing in E2E scroll tests
* fix(router): narrow renderPagesRouterElement scroll param, drop dead hash branch
* fix(router): reset x/y scroll on hash routes before routeChangeComplete and cancel pending render on supersede
- Pass {x:0, y:0} through commit boundary for all scrollable
navigations, not just hash-less ones (hash scroll deferred to after
routeChangeComplete as before).
- Cancel pending render-commit Promise when a newer navigation starts,
preventing hang when the first root.render() commit is dropped by
React re-reconciliation.
- Add regression tests for both edge cases.
* fix(router): use SSR-safe layout effect in commit boundary and pin shallow scroll ordering
Address bonk review on #1905:
- swap the commit boundary's raw useLayoutEffect for the SSR-safe
useNonWarningLayoutEffect pattern from shims/image.tsx so Pages Router
SSR renders no longer log React's useLayoutEffect warning
- document that the default-noop commit boundary intentionally also wraps
SSR and initial hydration
- document the intentional shallow-scroll-before-routeChangeComplete
ordering and add a unit test pinning it
* fix(router): keep history-state scroll restore on popstate when scrollRestoration flag is off
Without experimental.scrollRestoration, popstate must still honour the
__vinext_scrollX/Y saved into history state on push, since a soft popstate
re-renders content the browser's native restoration can't position. Also
document why the popstate-time sessionStorage snapshot depends on
history.scrollRestoration = "manual".
* fix(router): guard render-commit canceller against late commits from superseded trees
* fix(router): defer current-history-key commit until beforePopState allows navigation
When beforePopState cancels a popstate the app stays on the previous
history entry, but _currentHistoryKey had already been updated to the
target key, skewing subsequent scroll-position bookkeeping. Move the
key commit below the beforePopState early-return, matching the
deliberate deferral of _lastPathnameAndSearch.
* chore(router): note intentional eager scroll snapshot; fix fixture link increment
- Document that the outgoing-entry scroll snapshot is deliberately written
before the beforePopState gate: on cancellation the app stays on
currentKey, so the saved value remains that entry's live scroll.
- Coerce id to a number in the pages-scroll-restoration fixture Link so
the href increments numerically instead of string-concatenating.
* fix(router): keep manual scroll restoration opt-in Pages-Router-only
Gate installManualScrollRestoration on the document not being an App
Router one (window.next.appDir === true): with the now-global
__NEXT_SCROLL_RESTORATION define, an App Router app that value-imports
next/router for compat would otherwise have its native browser scroll
restoration disabled at module eval. Upstream only flips
history.scrollRestoration inside the Pages Router constructor, which
never runs on App Router documents.
Also document the hash-only popstate path discarding the per-entry
__next_scroll_<key> snapshot as intentional upstream parity (change()'s
onlyAHashChange branch passes null to this.set and only scrollToHash
runs), and add unit coverage for both behaviors.
* ci: run pages-scroll-restoration E2E project in the CI matrix
Add the missing E2E matrix entry so the ported Next.js regression spec
(reload-scroll-backforward-restoration.spec.ts) actually executes in CI,
matching the shape of the sibling single-shard Pages Router entries.
Also document why the soft-popstate scroll fallback differs between
manual restoration on ({ x: 0, y: 0 }) and off (null -> native auto
restoration handles unstamped entries), per review.
* refactor(router): fold App Router gate into manualScrollRestoration constant
The popstate handler's sessionStorage save/read branches gated on the
module-level constant, which lacked the window.next?.appDir check that
installManualScrollRestoration used, leaving those branches relying
solely on the __N state filter to stay inert on App Router documents.
Fold the appDir check into the constant so every consumer is
Pages-Router-only by the same gate; the App Router bootstrap stamps
window.next.appDir before any client module evaluates, so the
module-eval-time check is reliable.
* test(pages-scroll-restoration): default getServerSideProps id to string "0"
Route params always arrive as strings, so the numeric 0 fallback was
inconsistent with the string values the component relies on
(Number(id) + 1 href, id === "error" check). Default to "0" for type
consistency.
* test(pages-scroll-restoration): align fixture deps and vinext import with pages-basic
Import vinext via the workspace package name instead of reaching into
dist by relative path, and declare the same react/react-dom/vinext
dependencies as the other fixtures so the fixture is self-describing
and the pnpm-lock.yaml importer entry is populated.
* fix(router): snapshot outgoing scroll on hash-only pushes
Upstream Next.js snapshots the outgoing entry's scroll into
__next_scroll_<key> inside Router.push() itself, before change()'s
onlyAHashChange short-circuit, so hash-only pushes still capture the
departed entry's position. Our hash-only push branch returned before
saveScrollPosition() ran, so a later back-popstate to that entry
restored {x: 0, y: 0} instead of the position the user had reached.
Save the outgoing scroll before updateHistory mints the new key,
matching the full-navigation push path and upstream ordering.
* docs(router): clarify manual scroll-target fallback chain precedence
* test(shims): tag scrollRestoration App Router test as eval-order guard
* docs(router): note missing-snapshot fallback intent at forcedScroll default
---------
Co-authored-by: James <james@eli.cx>
* fix(app-router): suppress redirect console errors in production onCaughtError (#1830)
When redirect() is called from a 'use client' root layout (or any client
component), React catches the NEXT_REDIRECT sentinel via RedirectErrorBoundary
and then calls the hydrateRoot onCaughtError callback. In dev mode vinext
already filtered these via devOnCaughtError, but the production path had no
onCaughtError at all — React's built-in fallback calls console.error, producing
a spurious browser console error that fails the Next.js compat test assertion
`expect(foundErrors).toBe(false)`.
Fix: add prodOnCaughtError to app-browser-error.ts that silently drops
navigation signal errors (NEXT_REDIRECT, NEXT_NOT_FOUND, NEXT_HTTP_ERROR_FALLBACK)
and logs all other caught errors, preserving React's default behavior.
Add fixture tests/fixtures/root-layout-redirect/ (dedicated 'use client' root
layout with redirect()) and Playwright project root-layout-redirect (port 4184)
with spec porting the Next.js root-layout-redirect > should work using browser.
Part of #1830
* fix: include result/ page in root-layout-redirect fixture
* fix: add lockfile entry for root-layout-redirect fixture, use prod server, import isNavigationSignalError
Three fixes to unblock catastrophically-failing CI:
1. pnpm-lock.yaml: Add missing importer entry for
tests/fixtures/root-layout-redirect. The fixture was added without
updating the lockfile, causing ERR_PNPM_OUTDATED_LOCKFILE on every
CI job during `vp install`.
2. playwright.config.ts: Switch the root-layout-redirect server from
`npx vp dev` (dev mode) to `vinext build + vinext start` (production
mode). The fix under test — prodOnCaughtError — is only active in
production builds; running in dev mode exercised the pre-existing
devOnCaughtError path and never touched the new code.
3. app-browser-error.ts: Replace the inline isNavigationSignalDigest
helper with an import of isNavigationSignalError from
utils/navigation-signal.ts. The module is dependency-free and
already in the production browser bundle; inlining it reintroduced
the drift risk that navigation-signal.ts was created to prevent.
Addresses ask-bonk findings: blocking (test ran in dev mode) and
maintainability (inline classifier duplication).
* test: add prodOnCaughtError unit tests, run root-layout-redirect e2e in CI, scope gitignore negation
* fix(app-router): preserve recent segment state with Activity BFCache
Fresh App Router navigations were reusing the active React tree when moving between dynamic segment values. That leaked client state into a newly entered segment and made the upstream back-forward-cache suite fail before it even reached browser traversal assertions.
The root cause was that vinext had history-entry bfcacheId plumbing but no separate segment state-key cache. Next.js treats those identities separately: render state is keyed by segment identity/pathname, while useRouter().bfcacheId identifies a concrete history entry.
Add state-key maps for app router slots, render retained entries through React.Activity when cacheComponents is enabled, keep the active one-entry keyed reset path otherwise, and port focused Next.js regression coverage for the three-entry BFCache behavior.
* fix(bfcache): store stateKeyMap in retained slot entries and re-provide on render
Review feedback requested two changes:
1. Add stateKeyMap to BfcacheSlotEntry so each retained BFCache entry carries
the state-key map that belonged to it when active, rather than borrowing the
current navigation's map. This prevents nested hidden entries from being rendered
with old elements but new pathname-derived state keys — an identity corruption
bug that upstream Next.js avoids by rendering each retained entry from its own
state.
2. Add a nested dynamic regression test: /nested/[section]/item/[id] with client
counters in both the [section] layout and [id] page. The existing flat /page/[n]
tests do not exercise nested retained-entry identity, which is exactly where the
missing state-key-map bug lives.
Changes:
- BfcacheSlotEntry gains stateKeyMap?: Readonly<Record<string, string>>
- updateBfcacheSlotEntries accepts and stores an optional stateKeyMap argument
- BfcacheSlotBoundary stores the current stateKeyMap alongside elements when
caching the active entry
- Both the Activity visible/hidden path and the non-cacheComponents single-entry
path now wrap rendered entries in BfcacheStateKeyMapContext.Provider using the
entry's own stateKeyMap (falling back to current map for forward compatibility)
- New e2e test verifies nested layout + page counter preservation across
segment navigations
* refactor(bfcache): extract shared provider stack into BfcacheEntryProviders
BfcacheSlotBoundary rendered the BfcacheStateKeyMapContext / ElementsContext /
SegmentContext provider stack in two places: the one-entry keyed reset path and
the per-entry Activity retention list. The two stacks were identical except for
where the React key sat, so the retained-entry bookkeeping and its render shape
were tangled together and harder to audit than needed.
Extract BfcacheEntryProviders as a pure presentational helper and render it from
both paths. The keyed-reset path keeps its remount semantics: the key now sits on
the BfcacheEntryProviders element, so a fresh active stateKey still remounts the
whole provider subtree.
Hook ordering is unchanged. All hooks stay above the early returns in
BfcacheSlotBoundary, so the boundary still satisfies the Rules of Hooks across the
activeStateKey undefined-to-defined transition. Covered by the slot BFCache
entry-state suite in tests/shims.test.ts.
* test(bfcache): isolate cacheComponents BFCache coverage in a dedicated fixture
The back/forward-cache e2e suite ran against the shared app-basic fixture with
cacheComponents: true. app-basic backs the single app-router Playwright server
that every app-router spec uses, so enabling cacheComponents globally changed a
core DOM invariant for unrelated tests: inactive route trees stay mounted as
hidden Activity DOM. The contamination already leaked a :visible workaround into
the interception-modal bfcacheId spec, which had nothing to do with Activity.
cacheComponents is an opt-in semantic mode, not default app-router behaviour, so
it does not belong on the shared baseline fixture. Move the BFCache routes into a
dedicated tests/fixtures/app-bfcache fixture with cacheComponents: true, served by
a new app-router-bfcache Playwright project on port 4183, and run the
back-forward-cache spec there against the project baseURL. The :visible locators
stay in that spec, where duplicate hidden DOM is the expected contract.
Remove cacheComponents and the BFCache routes from app-basic so the shared fixture
models default behaviour again, and revert the :visible workaround in
use-router-bfcache-id.spec.ts now that the modal route no longer retains hidden
DOM. Fix the nested item page import while moving it: nested/[section]/item/[id]/
page.tsx imported ../counter, but the counter lives two levels up at
nested/[section]/counter.tsx, so it must be ../../counter.
Validation: PLAYWRIGHT_PROJECT=app-router-bfcache (5 passed) and the full
use-router-bfcache-id spec under app-router (11 passed), plus the slot BFCache
unit suite (1172 passed) and vp check (formatting + lint + types clean).
* ci: run the app-router-bfcache e2e project in the matrix
The dedicated app-router-bfcache Playwright project was added to
playwright.config.ts but not to the CI E2E matrix, so the back/forward-cache
specs never ran in CI. Add it to the matrix project list so the BFCache suite
runs on every push. It uses the default chromium browser, so the existing setup
and browser-cache steps apply unchanged.
* fix(bfcache): keep persisted slots mounted when cacheComponents is off
Without cacheComponents there is no Activity retention, yet BfcacheSlotBoundary
still keyed the active entry by its segment stateKey. That stateKey tracks the
pathname (createBfcacheSegmentIdentity), so any slot whose identity moves with
the URL (shared layouts, interception source slots) was remounted on navigation,
discarding client state that survives a normal route change. The baseline router
reconciles these slots in place.
This regressed seven existing app-router e2e tests once cacheComponents was no
longer masking it on the shared fixture: interception source-context and modal
preservation in advanced.spec.ts, and layout-persistence.spec.ts dynamic-segment
layout counter survival. Fresh-entry reset is driven by userland bfcacheId keying
in the fixtures, not by remounting the slot subtree, so the keying was pure
overreach in the non-cacheComponents path.
Render the single active entry unkeyed when cacheComponents is off so the boundary
reconciles in place like the baseline. The keyed Activity retention path is
unchanged for cacheComponents.
Validation: full app-router project 436 passed / 0 failed (the seven regressions
now pass); use-router-bfcache-id 11 passed (fresh-push reset intact); app-bfcache
5 passed (Activity retention intact); shims unit suite 1024 passed.
* docs(test): point bfcacheId spec comment at the app-router-bfcache project
The header noted that Activity-backed preservation is covered in
back-forward-cache.spec.ts, but that spec moved to the dedicated app-bfcache
fixture under the app-router-bfcache project. Name the project and path so the
cross-reference stays accurate.
* docs: align cacheComponents comment with unkeyed active-segment behavior
* test(bfcache): assert fresh bfcacheId after Activity-restored return
* style: collapse locator chains per oxfmt
* refactor(bfcache): split slot entry order from snapshots
* fix(bfcache): keep non-cacheComponents slot path baseline
* fix(bfcache): use string cacheComponents define
* test: remove external fetch from revalidate tag fixture
* fix(bfcache): harden Activity state key retention
* docs(bfcache): document cacheComponents Activity caveats
* fix(bfcache): preserve navigation context for fallback SSR
Fallback boundary rendering can outlive the original request-scoped navigation context. That made app SSR fail fast with a missing navigation context when loading-boundary digest routes rendered their HTTP fallback shell.
The SSR boundary contract still requires a navigation context for real request renders, but boundary renderers now synthesize the same typed shape from the request URL and matched params when request scope has already been cleared.
* refactor(bfcache): normalize pathname in state-key map, document invariant callers
Moves hydration-pathname normalization into createBfcacheSegmentStateKeyMap so SSR and client always produce byte-identical Activity keys. Documents the requireNavigationContext guaranteeing callers per review feedback.
* fix(bfcache): tolerate malformed state-key pathnames
* docs(bfcache): document known Next.js divergence and dead-branch contract
* chore(deps): update lockfile for vite 0.1.24
* fix(app-router): preserve encoded delimiters in bfcache keys
* docs(bfcache): track router-owned retention follow-up
* chore: retrigger ci
* fix(app-router): preserve redirect bridge fallback
* fix(build): bundle @vinext/cloudflare into vinext to break dependency cycle
vinext consumed a few runtime helpers (KVCacheHandler, CloudflareCdnCacheAdapter,
ENTRY_PREFIX) from @vinext/cloudflare via a runtime `dependencies` edge. Combined
with @vinext/cloudflare's `peerDependencies` on vinext, this formed a cycle that
forced changesets to force-major @vinext/cloudflare on every vinext release
(mitigated defensively in #1764).
Bundle the small amount of @vinext/cloudflare code vinext actually imports into
vinext's dist via tsdown's deps.neverBundle, and move @vinext/cloudflare to
devDependencies. The published install graph now points one way
(@vinext/cloudflare -> vinext).
@vinext/cloudflare remains a published package: user vite.config files still
import cdnAdapter()/kvDataAdapter(), and the generated worker resolves its
*.runtime.js factories by absolute path. Its bundled-in vinext/shims/* imports
stay external and resolve via vinext's own package exports (Node self-reference).
Drop the now-obsolete create-next-app CI steps that packed and overrode
@vinext/cloudflare (vinext's tarball no longer declares it as a dependency).
* refactor(build): clarify @vinext/cloudflare bundling config
Use a single intent-revealing neverBundle predicate with a BUNDLED_DEPS
carve-out and document why the carve-out must live in neverBundle (tsdown
rejects skipNodeModulesBundle + alwaysBundle as mutually exclusive, and a
catch-all neverBundle takes precedence over alwaysBundle).
* fix(build): bundle @vinext/cloudflare via source alias, not external predicate
The previous approach replaced deps.skipNodeModulesBundle with a custom
neverBundle predicate. That broke tsdown's rewriting of vinext's own
`vinext/shims/*` tsconfig-path self-imports to relative paths, leaving them as
bare `vinext/*` specifiers in 46 files. Across Vite's separate RSC/SSR/client
dev module graphs those bare self-references resolved to distinct module
instances, breaking identity checks (e.g. `instanceof ReadonlyURLSearchParams`).
Keep skipNodeModulesBundle: true untouched and instead bundle @vinext/cloudflare
by aliasing its cache/* subpath to source. skipNodeModulesBundle externalizes
bare package specifiers before tsconfig paths apply, so the alias rewrites the
import to a file path up front; tsdown then treats it as local source and
bundles it. The bundled code's own vinext/shims/* imports still resolve to
vinext's relative output (single module instance).
Output is byte-identical to the pre-change build except the added bundled
cloudflare files and one cosmetic import reorder.
* ci: add fifth integration shard
* ci: weight integration shards from timing data
* ci: shard app-router e2e
* ci: rerun optimization experiment
* ci: add sixth weighted integration shard
* ci: rebalance weighted integration shards
* ci: shard unit tests
* ci: rebalance integration shards from current timings
* ci: move weighted integration shard list from YAML into script + timing manifest
* ci: move weighted integration shard list from YAML into script + timing manifest
* feat(ci): derive integration shard weights from real CI timings with provenance
Integration shard weights lived in a hand-seeded flat path->ms map
("aggregation": "manual seed"). A reviewer could not tell a measured
number from a guess, and the guesses were wrong: favicon-short-circuit
was seeded at 5s but runs ~35s in CI across five runs, a 7x under-weight
that mis-packed the shards. The seed had no provenance and no way to
regenerate from real data.
Restructure the manifest to a v2 provenance model: per file estimateMs
(the weight the planner uses), plus medianMs/p75Ms/samples and a
generatedFrom.runs list, an estimator metric, and generatedAt. Add
scripts/ci-integration-timings-refresh.mjs to aggregate Vitest blob
reports downloaded from successful CI runs (p75 per file, nearest-rank)
and rewrite the manifest deterministically, failing closed when the
blobs do not cover every discovered file. The manifest here was
regenerated from 5 successful runs (30 blobs); all six shards now pack
to 84s.
Extract planning and blob parsing into scripts/lib/* so the fragile
Vite+ blob-parser probe lives in one place. Replace the O(files*shards)
lightest-group scan with an O(n log m) binary min-heap and collapse the
three duplicated local-search move/swap helpers into one makespanAfter +
transfer primitive. Behavior preserved: the --check gate still verifies
every file lands in exactly one shard.
Harden --check to fail closed on no discovered files, missing, stale,
malformed/zero/negative timings, shard-count drift, and bucket coverage.
Add an advisory --recommend mode that models the optimal shard count from
real weights and flags when integration has dropped below the competing
cross-job bottleneck. It is advisory only and never runs in CI; the count
stays declarative in manifest.shardTotal with the matrix enforced
against it.
* ci: pass integration shard file list via env to avoid template injection
The integration shard step expanded ${{ steps.shard.outputs.files }}
directly into the run: block. That output is a list of test file paths
discovered from `vp test list`, and on pull_request runs a filename is
attacker-controllable: a fork PR adding a file whose name contains shell
metacharacters would inject it into the runner shell. GitHub code
scanning (zizmor) flagged this as template-injection, alert 163.
Route the file list and the other computed values through env vars and
reference them in the script, leaving $SHARD_FILES unquoted so the shell
still word-splits it into separate file arguments. The shell now treats
the value as data, never as script text. Verified with zizmor: the
pre-fix workflow reports template-injection on this line, the fixed
workflow reports no findings.
* feat(ci): require refresh blobs to back the claimed --run provenance
The refresh tool recorded every --run id as provenance but only checked
that each discovered file had at least one timing sample. Passing five
--run ids with blobs for a single complete run still produced a manifest
claiming five-run provenance while every file held one sample. The
manifest could claim stronger provenance than the blob directory backs.
A test file runs in exactly one shard per run, so one complete run
yields exactly one sample per file. Require samples === runIds.length for
every discovered file: too few means a claimed run's blobs are missing,
too many means the directory holds blobs beyond the claimed runs.
--allow-partial relaxes the check to "at least one sample per file" for
the re-run-failed-shard case while still recording the true per-file
sample count.
* experiment: run integration at 5 shards to benchmark the latency/cost knee
Temporary, for benchmarking only. Repacks the same provenance weights
into 5 integration shards instead of 6 (manifest shardTotal and matrix
set to 5, Check gate updated to match) so the 5 vs 6 trade-off can be
measured with the same weights, unit split, and E2E split. To be
reverted to 6 after the run is captured.
* experiment: go aggressive on wall-clock (8 integration, 3 unit, 3 app-router E2E)
Runner minutes are free on this public repo, so the objective is pure
wall-clock. Attack the whole critical-path cluster at once: integration
to 8 shards (~63s test load each, near the per-file floor), unit to 3,
and the app-router E2E project to 3-way so none of them becomes the new
ceiling once the others drop. Report job left as-is. Benchmarking only;
final counts settle after the run lands.
* ci: set integration to 10 shards, the wall-clock floor on free CI
Public repo, so runner minutes are free and the objective is pure
wall-clock. At 10 shards each integration shard carries ~51s of test
load; combined with the serial report tail this brings the integration
critical path down to roughly where the un-shardable create-next-app
(windows) job sits, so additional shards stop moving the overall wall.
Keeps unit at 3 shards and the app-router E2E project at 3-way from the
prior step. Benchmarking continues; counts can still change.
* fix(ci): default refresh shard count to the existing manifest, not a constant
ci-integration-timings-refresh.mjs defaulted --shard-total to a hardcoded
6. The documented refresh command in ci.yml omits --shard-total, so once
the matrix moved past 6 shards, following the advertised workflow rewrote
shardTotal: 6 into the manifest and the next run failed the Verify
integration shard manifest step with shard-count drift.
Default to the current manifest's shardTotal instead. manifest.shardTotal
is the single source of truth for the count: the matrix mirrors it and
--check enforces no drift, so a plain refresh now preserves whatever the
matrix uses. An explicit --shard-total still overrides it for an
intentional count change, and a missing count with no existing manifest
now fails with a clear message instead of silently picking a number.
Found by Codex review on a31c99c4.
* refactor(ci): share integration shard CLI helpers
* refactor(ci): clarify shard local search
* refactor(ci): drop doubled flag prefix in refresh shard-total error
The invalid --shard-total message reconstructed the flag as
'--shard-total=<value>', printing a doubled prefix
('Invalid --shard-total: --shard-total=abc'). parseFlag already
returns just the value, so print it directly to match the planner
CLI's wording.
* ci(shard): warn on timing drift, enforce shard count at selection
The integration shard check fails closed when a discovered file is
missing from the timing manifest, so adding one integration test reds CI
until someone hand-refreshes scripts/ci-integration-timings.json. The
per-file weights are only a load-balancing hint: a missing or stale
weight costs a little shard balance, never test correctness or coverage.
Gating on a freshness signal blocks contributors (and forks, which run
the secret-free ci.yml against the committed manifest) for an imbalance
worth a few seconds on one shard.
checkPlan now returns warnings separately from errors. Missing and stale
files become warnings; the structural invariants (schema, shard-count
drift, zero discovery, dropped or duplicated file) stay fail-closed. The
check job prints warnings as ::warning:: annotations and exits 0, so the
plan stays valid and a maintainer refreshes the manifest at leisure.
Separately, runShard packed into whatever N/M the workflow passed while
only --check compared the manifest to --shard-total, so a future edit
could drift the matrix count from the manifest and silently drop or
double-run tests at the point tests are selected. Guard
manifest.shardTotal against the requested total in runShard too, dying on
a mismatch instead of producing a malformed plan.
* docs(ci): clarify missing timing warning
* fix(ci): harden integration shard refresh
Vitest integration CI shards by test file, so the 340-test App Router integration file becomes one oversized scheduling unit. That makes the slowest shard depend on one monolithic file even after adding native shards.
Split the App Router integration coverage into focused files and increase the integration matrix to four native Vitest shards. This keeps fileParallelism disabled for shared-fixture safety while giving CI smaller scheduling units to distribute.
Validation: vp check tests/app-router-*.test.ts vite.config.ts .github/workflows/ci.yml; vp test run --project integration tests/app-router-client-preloading.test.ts tests/app-router-dev-server.test.ts tests/app-router-external-rewrite.test.ts tests/app-router-font-google-prod.test.ts tests/app-router-isr-codegen.test.ts tests/app-router-malformed-url.test.ts tests/app-router-metadata-routes.test.ts tests/app-router-middleware-next-request.test.ts tests/app-router-next-config-codegen.test.ts tests/app-router-next-config-dev.test.ts tests/app-router-origin-check.test.ts tests/app-router-production-build.test.ts tests/app-router-production-server.test.ts tests/app-router-rsc-flight-hint.test.ts tests/app-router-rsc-plugin.test.ts tests/app-router-static-export.test.ts tests/app-router-worker-entry.test.ts
* refactor(cache): extract Cloudflare cache adapters into @vinext/cloudflare
Move the Cloudflare KV data cache and edge CDN cache adapters out of
vinext into a new publishable @vinext/cloudflare package:
- cache/kv-data-adapter(.runtime).ts (KVCacheHandler, kvDataAdapter)
- cache/cdn-adapter(.runtime).ts (CloudflareCdnCacheAdapter, cdnAdapter)
tpr.ts stays in vinext. vinext now depends on @vinext/cloudflare
(workspace:*) and the package declares vinext as a peer dep; both build
from source via tsconfig paths so there is no build-order cycle. The
vinext/cloudflare barrel still re-exports KVCacheHandler for back-compat.
Wires up tsconfig paths, a vitest source alias, root build/postinstall,
and the preview/publish workflows for the new package. Updates internal
consumers (apps/web, examples/workers-cache), docs, and tests.
* ci(create-next-app): install @vinext/cloudflare from local tarball
vinext now depends on @vinext/cloudflare, which isn't published to npm
yet. The create-next-app smoke test packs vinext locally and resolves
its deps from the registry, so the install (and dev server) failed with
ERR_PNPM_FETCH_404 for @vinext/cloudflare.
Pack @vinext/cloudflare alongside vinext and add a pnpm override in the
scaffolded project pointing at the local tarball so the dependency
resolves offline.
* refactor(cloudflare): address review feedback
- Remove the root barrel export from @vinext/cloudflare; expose only the
./cache/* subpaths via a wildcard export (no root main/types).
- vinext/cloudflare re-exports KVCacheHandler from the full subpath.
- Drop the redundant .npmignore (the package.json "files" allowlist
already restricts the publish to dist).
- Remove the unsupported imperative setCacheHandler/KVCacheHandler usage
from both READMEs; the cache plugin config is the supported approach.
- Simplify test wiring: drop the now-unused @vinext/cloudflare tsconfig
path and dedupe the vitest source alias into a shared constant.
* chore(cloudflare): drop unused vite devDependency
The @vinext/cloudflare config uses vite-plus and nothing imports vite, so
the vite devDependency was unused. build/check/knip stay green without it.
* Apply suggestion from @james-elicx