447 Commits

Author SHA1 Message Date
Divanshu Chauhan (divkix) ed2618a1ae refactor: post-PR #690 cleanup - shared test helper and ??= syntax
Addresses review comments from PR #690:

1. Extract waitForHydration helper into shared location (tests/e2e/helpers.ts)
   - Adds waitForAppRouterHydration() using expect().toPass() pattern
   - Updates 19 App Router test files to use shared helper
   - Removes ~100 lines of duplicated code across test suite
   - Standardizes on Pattern A (expect().toPass) for better error messages

2. Apply ??= operator syntax nit in navigation.ts
   - Replaces if (!x) { x = {...} } with x ??= {...}
   - Cleaner, idiomatic TypeScript for lazy initialization

Type check and E2E verification passed.
v0.0.39
2026-04-02 10:57:19 +01:00
Nathan Nguyen 6da45a680a perf: build-time precompression + startup metadata cache for static serving (#641)
* perf: add build-time precompression for hashed static assets

Generate .br (brotli q11), .gz (gzip l9), and .zst (zstandard l19)
files alongside compressible assets in dist/client/assets/ during
vinext build. These are served directly by the production server,
eliminating per-request compression overhead for immutable build output.

Only targets assets/ (hashed, immutable). Public directory files still
use on-the-fly compression since they may change between deploys.

* perf: add startup metadata cache for zero-syscall static serving

StaticFileCache walks dist/client/ once at server boot and caches:
- File metadata (path, size, content-type, cache-control, etag)
- Pre-computed response headers per variant (original, br, gz, zst)
- In-memory buffers for small files (< 64KB) for res.end(buffer)
- Precompressed variant paths and sizes

Per-request serving is Map.get() + res.end(buffer) with zero filesystem
calls, zero object allocation, and zero header construction. Modeled
after sirv's production mode but with in-memory buffering for small
files which eliminates createReadStream fd overhead.

* perf: refactor tryServeStatic to async + cache + precompressed serving

Refactor tryServeStatic to use StaticFileCache for the hot path:
- Pre-computed response headers (zero object allocation per request)
- In-memory buffer serving for small precompressed files
- 304 Not Modified via ETag + If-None-Match
- HEAD request optimization (headers only, no body)
- Zstandard serving (zstd > br > gzip > original fallback chain)
- Async filesystem fallback for non-cached files (replaces blocking
  existsSync + statSync)
- Skip decodeURIComponent for clean URLs (no % in path)

Wire StaticFileCache.create() into both startAppRouterServer and
startPagesRouterServer at startup. Integrate precompressAssets() into
the vinext build pipeline.

CONTENT_TYPES is now a single source of truth exported from
static-file-cache.ts (was duplicated in prod-server.ts).

* docs: fix stale comments in precompress (mention .zst)

* fix: deduplicate buffer reads for HTML alias entries, fix stale JSDoc

* docs: fix stale comment in cli.ts (mention zstd)

* fix: address code review findings

- Add Vary: Accept-Encoding to original variant when compressed siblings exist
- Use pipeline() instead of .pipe() for stream error safety
- Remove legacy accessor fields from StaticFileEntry (all new code)
- Exclude Content-Type from 304 responses per RFC 9110
- Fix misleading Accept-Encoding comment in prod-server
- Batch stat() calls in walkFilesWithStats (chunks of 64)
- Bound precompression concurrency to availableParallelism (max 16)
- Gracefully skip zstd when zlib.zstdCompress unavailable (Node <22.15)
- Add --no-precompress CLI flag to skip build-time compression
- Drop brotli to q5 (fast, nearly same ratio as q11 at build time)
- Max out zstd at level 22 (build time, no reason to hold back)

* fix: address remaining review findings in prod-server

- Gate negotiateEncoding zstd on runtime availability (HAS_ZSTD)
- Add ZSTD_e_flush for streaming mode (progressive SSR decodability)
- Serve HEAD without body on slow path (compressed + uncompressed)
- Handle pipeline errors instead of silently swallowing them
- Lowercase Accept-Encoding in fast path per RFC 9110
- Fix stale comments: add zstd to module header and variant detection
- Add tests for slow path, HEAD, and malformed percent-encoded pathnames

* perf: move precompression to Vite plugin with edge auto-detection

Move build-time precompression from CLI post-step into a writeBundle
plugin hook (vinext:precompress). The plugin already knows the
deployment target — auto-skips for Cloudflare Workers and Nitro where
the CDN handles compression at the edge.

- Add precompress option to VinextOptions: 'auto' | boolean
- CLI --no-precompress forwards via VINEXT_NO_PRECOMPRESS env var
- Works for any build invocation (CLI, API, CI), not just vinext build

* docs: fix inaccurate comments in precompress and static-file-cache

- precompress: remove false idempotency claim (overwrites, not skips)
- static-file-cache: "frozen objects" → "reusable objects" (no freeze)
- static-file-cache: fix unrealistic compression ratio example

* fix: address code review findings

- Remove dead code: redundant .br/.gz/.zst extension checks already
  caught by COMPRESSIBLE_EXTENSIONS filter
- Fix unreachable pipeline error handlers: res.writeHead() was called
  before pipeline(), so res.headersSent was always true in the callback.
  Use res.destroy(err) to cleanly tear down the socket on stream errors.
- Read file content lazily inside compression chunks instead of loading
  all files upfront, keeping peak memory at O(CONCURRENCY * max_file_size)

* perf: tune compression levels and restore auto mode

- Drop zstd from level 22 to 8 (250x faster build, 7% larger output)
- Drop gzip from level 9 to 8 (marginal size difference, faster)
- Restore precompress: 'auto' | boolean config with auto as default
  (auto-enables for Node.js targets, skips for Cloudflare/Nitro)
- Add progress bar for precompression step (matches prerender UX)
- Clarify compression ratio output as brotli-specific

* fix: use filename hash for ETag on hashed assets

Hashed assets (assets/app-DqZc3R4n.js) now derive their ETag from the
content hash in the filename instead of mtime. More robust across
Docker builds, CI artifacts, and cp -r where mtime changes between
deploys even when content is identical.

- Use weak validator (W/) since the tag is shared across content-encoded
  variants (.br, .gz, .zst) which are byte-different (RFC 9110 §8.8.1)
- Fall back to mtime-based ETag when filename has no recognizable hash
  (custom assetFileNames, plugin-emitted assets, public/ files)

* fix: make precompress opt-in per maintainer feedback

Real-world benchmark shows 13.5% build-time regression on fumadocs
with auto mode (2.4s → 2.7s). Switch to explicit opt-in via
`precompress: true` or `--precompress` CLI flag until the feature
stabilises.

* fix: address code review findings

- ETag regex: use lastIndexOf('-') to extract only the final hash
  segment, avoiding capture of filename fragments in multi-hyphenated
  names (e.g. `before-pop-state-D3R3Ien6` now extracts `D3R3Ien6`)
- Progress bar: track processed count (including skipped files) so
  the bar always reaches 100%
- Tests: update ETag test for filename-hash behavior, add coverage
  for mtime fallback on non-hashed and unhashed-in-assets files

* chore: use type instead of interface per lint rule

* fix final precompression review comments

* perf: optimize precompress build path

- Overlap precompression with SSR build phase (async writeBundle + closeBundle await)
- Lower concurrency cap from 16 to 8 (reduces zlib queue contention)
- Fix progress counter race in chunked processing

* fix: address final review feedback

- Fix progress counter race in precompress (single onProgress call site
  after readFile, monotonic by construction)
- Add console.error before res.destroy in all pipeline error handlers
  for operational visibility
- Chunk buffer reads at 64 concurrent to bound FD usage on large projects
- Defer createCompressor until after HEAD check on slow path
- Use Math.floor for mtime ETag (matches send/Express convention)
- Document Rolldown hash length assumption

* fix: address final bonk review nits

- Move progress reporting to after chunk completion to avoid
  non-deterministic ordering within Promise.all
- Use Math.trunc instead of Math.floor for mtime-based ETags
  (semantically correct for truncating to whole seconds)

* address bonk review comments: counters post-write, Math.floor, Content-Length comment, warn log level

- precompress.ts: move filesCompressed++ and totalOriginalBytes+= to after
  writes complete so partial failures (e.g. ENOSPC) don't inflate totals
- precompress.ts: add comment clarifying that progress tracks all files
  including skipped ones below MIN_SIZE, unlike filesCompressed
- static-file-cache.ts: Math.trunc -> Math.floor for mtime-based ETags
  (matches send/Express convention; correct for pre-epoch mtimes)
- prod-server.ts: add comment explaining Content-Length is intentionally
  omitted on slow-path compressed responses (chunked transfer encoding)
- prod-server.ts: downgrade all three pipeline error logs from console.error
  to console.warn and add comments explaining the destroy-only pattern

* address bonk: slow-path ETag+304 parity, visible precompress error log

- prod-server.ts slow path: add mtime-based ETag (W/"size-mtime") and
  304 Not Modified support, matching fast-path cache behaviour; also
  propagate mtimeMs through resolveStaticFile/statIfFile
- index.ts: add console.error in precompress .catch() so build errors are
  visible even if the SSR closeBundle hook never fires

* address bonk: slow-path 304 Vary header, HAS_ZSTD comment

- prod-server.ts slow path: include Vary: Accept-Encoding in 304
  responses when the content type is compressible, matching fast-path
  notModifiedHeaders behaviour and preventing shared caches from
  collapsing compressed/uncompressed representations
- prod-server.ts fast path: add comment explaining HAS_ZSTD is not
  checked during precompressed variant selection — we're serving a
  pre-existing .zst file from disk, not invoking zstdCompress() at
  runtime; HAS_ZSTD only guards the slow-path on-the-fly compressor

* address bonk: clarify Vary/compress=false intent in slow-path 304 comment

Expand the 304 comment to explain that Vary: Accept-Encoding is
intentionally omitted when compress=false (matching fast-path behaviour
where compress=false skips all compressed variants), and note that
spreading undefined is a deliberate no-op per ES2018+.

* address bonk: readFile-before-stat comment, document VINEXT_PRECOMPRESS env var

- precompress.ts: add comment on MIN_SIZE early-return explaining the
  read-first vs stat-first tradeoff (one wasted read vs extra syscall
  per tiny file; read-first cheaper for typical hashed asset sets)
- index.ts: document VINEXT_PRECOMPRESS=1 env var in VinextOptions.precompress
  JSDoc alongside the existing --precompress CLI flag mention

* address bonk: filename-hash ETag on slow path, entry-level dedup in buffer pass

- static-file-cache.ts: export etagFromFilenameHash so prod-server can
  reuse the same filename-hash extraction logic
- prod-server.ts: slow path now uses etagFromFilenameHash for /assets/*
  files, falling back to mtime-based ETag for non-hashed files; this
  matches fast-path cache behaviour and makes ETags stable across deploys
- static-file-cache.ts buffer pass: deduplicate at the StaticFileEntry
  level (using a seenEntries Set) rather than at the variant path level;
  HTML aliases share the same entry by reference so the old approach
  iterated variants multiple times for sites with many HTML pages

* address bonk: fix etagFromFilenameHash arg, explicit re-run safety comment

- prod-server.ts: use resolved.path (not pathname) for etagFromFilenameHash
  so that ext and the hash extraction always come from the same file;
  pathname and resolved.path diverge after HTML fallback (e.g. a request
  for /assets/widget-abc123 resolving to widget-abc123.html would have
  extracted a hash from the wrong name)
- precompress.ts: add inline comment on the COMPRESSIBLE_EXTENSIONS filter
  making explicit that .br/.gz/.zst are intentionally absent, so
  precompressed variants from a previous run are never re-compressed

* address bonk: add slow-path 304 test coverage

---------

Co-authored-by: James <james@eli.cx>
2026-04-01 23:33:12 +01:00
Nathan Nguyen e19ec196c8 fix(tpr): use correct KV cache key format matching runtime (#736)
* fix(tpr): use correct KV cache key format matching runtime

TPR's uploadToKV wrote keys as `cache:<pathname>` but the runtime
KVCacheHandler reads `cache:app:<buildId>:<pathname>:html`. Every
TPR-seeded entry was a dead key — guaranteed cache miss on first
request.

Extract buildTprKVPairs() as a testable pure function that delegates
key construction to the shared isrCacheKey(), and read buildId from
the prerender manifest at upload time.

* fix(tpr): read buildId from BUILD_ID file instead of prerender manifest

TPR doesn't use the prerender pipeline, so vinext-prerender.json never
exists in TPR context. Write a standalone BUILD_ID file during every
build (matching Next.js convention) and read from that instead.

* address bonk review comments

- Add console.warn when BUILD_ID can't be read so operators can diagnose key mismatches
- Match runtime KVCacheHandler TTL strategy: use flat MAX_KV_TTL_SECONDS (30d) instead of 10x revalidate, preventing premature eviction of low-traffic TPR-seeded pages
- Export ENTRY_PREFIX from kv-cache-handler.ts and import it in tpr.ts instead of duplicating with a comment
- Restrict vinext:build-id writeBundle to rsc environment only (was firing for both rsc+ssr, writing duplicate files)
- Guard nextConfig resolution so buildId is generated exactly once per plugin instance across all Vite environments
- Update tpr-kv-keys tests to reflect new flat-TTL behaviour

* address second bonk review round

- Document intentional 24h fallback TTL divergence from runtime for non-revalidating entries
- Clarify index.ts guard comment: resolveNextConfig()->resolveBuildId() chain; note fileMatcher etc stay outside guard intentionally
- Make missing BUILD_ID a hard failure: skip KV upload entirely instead of proceeding with dead keys
- Update no-buildId test to document it exercises buildTprKVPairs() directly; runTPR() now fails fast before reaching that path

* fix(build-id): write BUILD_ID once per build via closeBundle, not per-environment

The previous writeBundle + envName === 'rsc' guard only wrote BUILD_ID for
App Router builds. Pages Router builds (ssr environment) and any future
router types were silently skipped.

BUILD_ID has nothing to do with which environment is active — it is a
per-build property. Replace with a closeBundle hook wrapped in an IIFE that
carries a write-once guard (buildIdWritten flag). The output path is always
root/dist/server/BUILD_ID, derived from the plugin-level root variable, so
it is router-agnostic and matches exactly where tpr.ts reads it from.

---------

Co-authored-by: James <james@eli.cx>
2026-04-01 22:54:17 +01:00
Nathan Nguyen 7d9f4a09b7 feat: implement parallelRoutesKey support in useSelectedLayoutSegment(s) (#737)
* feat: migrate LayoutSegmentContext to segmentMap for parallelRoutesKey

Context type changes from string[] to SegmentMap — a record with a
required "children" key plus optional parallel route keys. useChildSegments()
now accepts a parallelRoutesKey parameter to index into the map.

Consumers not yet updated — entry template and boundary rendering still
use the old childSegments prop. Next commit updates them.

* refactor: update all LayoutSegmentProvider consumers to segmentMap

Mechanical change: every createElement(LayoutSegmentProvider, { childSegments })
becomes createElement(LayoutSegmentProvider, { segmentMap: { children: ... } }).
Behavioral output is identical — useSelectedLayoutSegments() returns the
same values. The segmentMap structure is ready for parallel slot entries.

* test: add parallelRoutesKey integration tests

Verifies useSelectedLayoutSegments(parallelRoutesKey) returns correct
per-slot segments through the full SSR pipeline. Flat slots correctly
return [] until PR 2 populates per-slot entries in the segmentMap.

* refactor: remove as cast from useSelectedLayoutSegments

SegmentMap arrays changed from readonly string[] to string[] to match
Next.js public API return type without requiring a forbidden as cast.
The map itself stays Readonly — arrays are created fresh at each layout
boundary so mutable typing is safe.

* Update packages/vinext/src/shims/layout-segment-context.tsx

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>

* Update packages/vinext/src/shims/navigation.ts

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>

* Update packages/vinext/src/shims/layout-segment-context.tsx

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>

---------

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: James Anderson <james@eli.cx>
2026-04-01 21:17:47 +00:00
Divanshu Chauhan (divkix) ef4192db49 fix: cross-route client navigation hangs in Firefox (#652) (#690)
* fix: cross-route client navigation hangs in Firefox (#652)

Replace flushSync-based RSC tree rendering with a two-phase navigation
commit that uses startTransition for same-route navigations and
synchronous updates for cross-route navigations. This fixes the Firefox
hang where startTransition never commits when the entire component tree
is replaced, and also resolves the Suspense double-flash from #639.

Key changes:
- navigation.ts: ClientNavigationState on Symbol.for global for module
  instance safety, render snapshot context for hook consistency during
  transitions, RSC response snapshot/restore for visited cache, unified
  navigateClientSide() entry point, history suppression helpers
- app-browser-entry.ts: BrowserRoot component with useState-managed tree,
  NavigationCommitSignal that defers URL commit to useLayoutEffect,
  visited response cache with LRU eviction, navigation ID counter for
  stale navigation bailout, manual scroll restoration
- link.tsx: remove duplicated helpers, delegate to navigateClientSide
- form.tsx: delegate App Router GET navigation to navigateClientSide

Closes #652

* fix: detect React Flight Thenables in updateBrowserTree and apply params eagerly

createFromFetch() returns a Thenable (not instanceof Promise), so the
async branch was never taken. The raw unresolved Thenable was set as
React children, causing suspension without a Suspense boundary and
empty content after cross-route navigations.

- Duck-type the .then() check to handle both Promises and Thenables
- Replace stageClientParams with applyClientParams so useSyncExternalStore
  subscribers see correct params immediately
- Remove dead stageClientParams function and replaceClientParamsWithoutNotify import

Fixes useParams E2E failures in CI (hooks.spec.ts:161, hooks.spec.ts:180).

* fix: await createFromFetch before passing RSC payload to tree

The Flight Thenable from createFromFetch was passed unresolved to
renderNavigationPayload/updateBrowserTree. The .then() callback fired
unreliably across consecutive navigations, causing empty content.

Await createFromFetch in both navigation paths (cached and fresh) so
renderNavigationPayload always receives a resolved ReactNode. Add stale
navigation checks after the new await points.

The HMR handler and server action handler already awaited correctly.

* fix: preserve app router params across cached navigation

Preserve the X-Vinext-Params header when snapshotting prefetched and visited RSC responses so cached navigations keep dynamic params intact. Stage client params until the URL commit lands so useParams, history state, and redirect handling stay aligned during App Router transitions.

* fix: address review feedback on navigation caching and history management

- Remove double history push for RSC navigations: let the commit effect
  in navigateRsc own push/replace exclusively. Also fixes a hidden bug
  where the early push broke isSameRoute detection (always true),
  negating the Firefox cross-route hang fix.
- Enforce prefetch cache size limits in prefetchRscResponse() by
  extracting shared eviction logic from storePrefetchResponse().
- Add TTL check in consumePrefetchResponse() so expired snapshots are
  discarded instead of served as stale UI.
- Prevent navigationSnapshotActiveCount leak when async payloads reject
  in updateBrowserTree by adding error handlers that balance the
  activate/commit pairing.
- Move BrowserRoot setBrowserTreeState assignment from render to
  useLayoutEffect to avoid side effects under React Strict Mode.
- Make visited response cache true LRU (delete+set on hit) and add a
  30-minute upper bound for traversal cache hits to prevent arbitrarily
  stale back/forward content.

* fix(navigation): address PR review feedback for Firefox navigation fix

Fixes the following review issues from ask-bonk:

Major fixes:
- Fix prefetch cache eviction order to add entry first, then evict (prevents
  temporary overflow past MAX_PREFETCH_CACHE_SIZE)
- Add error handling in renderNavigationPayload to ensure
  navigationSnapshotActiveCount is decremented on synchronous errors,
  preventing counter leaks

Minor fixes:
- Add cross-reference comments between Pages Router and App Router scroll
  restoration handlers (navigation.ts and app-browser-entry.ts)
- Add JSDoc to getPathnameSnapshot/getSearchParamsSnapshot explaining cached
  behavior vs live window.location reads
- Add comment documenting server action navigation flow (doesn't use
  activateNavigationSnapshot because it stays on same URL)
- Add comment explaining Map iteration safety in resolveCommittedNavigations
- Use stripBasePath in isSameRoute comparison for robust basePath handling
- Explicitly pass undefined for historyUpdateMode in redirect chains with
  explanatory comment (prevents double URL push)

All 1094 tests pass.

* fix: prevent stale navigation bailout from leaking staged params

Addresses review feedback on PR #690:
- Move stageClientParams() to after final navId check in both cached and fresh paths
- Build navigationSnapshot from local params instead of latestClientParams
- Move storeVisitedResponseSnapshot after final stale check
- Add doc comment about history method patching behavior

This fixes a race where cancelled navigations could leak their staged params
into subsequent unrelated navigations when commitClientNavigationState()
was called later without clearing the pending state.

Test: Add E2E test for cancelled slow navigation param leak scenario

* fix: remove duplicate onRouterTransitionStart call and fix formatting

The refactor to delegate Link navigation to navigateClientSide() left a
duplicate notifyAppRouterTransitionStart() call in link.tsx, causing the
callback to fire twice per push navigation.

* fix(navigation): address code review feedback on Firefox cross-route navigation hang fix

Fixes four edge cases identified in code review:

1. NavigationCommitSignal cleanup now resolves pending promises on unmount
   to prevent hanging callers if React unmounts the component (e.g., error
   boundary triggers full-page nav)

2. Added dev-mode warning for navigationSnapshotActiveCount going to 0 before
   decrement to catch activate/commit pairing bugs early

3. Changed prefetch eviction from if to while loop to match visited cache
   pattern and prevent temporary cache overflow during rapid prefetch calls

4. Moved storeVisitedResponseSnapshot below final navId check to avoid storing
   stale cache entries from superseded navigations

Also added cross-reference comments between the two popstate handlers in
app-browser-entry.ts and navigation.ts for future maintainers.

* fix(navigation): resolve infinite recursion in getServerSearchParamsSnapshot

The function getServerSearchParamsSnapshot had infinite recursion when
called on the server - it would call itself infinitely when
getClientNavigationState() returned null.

Fixed by implementing proper server context handling:
- Use _getServerContext() to access server navigation context
- Cache ReadonlyURLSearchParams wrapper using symbol-based caching
- Return cached empty instance when no context available
- Follows established patterns from getServerParamsSnapshot and
  getSearchParamsSnapshot

Fixes CI failures in PR #690 where 18 tests were failing with
"Maximum call stack size exceeded" errors during SSR.

* style(navigation): fix lint errors from stricter oxlint rules

Convert interface declarations to type aliases per consistent-type-definitions rule:
- CachedRscResponse, PrefetchCacheEntry, ClientNavigationRenderSnapshot
- VisitedResponseCacheEntry in app-browser-entry.ts

Add oxlint-disable block comments for react-hooks/rules-of-hooks violations:
- useChildSegments, useClientNavigationRenderSnapshot, usePathname
- useSearchParams, useParams

The original conditional hook pattern (if isServer return early) is intentional
and required for SSR/client duality. Server components must not call client-only
React hooks. Block-level disable comments suppress the false positives while
maintaining the correct runtime behavior.

* fix(navigation): address review feedback on Firefox cross-route navigation fix

- Fix isHashOnlyChange to strip basePath for consistent comparison with isSameRoute
- Add snapshotPending guard to prevent double-decrement in catch block
- Document storePrefetchResponse API contract (callers manage prefetchedUrls)
- Remove duplicate prefetched.add from _appRouter.prefetch (ownership now in prefetchRscResponse)
- Add invariant comment to drainPrePaintEffects explaining activate/commit pairing
- Improve test error handling in cancelled navigation test with debug logging

* fix: address PR review — prefetch dedup, comments, and test robustness

- Add dedup guard to router.prefetch() matching link.tsx pattern
  (check/add prefetchedUrls before calling prefetchRscResponse)
- Fix incorrect comments claiming prefetchRscResponse manages prefetchedUrls
- Add doc comment to storePrefetchResponse noting caller owns URL tracking
- Document module-level state assumption in app-browser-entry.ts
- Add inline comments in drainPrePaintEffects for winning/superseded pairing
- Add element visibility assertion in E2E cancelled-navigation test

* chore: review changes implemented

* Address ask-bonk review feedback: remove dead code and add defensive comments

- Remove vestigial response field from PrefetchCacheEntry (never read by consumePrefetchResponse)
- Add defensive comment for _snapshotPending guard brittleness
- Document isSameRoute race condition during rapid navigations
- Update test helper to use snapshot instead of response

All changes are non-behavioral documentation/clarity improvements.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: prevent double-decrement of navigationSnapshotActiveCount on renderNavigationPayload throw

When updateBrowserTree throws synchronously inside renderNavigationPayload,
the inner catch already calls commitClientNavigationState() and re-throws.
Previously the outer navigateRsc catch saw _snapshotPending === true and
decremented again, leaving the counter off by 1 for concurrent navigations.

Fix by wrapping each renderNavigationPayload call in try-finally that always
clears _snapshotPending, so the outer catch guard correctly skips the
already-committed case.

* docs: document latestClientParams staging invariant in stageClientParams

Per bonk review: stageClientParams diverges latestClientParams from
ClientNavigationState.clientParams between staging and commit. Server
action snapshots read latestClientParams, so add a comment clarifying
this narrow race is acceptable since the commit effect fires synchronously
in the same React commit phase.

* fix: address two blocking concerns from bonk review

1. Guard outer catch against hard-navigating to stale URL when superseded:
   - Hoist navId above the try block so it is accessible in the catch
   - Add 'if (navId !== activeNavigationId) return' before window.location.href
   - Move navId check before createFromFetch in cached route path to avoid
     wasted parse work and the same stale-URL risk on throw

2. Guard handleAsyncError in updateBrowserTree against counter corruption:
   - Add snapshotActivated parameter (default false) to updateBrowserTree
   - Only call commitClientNavigationState() when snapshotActivated is true
   - renderNavigationPayload passes true; server actions and HMR pass false
     (they never call activateNavigationSnapshot so must not decrement)
   - Update comments on server action and HMR call sites to document why

Also add a note on restoreRscResponse that reconstructed Response.url is
always empty string — the Response constructor does not accept a url option.

* fix: tighten cached-route stale-nav guards and fix stale comment

- Add navId check between cachedNavigationSnapshot creation and
  createFromFetch in the cached route path, so superseded navigations
  bail out before the async parse rather than only after
- Fix stale line reference in stageClientParams comment (533 -> 544/558)
- Document slow-route fixture delay / click timeout coupling in E2E test
  so future changes to the fixture delay don't silently break the test

* fix: stage params after _snapshotPending=true in both nav paths, fix stale comment

Move stageClientParams() to after _snapshotPending = true in both the
cached-route path and the non-cached path. This makes the intent clearer:
params are staged as part of an in-flight snapshot, not as a standalone
side-effect before the snapshot flag is set.

If renderNavigationPayload throws synchronously, its inner catch calls
commitClientNavigationState() which flushes pendingClientParams — committing
params for a route that never rendered. The hard-nav fallback in the outer
catch resets everything, so this was self-correcting, but reordering makes
the relationship explicit and guards the intent.

Also replace the stale "lines 544/558" reference in the stageClientParams
comment with the function name (registerServerActionCallback) so it doesn't
rot as the file evolves.

* fix: expand module-singleton comment, remove redundant stale-nav check, document BASE URL

- Expand the module-level state comment to call out Map fragmentation as
  the most severe consequence of multiple instances: pendingNavigationCommits
  and pendingNavigationPrePaintEffects would split across instances, causing
  drainPrePaintEffects to miss effects queued by the other instance and
  permanently leaking navigationSnapshotActiveCount.

- Remove the redundant navId !== activeNavigationId check between
  createClientNavigationRenderSnapshot (synchronous) and createFromFetch
  (async). The pre-check before createClientNavigationRenderSnapshot is
  sufficient; the meaningful post-check is after the await of createFromFetch.
  Added a comment noting the function is synchronous so no recheck is needed.

- Add a comment to BASE in navigation-regressions.spec.ts explaining it
  follows the established repo convention (same pattern in navigation.spec.ts,
  server-client-only.spec.ts, after.spec.ts, etc.).

* docs: annotate stageClientParams throw path in both nav paths

If stageClientParams throws (e.g. a param object with a throwing getter),
_snapshotPending is true but renderNavigationPayload was never called.
The outer catch then calls commitClientNavigationState() which decrements
a counter never incremented by activateNavigationSnapshot() — safe due to
the > 0 guard — but pendingClientParams and hasPendingNavigationUpdate
could persist until the hard-nav fallback resets JS state.

Add a brief NB comment on both stageClientParams call sites noting that
the outer catch hard-navigates, which resets all JS state, making this
benign in practice.

* fix: store visited response snapshot only after successful render

Move storeVisitedResponseSnapshot to after the renderNavigationPayload
try-finally block so we only cache RSC responses from navigations that
actually rendered successfully. Previously, the cache write happened
before renderNavigationPayload — if it threw, a future back/forward
navigation could replay a snapshot from a navigation that never rendered.

The hard-nav fallback in the outer catch made this self-correcting in
practice, but caching only on success is the correct invariant.

Also expand the cached-path navId comment to acknowledge the style
asymmetry vs the non-cached path, explaining why no extra check is
needed between createClientNavigationRenderSnapshot (synchronous) and
createFromFetch.

---------

Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: James <james@eli.cx>
2026-04-01 22:12:52 +01:00
James Anderson f99ff7afa8 chore: try different opencode version for bonk (#738) 2026-04-01 12:15:02 +00:00
James Anderson bd2e41f848 chore: turn on pnpm supply chain rules (#729) 2026-03-31 14:15:31 +01:00
James Anderson ef1943ce5d chore: add more gitignore entries (#725) 2026-03-30 19:52:11 +00:00
James Anderson d60c8107bc fix(fonts): handle nested-brace options objects in self-hosting transform (#723)
* fix(fonts): handle nested-brace options objects in self-hosting transform

namedCallRe and memberCallRe used \{[^}]*\} which stops at the first
closing brace, so calls like Inter({ axes: { wght: 400 } }) were silently
skipped and fell back to CDN delivery.

Replace both regexes with a lookahead-only pattern plus a
findBalancedObject() helper that tracks brace depth and skips string
literals, correctly extracting the full options object regardless of
nesting depth.

Adds two regression tests: one for the named-import path and one for the
default-import proxy (namespace member) path.

* fix(fonts): address bonk review — template literal interpolations, dedup, hoisting

- Fix _findBalancedObject to correctly skip ${...} interpolations inside
  template literals. The previous backtick skipper treated any '}' as the
  end of the string, which would prematurely exit for values like
  `val ${nested}`. Now tracks an exprDepth counter inside interpolations.

- Extract _findCallEnd(code, objEnd) helper that skips whitespace and
  returns the index after ')'. Removes ~20 lines of duplicated logic
  between the named-call and member-call branches.

- Hoist both helpers to module level (top-level exported functions) so
  they are not recreated on every transform() call.

- Add tests: integration test for string values containing brace chars
  (Inter({ label: "font {bold}" })), plus unit describe blocks for
  _findBalancedObject and _findCallEnd covering string/template/escape/
  interpolation/whitespace/null edge cases.
2026-03-30 20:48:04 +01:00
Sankalp Mukim f6aedd3615 Add standalone self-host output and align init production scripts (#178)
* feat: add standalone self-host output for vinext build

Generate dist/standalone from output: 'standalone' with a runnable server entry and runtime deps, and align init scripts with vinext build/start for production self-host workflows.

* fmt

* Address bonk review: fix duplicate PHASE constant, complete lazy-chunks extraction, remove dead imports, add process.exit to standalone path

* Address bonk nits: document regex patterns, move PHASE re-exports to bottom, add assets field to BuildManifestChunk

* refactor(standalone): use Vite bundle graph for server externals instead of regex scan

Replace the fragile post-build regex scan of emitted JS files and the
package.json#dependencies over-seeding with a new
vinext:server-externals-manifest Vite plugin. The plugin collects
chunk.imports + chunk.dynamicImports from the SSR/RSC writeBundle hook
and writes dist/server/vinext-externals.json — the authoritative,
compiler-derived list of packages left external in the server bundle.

emitStandaloneOutput now reads this manifest as its sole seed for the
BFS node_modules copy, so standalone output only includes packages the
server actually imports at runtime. Removed collectServerExternalPackages,
walkFiles, the four regex constants, and the package.json deps seeding.

* fix(standalone): copy vinext runtime deps, add node_modules filter to cpSync, remove unused pendingWrites

* fix(standalone): address round-6 bonk nits — explicit root, manifest comment, pre-flight check, bare-specifier comment, export-from re-export

* fix(standalone): address round 7 bonk review comments

- harden outDir resolution in server-externals-manifest: replace fragile
  walk-up heuristic with direct basename check (ssr -> dirname, else dir),
  avoiding misfires when a user's project path contains a 'server' segment
- remove redundant 'chunk' alias in server-externals-manifest writeBundle;
  use 'item' directly after the type guard narrows it to OutputChunk
- add node_modules filter to vinext dist/ cpSync in standalone.ts, matching
  the same defensive filter used for app package copies
- rewrite standalone server.js as pure ESM using import.meta.dirname
  (Node >= 21.2, vinext requires >= 22); drop CJS require/__dirname and
  switch standalone package.json to 'type':'module'
- add explanatory comment for DEFAULT_PHASE constant in next-config.ts
- add comment in cli.ts noting pre-flight path and resolveVinextPackageRoot
  must stay in sync

* fix(standalone): address round 8 bonk review comments

- fix test import: use 'vite-plus/test' instead of 'vitest' in
  standalone-build.test.ts, matching the convention used by every other
  test file in the repo (documented in AGENTS.md)
- extract resolveVinextPackageRoot into utils/vinext-root.ts so cli.ts
  and standalone.ts share a single source of truth; removes the coupling
  comment added in round 7 and the duplicated path traversal logic
- add node_modules filter to dist/client and dist/server cpSync calls for
  defensive consistency with the vinext dist/ and package copies

* interface -> type

* fix(standalone): address round 9 bonk review comments

- add node_modules filter to public/ cpSync for defensive consistency
  with all other cpSync calls in emitStandaloneOutput
- warn on malformed vinext-externals.json instead of silently returning
  an empty list; includes the error string to aid debugging
- add doc comment on copyPackageAndRuntimeDeps clarifying that the return
  value is the full accumulated set (including pre-existing entries), not
  only packages copied by the current call
- document HOST (not HOSTNAME) env var in README standalone section, with
  a note explaining the difference from Next.js standalone behaviour

* fix(standalone): address round 10 bonk review comments

- add comment in writeStandaloneServerEntry explaining why the import of
  vinext/server/prod-server is intentional as a static import: it is a
  documented exports-map entry, always present in the copied dist tree,
  and a static import gives a clearer ERR_MODULE_NOT_FOUND at startup
- generalise outDir sub-directory detection in server-externals-manifest:
  replace basename === 'ssr' with basename !== 'server' so any future
  sub-directory environment (e.g. 'edge') is handled without code changes
- add JSDoc on runtimeDeps clarifying that it returns both dependencies
  and optionalDependencies

* fix(next-config): drop PHASE_DEVELOPMENT_SERVER from re-export

No external consumer imports it from next-config.ts — only
PHASE_PRODUCTION_BUILD is used externally (by cli.ts). Removing it
minimises the public surface area of the module.

---------

Co-authored-by: GPU VM <copilot-gpu-vm@example.com>
Co-authored-by: James <james@eli.cx>
2026-03-30 20:42:40 +01:00
Nathan Nguyen 33dfe26148 perf: optimize resolveParentParams lookups (#718)
* test: add direct unit tests for resolveParentParams

* perf: optimize resolveParentParams lookups

- Use Map<pattern, route> index for O(1) route lookups (was O(N) linear scan)
- Use pre-computed patternParts instead of re-splitting pattern string
- Pre-compute last dynamic segment index (was per-iteration slice+some)

* test: add edge case tests for resolveParentParams

Cover fully static routes, single-segment dynamic routes, and
catch-all child segments.

* refactor: simplify resolveParentParams internals

- Remove dead ParentSegment.params field (allocated but never read)
- Replace slice+join with incremental prefix string building
- Export StaticParamsMap type so tests derive from source
- Improve comment to explain WHY last segment is excluded

* refactor: address bonk review nits in resolveParentParams

- Move StaticParamsMap type above JSDoc block so the function comment
  sits directly above resolveParentParams (was erroneously separating them)
- Declare 'part' before building prefixPattern so it's used for both the
  prefix string concatenation and the startsWith check, avoiding redundant
  array indexing

---------

Co-authored-by: James <james@eli.cx>
2026-03-30 20:31:25 +01:00
James Anderson a7dd2d56af fix(fonts): double-comma syntax error when font options have a trailing comma (#719) 2026-03-30 11:00:29 +01:00
James Anderson a83be594f9 chore: turn on lint rule to prefer type over interface (#716)
* chore: turn on lint rule to prefer `type` over `interface`

* fix vite-hmr.d.ts
2026-03-29 22:09:39 +01:00
James Anderson 2245429c38 chore: turn on no-explicit-any lint rule (#715)
* chore: turn on no-explicit-any lint rule

* Apply suggestions from code review

Co-authored-by: James Anderson <james@eli.cx>
2026-03-29 21:47:32 +01:00
James Anderson 1ff166eb92 chore: turn on more lint rules (#714)
* chore: turn on more lint rules

* fmt

* add todos
2026-03-29 20:40:57 +01:00
James Anderson 3c8ff6a7ac chore(ci): disable nextjs tracker for forks (#713) 2026-03-29 19:09:21 +00:00
Divanshu Chauhan (divkix) f7c8633e7d fix: map route segment revalidate to Nitro routeRules SWR (#669)
* fix: map route segment revalidate to Nitro routeRules SWR

- Add generateNitroRouteRules() to convert ISR routes to Nitro format
- Add writeBundle hook (vinext:nitro-route-rules) that emits routeRules
  to .output/nitro.json when Nitro plugin is detected
- Closes #648

* fix: address review feedback for Nitro routeRules SWR

- Add Number.isFinite() guard to prevent Infinity serializing to null in JSON
- Replace Object.assign with per-route deep merge to preserve existing Nitro route fields
- Add parse error handling with warning + skip to avoid clobbering invalid nitro.json
- Fix Infinity test case to assert no rule is produced (reflects real behavior)
- Fix test pattern from [slug] to :slug (matches AppRoute.pattern syntax)

Note: shims.test.ts failures are pre-existing on main (React 19 SSR hook issues)

* fix: format import statement in index.ts

* fix: remove unnecessary ?? {} in spread operator to satisfy lint

* fix: prefix unused handler parameter with underscore to satisfy lint

* fix: nitro routeRules integration - duplicate execution, mkdirSync, type safety, and tests

- Add hasAppDir guard to prevent duplicate writeBundle execution in App Router multi-env builds
- Add mkdirSync with recursive:true before writing nitro.json to handle missing .output/ dir
- Use proper AppRoute[] and Route[] types instead of any[]
- Add clarifying comment on Infinity revalidate defensive test case
- Add TODO note (verified with URLPattern test) that vinext :param syntax works natively with Nitro's rou3 router
- Add integration tests for nitro.json file I/O: directory creation, content correctness, merge behavior, and empty routeRules handling

* fix: configure Nitro routeRules before build

Nitro reads routeRules from config during compilation, so mutating .output/nitro.json after writeBundle was the wrong integration point. Move ISR rule generation into nitro.setup and cover merge behavior with dedicated tests.

* fix: convert :param route patterns to /** globs for Nitro routeRules

Nitro routeRules use radix3 toRouteMatcher which documents exact paths
and /** glob patterns, not :param segments. Added convertToNitroPattern()
to translate vinext internal route syntax before emitting routeRules keys.

Also adds reviewer-requested documentation comments on the hand-rolled
NitroRouteConfig type, plugin cast, redundant scanning note, and test guards.

Signed-off-by: Divanshu Chauhan <divkix@divkix.me>
Made-with: Cursor

* fix(build): fix convertToNitroPattern regex for consecutive dynamic segments

- Replace regex with split-map-join approach to handle consecutive
  dynamic segments correctly (e.g., /:a/:b now converts to /**/**)
- Add test coverage for consecutive dynamic segment conversion
- Fix mergeNitroRouteRules tests to use /** glob patterns for consistency

Fixes review feedback from ask-bonk on PR #669

* fix: map route params to correct rou3 wildcards in Nitro routeRules

Map bare :param segments to * (single-segment wildcard) and :param+/:param*
to ** (multi-segment wildcard) per rou3 semantics. Update doc comment to
reference rou3 instead of radix3. Fixes review feedback about semantic
correctness of route pattern conversion.

* docs: fix garbled JSDoc example for consecutive dynamic segments

The JSDoc example for /:a/:b was rendering with literal backticks that
made it hard to read. Changed '->' to 'produces' for clarity while
keeping the backticks around the wildcard patterns to prevent the
parser from interpreting /* as a comment start.

---------

Signed-off-by: Divanshu Chauhan <divkix@divkix.me>
2026-03-29 20:03:16 +01:00
Divanshu Chauhan (divkix) fef54db9b4 fix: skip MDX files during RSC scan-build to prevent parse errors (#668)
* fix: skip MDX files during RSC scan-build to prevent parse errors (issue #659)

Adds a vinext:rsc-scan-mdx-guard plugin that runs with enforce:"pre"
to ensure MDX files are skipped before rsc:scan-strip processes them.
This prevents es-module-lexer parse errors when MDX files contain
frontmatter (---) or JSX that isn't valid JavaScript syntax.

Also adds a regression test that uses MDX files with YAML frontmatter
loaded via import.meta.glob to verify the build succeeds.

* fix(mdx): return stub module for undetected MDX files during scan-build

The guard plugin was a no-op that passed raw MDX through unchanged. When
rsc:scan-strip received this raw MDX, es-module-lexer.parse() failed on
frontmatter (---) and JSX syntax.

The real fix is in vinext:mdx: when mdxDelegate is null (no MDX files in app/
or pages/), return a valid JS stub module instead of undefined. This handles
MDX files that enter the build graph via import.meta.glob outside app/pages.

- Remove vinext:rsc-scan-mdx-guard plugin (was a no-op)
- Modify vinext:mdx to return stub module when mdxDelegate is null
- Remove app/mdx-probe/page.mdx from test (was hiding the bug)
- Replace fragile '---' assertion with specific frontmatter checks

Fixes issue #659

* fix(mdx): lazily compile globbed mdx imports

Compile plain .mdx files on demand when they enter the graph via import.meta.glob outside app/pages so scan-build stops crashing without discarding runtime content. Restore the existing happy-path MDX fixture, add focused lazy-compilation coverage, and extract shared Cloudflare fixture setup to keep the regression tests honest.

* fix review issues

* fix: use absolute path for root tsconfig alias to fix import.meta.glob resolution

toViteAliasReplacement was returning "/" (Vite root-relative convention) when
the tsconfig path alias pointed to the project root (e.g. "@/*": ["./*"]).
This worked for regular imports but broke import.meta.glob because Vite's
glob plugin resolves patterns via this.resolve(), which couldn't properly
resolve the "/" replacement to matching filesystem files.

Changed to return the absolute path instead, which works correctly for both
regular imports and glob pattern resolution.

Signed-off-by: Divanshu Chauhan <divkix@divkix.me>
Made-with: Cursor

* refactor: address review feedback on MDX lazy initialization

- Add clarifying comments for mdxDelegatePromise caching semantics
- Document subtle control flow when hasUserMdxPlugin is true
- Suppress duplicate warning on on-demand MDX init (only warn during detection)
- Add case-insensitive extension check in transform hook
- Add test for query parameter edge cases (?raw, ?v=123, etc.)

* refactor: address review feedback on MDX handling

- Merge duplicate query-param MDX skip tests in pages-router.test.ts
- Tighten hasUserMdxPlugin control flow comment for clarity
- Make scanDirForMdx case-insensitive to align with transform check

Refs: PR #668 review feedback

* docs: clarify mdx delegate fallback comment

* chore: trigger ci

---------

Signed-off-by: Divanshu Chauhan <divkix@divkix.me>
2026-03-29 19:51:50 +01:00
James Anderson f8a28fdfe2 chore: add scripts to run unit + integration tests (#712) 2026-03-29 16:20:23 +00:00
Jachin 6d43c942c0 refactor(head): improve SSR head management and validation (#89)
* refactor(head): improve SSR head management and validation

- Introduced `getValidHeadChildren` to collect valid head elements for SSR, ensuring consistent validation and warning for disallowed tags in development.
- Updated `headChildToHTML` to handle props and convert them to HTML strings.
- Simplified the `Head` component's SSR logic to utilize the new validation function, enhancing code clarity and maintainability.

* refactor(head): remove redundant ALLOWED_HEAD_TAGS guard in headChildToHTML

collectHeadElements (called via reduceHeadChildren) already filters out
disallowed tags before they reach headChildToHTML, making the check dead
code. Remove it and document the precondition in the JSDoc instead.

* refactor(head): hoist rawContentTags to module-level Set

---------

Co-authored-by: James <james@eli.cx>
2026-03-29 16:10:42 +00:00
James Anderson 083e973752 fix(build): use createBuilder for Pages Router so cloudflare plugin runs (#711)
* fix(build): use createBuilder for Pages Router so cloudflare plugin runs

* fix environments

* fix for tests

* another guard
v0.0.38
2026-03-29 13:41:32 +01:00
James Anderson 5ce1c13007 refactor(image): introduce util function for image props (#707)
* feat(image): introduce resolveImageSource function to streamline image prop handling

Added a new utility function, resolveImageSource, to consolidate the logic for resolving image source properties (src, width, height, blurDataURL) in the Image component and getImageProps. This change enhances code maintainability and ensures consistent behavior across components.

* tweak

---------

Co-authored-by: Jachin <ygcaicn@gmail.com>
2026-03-28 22:39:06 +00:00
Nicholas Bardy b71ccd27c2 fix: wire ctx.waitUntil for middleware fetch event background tasks (#126)
* fix: pass NextFetchEvent to middleware and bubble up waitUntil promises to Cloudflare Worker ctx

- Instantiates `NextFetchEvent` with `waitUntil` shim and passes to `middlewareFn`
- Extracts `_waitUntilPromises` from the middleware result and attaches them to the `Response` object via `__vinextWaitUntil`
- Updates the generated Cloudflare Worker `fetch` signature to include `ctx: ExecutionContext`
- Iterates over `__vinextWaitUntil` on the final response and delegates to Cloudflare's native `ctx.waitUntil(promise)`
- Enables background tasks (like telemetry and session sync in `@clerk/nextjs`) to survive the worker response lifecycle
- Updates `vinext check` to explicitly report `@clerk/nextjs` as supported
- Adds `app-router.test.ts` assertion to verify `waitUntil` injection

* fix: address review feedback for waitUntil/NextFetchEvent support

- Fix lost waitUntil promises on redirect/custom response early returns
- Settle waitUntil promises in prod-server (Node.js) instead of dropping
- Mark @clerk/nextjs as "partial" until ESM fix lands (clerk/javascript#7954)
- Align __vinextWaitUntil guard in deploy.ts with app-router-entry.ts
- Use const for non-reassigned _waitUntilPromises array
- Add comment explaining waitUntil check ordering in app-router-entry
- Add unit test verifying runMiddleware bubbles up waitUntil promises

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: move waitUntil settlement before early returns in prod-server

Promise.allSettled(waitUntilPromises) was placed after the !result.continue
check, so redirect/custom-response paths silently dropped promises (e.g.
Clerk session sync on auth redirects). Moved it before the check to match
the pattern in deploy.ts.

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

* fix(middleware): remove double drainWaitUntil, include waitUntilPromises on error path

* fix: remove dead __vinextWaitUntil read code with no corresponding setter

* fix: remove no-op .catch on Promise.allSettled, strengthen waitUntil redirect test

* test: add waitUntil continue:true path coverage

* fix: settle waitUntil promises in Pages Router dev server (index.ts)

* fix: drain waitUntil promises in generated entry error paths

* regen snaps

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
2026-03-28 22:21:37 +00:00
Stephen Zhou 207588d164 feat: add instrumentation-client support (#679)
* feat: add instrumentation-client support

* trigger ci

* fix deps

* tweaks

* tweaks

* tweaks

* tweaks

* rebuild lockfile

* fix: add app-with-src to CI E2E matrix

---------

Co-authored-by: James <james@eli.cx>
2026-03-28 21:21:36 +00:00
James Anderson 0b69dea1ee refactor: extract OG asset plugins into src/plugins/og-assets.ts (#706)
Move vinext:og-inline-fetch-assets and vinext:og-assets out of the
monolithic index.ts into a focused file (src/plugins/og-assets.ts).

- createOgInlineFetchAssetsPlugin() factory: owns the per-build cache
  (Map<string, string>) and isBuild flag as closure state, removing
  the two module-level variables that leaked into the outer vinext()
  factory closure.
- ogAssetsPlugin constant: the writeBundle-only plugin has no mutable
  state, so it exports directly as a plain plugin object.

index.ts now imports and delegates to these, removing ~190 lines of
inline plugin code from the main plugin file.
2026-03-28 20:57:38 +00:00
James Anderson c291dcd4b7 refactor: extract font plugins into src/plugins/fonts.ts (#705)
Move all Google Fonts and local fonts plugin logic out of the monolithic
index.ts into a single focused file (src/plugins/fonts.ts). The new file
exports createGoogleFontsPlugin() and createLocalFontsPlugin() factory
functions, plus the shared helpers VIRTUAL_GOOGLE_FONTS,
RESOLVED_VIRTUAL_GOOGLE_FONTS, generateGoogleFontsVirtualModule, and
parseStaticObjectLiteral (which index.ts also uses for tsconfig parsing).

index.ts now imports these and delegates to the factories, removing ~340
lines of inline plugin code from the main plugin file.
2026-03-28 20:49:56 +00:00
mehedi c9d1ddbee6 fix(google-fonts): use closure variables instead of this for plugin sstate (#53)
* fix(google-fonts): use closure variables instead of this for plugin state

Vite does not bind `this` to the plugin object when calling hooks like
configResolved and transform.handler. Using `this._isBuild` etc. results
in a TypeError at startup ('Cannot set properties of undefined').

Replace the property-based state with closure variables so the plugin
works correctly regardless of how Vite invokes the hooks.

* fix(google-fonts): fix indentation of plugin object properties inside IIFE return

* fix: update tests to use closure-compatible plugin initialization

Tests were reaching into plugin internals (_isBuild, _fontCache,
_cacheDir) which are now closure variables after the IIFE refactor.
Updated to drive state through configResolved hook and mock fetch
for cache tests.

* fix(google-fonts): apply closure-variable fix on top of main's refactored plugin

main rewrote the plugin to add virtual module import rewriting but kept
the broken this-binding pattern. Apply the PR's fix: wrap in an IIFE so
state is held in closure vars (isBuild, fontCache, cacheDir) instead of
(this as any)._isBuild etc.

* fmt

* fix tests: replace plugin._isBuild direct access with initPlugin() helper

The google-fonts plugin closure fix removes the _isBuild/_fontCache/_cacheDir
properties from the returned Plugin object. Three tests from main's refactor
still used plugin._isBuild = false directly; replace with initPlugin(plugin,
{ command: 'serve' }) to simulate dev mode via the configResolved hook.

* fix: silence no-unused-vars lint warnings in font-google tests

Remove unused cacheDir variable and prefix unused fetch mock parameters
with underscore (_input, _init) per oxlint convention.

---------

Co-authored-by: Steve Faulkner <sfaulkner@cloudflare.com>
Co-authored-by: James <james@eli.cx>
2026-03-28 20:31:02 +00:00
James Anderson 2dacef853f refactor: extract vinext:fix-use-server-closure-collision into its own plugin file (#704)
Move the inline plugin object from index.ts into
packages/vinext/src/plugins/fix-use-server-closure-collision.ts,
following the same pattern as async-hooks-stub, client-reference-dedup,
and optimize-imports. index.ts now imports and references the exported
fixUseServerClosureCollisionPlugin constant. No behaviour change.
2026-03-28 20:30:00 +00:00
dependabot[bot] 9a07314146 chore(deps): bump h3 from 2.0.1-rc.16 to 2.0.1-rc.20 (#703)
Bumps [h3](https://github.com/h3js/h3) from 2.0.1-rc.16 to 2.0.1-rc.20.
- [Release notes](https://github.com/h3js/h3/releases)
- [Changelog](https://github.com/h3js/h3/blob/main/CHANGELOG.md)
- [Commits](https://github.com/h3js/h3/compare/v2.0.1-rc.16...v2.0.1-rc.20)

---
updated-dependencies:
- dependency-name: h3
  dependency-version: 2.0.1-rc.20
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-28 15:49:53 +00:00
Webdesign29 7d0203d421 fix: RSC compatibility for dynamic() and layout segment context (#466)
* fix: RSC compatibility for dynamic() and layout segment context

Three related fixes for React Server Component environments:

1. **dynamic.ts: Remove "use client" and add RSC async path**

   The `"use client"` directive forced `next/dynamic` into a client
   component boundary, but `dynamic()` should work in server components
   too. In the RSC environment, `React.lazy` is not available (the
   `react-server` condition exports a stripped-down React). Added a
   runtime check: when `React.lazy` is not a function, use an async
   server component pattern instead (the RSC renderer natively supports
   async components).

   Also switched from destructured imports (`lazy`, `Suspense`,
   `useState`, `useEffect`) to `React.lazy`, `React.Suspense`, etc.
   to avoid importing names that don't exist under the `react-server`
   condition.

2. **layout-segment-context.tsx: Remove "use client"**

   This module is imported directly by the RSC entry. The `"use client"`
   directive created a client component boundary that breaks the RSC
   rendering pipeline. `getLayoutSegmentContext()` already returns
   `null` when `React.createContext` is unavailable (RSC), and the
   `LayoutSegmentProvider` gracefully falls back to passing children
   through unchanged.

3. **app-rsc-entry.ts: Wrap route handler params with makeThenableParams**

   Next.js 15+ changed route handler params to be async (Promises).
   Route handlers that `await params` crash when params is a plain
   object. `makeThenableParams()` wraps the object so it's both a
   Promise and has synchronous property access.

* chore: fix formatting and update entry-templates snapshots after merge with main

* fix: restore 'use client' in layout-segment-context to fix useSelectedLayoutSegment(s)

Removing 'use client' caused LayoutSegmentProvider to run in the RSC
environment where React.createContext is undefined. getLayoutSegmentContext()
returned null, the provider became a no-op, and useSelectedLayoutSegments
always returned [] instead of the actual segments.

The 'use client' boundary is required so the RSC bundler renders this
component in the SSR/browser environment where createContext works.

* test: add regression tests for RSC dynamic() and route handler await params

Add two regression tests for the fixes in PR #466:

1. Route handler await params (Next.js 15 async params pattern):
   Tests /api/catch-all/[...slugs] which uses `await params` in the
   handler — verifies that makeThenableParams() correctly wraps params
   so both await and direct property access work.

2. dynamic() in RSC (async component path):
   Adds a pure server component fixture that uses dynamic() without
   "use client". In the RSC environment React.lazy is unavailable, so
   dynamic() must fall back to the async component pattern. Tests that
   the dynamically-loaded component renders in the HTML output.

* fix: address bonk review comments on dynamic.ts RSC async path

- Add comments explaining LoadingComponent is intentionally ignored in
  the RSC async fallback path (parent Suspense boundaries handle loading
  states; error handling defers to nearest error boundary)

- Add comment explaining the 'as unknown as ComponentType<P>' cast is
  safe because the RSC renderer natively supports async components, but
  TypeScript's ComponentType<P> doesn't account for async return types

- Add unit tests for the AsyncServerDynamic path by stubbing React.lazy
  to undefined (simulating a react-server condition without lazy).
  Verifies: displayName, async resolution, bare component exports,
  and that LoadingComponent is correctly ignored.

Note: React 19.x exports React.lazy from the react-server condition,
so typeof React.lazy !== 'function' does not trigger in current React.
The AsyncServerDynamic path is defensive forward-compatibility code.
The unit tests stub React.lazy to exercise it directly.

* chore: remove unused vi import from dynamic.test.ts

* docs: correct inaccurate React.lazy/RSC comments throughout

React 19.x exports React.lazy from the react-server condition, so the
typeof React.lazy !== 'function' guard does not trigger in current
React. The AsyncServerDynamic path is defensive forward-compatibility
code only — it does not execute today.

Update all comments that incorrectly claimed React.lazy is unavailable
in the react-server environment:

- dynamic.ts: top-level doc comment and guard comment
- dynamic-rsc.tsx fixture: comment explaining the RSC path
- rsc-dynamic/page.tsx fixture: explain which path actually executes
- nextjs-compat/dynamic.test.ts: clarify that the integration test
  exercises LazyServer + Suspense, not AsyncServerDynamic

---------

Co-authored-by: James <james@eli.cx>
2026-03-28 15:19:50 +00:00
Steve Faulkner 05cac9d745 build: align Vite 8 bundler config (#599)
* build: align Vite 8 bundler config

* fmt

---------

Co-authored-by: James <james@eli.cx>
2026-03-28 15:16:03 +00:00
James Anderson 3757b0fbe8 refactor: consolidate fileURLToPath path resolution into resolveEntryPath() (#702)
Replace the repeated `fileURLToPath(new URL(rel, import.meta.url)).replace(/\\/g, "/")`
idiom across entry generators with a single shared utility. Add `resolveEntryPath(rel, base)`
to runtime-entry-module.ts, then use it in app-rsc-entry.ts (16 call sites),
pages-server-entry.ts (8 call sites), and have resolveRuntimeEntryModule delegate to it.
Replace `const __dirname = path.dirname(fileURLToPath(import.meta.url))` in index.ts and
tpr.ts with `import.meta.dirname` (Node 22+, already used throughout the test suite).
2026-03-28 10:39:49 +00:00
Steve Faulkner da04d2062b refactor: extract Pages API route runtime (#683)
* refactor: extract pages api route runtime

* refactor: share Pages ISR cache runtime (#684)

* fix: address Pages API route review feedback

- Replace fragile error.message string matches with typed PagesBodyParseError
  (statusCode: 413) thrown from pages-node-compat.ts and api-handler.ts; the
  existing instanceof catch in handlePagesApiRoute handles both 400 and 413
- Fix res.setHeader('set-cookie') to replace instead of append on repeated
  calls, matching Node.js ServerResponse semantics
- Extract shared getMediaType/isJsonMediaType helpers and the body-parse error
  class into pages-media-type.ts to eliminate drift between api-handler.ts and
  pages-node-compat.ts
- Add unit tests for 404 (null match), missing default export (500), redirect
  (1-arg and 2-arg), writeHead header lowercasing/array joining, setHeader/
  getHeader round-trip, and set-cookie replace-not-append parity

---------

Co-authored-by: James <james@eli.cx>
2026-03-28 09:36:30 +00:00
mounir 892fce6315 fix(app-router-playground): clean warnings and stabilize checks (#694) 2026-03-28 09:20:35 +00:00
Raed Alharbi a515f07a49 test: add static export E2E tests (#564) (#686)
* test: add static export E2E tests (#564)

Add Playwright E2E tests for `output: "export"` static builds covering
both App Router and Pages Router. Includes a lightweight static file
server with path traversal protection, proper error handling, and
startup validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update tests/e2e/static-export/serve-static.mjs

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
2026-03-28 09:06:06 +00:00
JamesbbBriz 73f4ca8d8d feat: implement cacheForRequest() per-request factory cache (#646)
* feat: add cacheForRequest() — per-request factory cache

Implements the cacheForRequest API discussed in #623.

API: import { cacheForRequest } from 'vinext/cache'

- Factory function reference is the cache key (no string collision)
- Async factories cache the Promise; rejected Promises clear their entry
  so subsequent calls within the same request can retry
- Outside request scope: factory runs every time (no caching)
- Public barrel at src/cache.ts, separate from the next/cache shim
- Vite dev alias registered in index.ts
- 6 unit tests covering sync, async, isolation, nested scopes, reject+retry

* fix: address review feedback for cacheForRequest

- Switch test imports from vitest to vite-plus/test (repo standard)
- Use runWithUnifiedStateMutation in nested scope test (real code path)
- Add requestCache assertion to createRequestContext defaults test
- Fix JSDoc @param accuracy for async factory retry behavior
2026-03-28 09:01:03 +00:00
Steve Faulkner b678035956 fix: don't await createFromReadableStream before hydrateRoot (#695) (#700)
* fix: don't await createFromReadableStream before hydrateRoot (#695)

createFromReadableStream returns a React thenable, not a resolved tree.
Awaiting it blocked hydrateRoot until the entire RSC stream was consumed,
which prevented useEffect callbacks from ever firing in production on
Cloudflare Workers where RSC chunks arrive progressively via script tags.

Pass the thenable directly to hydrateRoot so React resolves components
lazily as stream chunks arrive -- matching the RSC contract and the
pattern already used in the SSR entry.

Adds a regression test fixture and e2e test for the Workers hydration
project that verifies useEffect fires after RSC hydration.

* fix: move effect-test page to app-router-cloudflare example

The cloudflare-workers e2e project builds examples/app-router-cloudflare,
not tests/fixtures/cf-app-basic. Move the regression test fixture page to
the correct location so the /effect-test route is actually served.
v0.0.37
2026-03-27 22:07:53 -05:00
Steve Faulkner a62e5c4901 chore(benchmarks): remove Vite 7 runner, update Next.js to 16.2.1 (#699)
* chore(benchmarks): remove Vite 7 runner, update Next.js to 16.2.1

The vinext-rolldown benchmark runner was identical to the vinext runner
(both resolved to the same Vite version via catalog), producing
duplicate results on the dashboard. Remove it entirely and simplify
to a two-runner comparison: Next.js (Turbopack) vs vinext (Vite 8).

Also bump the Next.js benchmark dependency from 16.1.7 to 16.2.1 to
pick up recent performance improvements.

* fix: remove vinext-rolldown from pnpm-workspace.yaml, fix migration comment
2026-03-27 19:23:50 -05:00
Steve Faulkner 5ee1607406 fix: use segment-wise pathname decoding in App->Pages Router fallback (#697)
The App Router to Pages Router fallback path used
decodeURIComponent(url.pathname), which decoded %2F to /, changing
the path structure. This allowed middleware bypass: a request to
/security/admin%2Fpanel would skip the middleware matcher for
/security/admin/:path* but the Pages fallback would decode it to
/security/admin/panel and serve the protected page.

Replace with segment-wise decoding (decodePathParams) that splits by
/, decodes each segment individually, then re-encodes path delimiters
(/, #, ?) via escapePathDelimiters. This preserves the path structure
while still decoding non-ASCII characters within segments.

Both utilities are ported from Next.js:
- escapePathDelimiters: packages/next/src/shared/lib/router/utils/escape-path-delimiters.ts
  https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/utils/escape-path-delimiters.ts
- decodePathParams: packages/next/src/server/lib/router-utils/decode-path-params.ts
  https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-utils/decode-path-params.ts

Adds unit tests for escapePathDelimiters (4 tests) and
decodePathParams (8 tests) covering non-ASCII decoding, delimiter
preservation, malformed encoding, and mixed paths.
2026-03-27 10:41:54 -05:00
Steve Faulkner 07b4fb4c70 fix: anchor has/missing condition value regex for full-string matching (#696)
Config rule has/missing conditions used unanchored regex matching,
so a condition like has:[cookie:role=admin] would match cookie values
containing 'admin' as a substring (e.g. 'not-admin', 'admin-temp').

Anchor the regex with ^ and $ to match the full value, matching
Next.js behavior (prepare-destination.ts uses new RegExp(`^${value}$`)).

Adds tests verifying exact match, substring rejection, superstring
rejection, and regex pattern compatibility with anchoring.
v0.0.36
2026-03-27 08:31:08 -05:00
Steve Faulkner 2d03bc569d fix: inline commits into agent prompt to prevent codebase exploration (#693)
The agent kept launching a Task subagent to "explore the vinext codebase
structure" despite instructions not to, consuming the entire 30-minute
timeout. The task permission deny in the agent config was not effective.

Fix: embed the commits JSON directly into the prompt so the agent has
all data immediately and no reason to explore. Also add explicit
"do not explore, do not launch subagents" at the end of the prompt.
2026-03-26 13:54:57 -05:00
Steve Faulkner 1f2cabd9e9 fix: prevent nextjs-tracker agent from wasting time exploring codebase (#692)
The agent was launching a Task subagent to "explore the vinext codebase
structure" which consumed the entire 30-minute timeout. Three fixes:

1. Deny task permission so it cannot launch subagents
2. Add explicit instruction not to explore the codebase
3. Inline the full codebase structure in the agent config so it has
   all the context it needs without file reads
2026-03-26 13:17:45 -05:00
Steve Faulkner 180ba7950a fix: explicitly pass --repo "$GITHUB_REPOSITORY" in nextjs-tracker agent (#691)
The agent hallucinated the repo name as anomalyco/vinext instead of
cloudflare/vinext, causing the gh issue list command to hang for the
entire 30-minute timeout. Explicitly instruct the agent to use the
$GITHUB_REPOSITORY env var for all gh issue commands.
2026-03-26 12:39:44 -05:00
Divanshu Chauhan (divkix) 1cd17b5b69 fix: usePathname() returns "/" during SSR of "use client" page components (#689)
* fix: usePathname() returns "/" during SSR of "use client" page components (#688)

The navigation shim uses a registration pattern where navigation-state.ts
calls _registerStateAccessors() to upgrade navigation.ts from module-level
state to ALS-backed state. This only updates the specific module instance
that imported navigation-state.ts.

In Vite multi-environment dev mode, "use client" components can end up with
a separate module instance of navigation.ts (due to pre-bundling or different
resolver chains). That instance never receives the ALS-backed accessors and
falls back to _serverContext (null), causing usePathname() to return "/".

Fix: navigation-state.ts now also stores the accessor functions on globalThis
via Symbol.for("vinext.navigation.globalAccessors"). The default accessors in
navigation.ts check this global before falling back to module-level state.
This ensures all module instances can reach the ALS-backed state regardless
of which instance _registerStateAccessors was called on.

On the browser (where navigation-state.ts is never imported), the global key
does not exist, so the fallback to module-level _serverContext is preserved —
which is the correct behavior for client-side hydration.

Closes #688

* refactor: deduplicate global accessor key and tighten types

- Export GLOBAL_ACCESSORS_KEY from navigation.ts and import in
  navigation-state.ts to prevent silent drift if the Symbol string
  ever changes
- Add satisfies constraint on the accessor object so shape mismatches
  are caught at compile time
- Assert warm-up fetch status in test to surface fixture setup failures
2026-03-26 12:31:03 -05:00
Steve Faulkner adfc2236cb fix: nextjs-tracker workflow fails because prompt_file is not a valid action input (#687)
The ask-bonk action doesn't support a prompt_file input. The prompt was
silently ignored, causing the scheduled run to fail with 'PROMPT input
is required'. Set the prompt as a multiline step output and reference it
via the prompt input instead.
2026-03-25 08:54:17 -05:00
Steve Faulkner 63cbaad7e0 feat: nextjs-tracker — daily workflow to track Next.js canary changes (#682)
Daily scheduled job that scans Next.js canary commits, uses an AI agent
to classify relevance to vinext, and opens labeled tracking issues for
anything that matters. Supports a dry-run mode for validation.
2026-03-24 21:11:19 -05:00
Steve Faulkner abe0fe5392 refactor: extract Pages page data and ISR runtime (#667) 2026-03-24 20:32:26 -05:00
Steve Faulkner 6cfdaa60a8 fix: percent-encode X-Vinext-Params to allow non-ASCII route params (#677)
Headers.set() requires ByteString values (chars <= U+00FF). When dynamic
route params contain non-ASCII characters (Korean, Japanese, etc.),
JSON.stringify preserves them verbatim which causes a TypeError on the
Headers.set() call in buildAppPageRscResponse.

Fix: encodeURIComponent the JSON on the server side before setting the
header, and decodeURIComponent on the client side when reading it back
during initial hydration and client-side navigation.

Closes #676
v0.0.35
2026-03-24 10:06:06 -05:00
Stephen Zhou fd7618c94a chore: move pnpm settings to pnpm-workspace.yaml (#671) 2026-03-23 09:47:39 +00:00
Nathan Nguyen cc66d4df45 feat: seed memory cache from pre-rendered routes (#645)
* feat: seed memory cache from pre-rendered routes at startup

Read vinext-prerender.json and pre-rendered HTML/RSC files at production
server startup to populate the MemoryCacheHandler. This ensures the first
request to any pre-rendered page is a cache HIT instead of triggering a
full re-render.

- Add seedMemoryCacheFromPrerender() in server/seed-cache.ts
- Add buildId, router, and trailingSlash to the prerender manifest
- Call seeding during App Router production server startup
- 11 unit tests covering ISR routes, static routes, dynamic routes,
  trailingSlash, graceful degradation, and missing files

Closes #561

* fix: address review feedback on cache seeding PR

- Warn on corrupt manifest JSON instead of silent swallow
- Always serialize trailingSlash (typeof boolean, not truthiness)
- Populate setRevalidateDuration() for seeded entries so
  Cache-Control headers are correct on first HIT
- Return seeded route count and log it at startup
- Add tests: corrupt JSON, missing buildId, FNV hash path,
  revalidate duration tracking, return value assertions

Addresses review comments on #645.
2026-03-23 09:43:07 +00:00