* refactor: consolidate 5 nested ALS scopes into unified request context
Every App Router request previously ran through 5-6 nested
AsyncLocalStorage.run() calls (headers, navigation, cache-state,
private-cache, fetch-cache, plus optional execution-context). Each
ALS scope push/pop has measurable cost on Workers isolates.
Introduce a single UnifiedRequestContext that holds all per-request
state in one flat object, backed by one ALS instance. Each shim module
checks isInsideUnifiedScope() first and reads its sub-fields from the
unified store, falling back to its own standalone ALS when outside
(SSR environment, Pages Router, tests).
- Create unified-request-context.ts with createRequestContext(),
runWithRequestContext(), getRequestContext(), isInsideUnifiedScope()
- Rewire _getState() in headers, navigation-state, cache, cache-runtime,
fetch-cache, and request-context to dual-path (unified first, own ALS
fallback)
- Make each runWith*() a no-op inside unified scope
- Replace 5-deep nesting in app-rsc-entry.ts (main handler + ISR regen)
with single _runWithUnifiedCtx() call
- Export ensureFetchPatch() from fetch-cache for standalone patch install
- Add 17 unit tests for unified context (isolation, nesting, concurrency)
* test: update entry-templates snapshots for unified ALS context
Snapshots were stale after the unified request context refactor changed
the generated entry code (new imports, removed 5-deep nesting).
* test: update app router waitUntil assertion
* refactor: unify request ALS across router flows
* fix: address unified request context review feedback
* fix: address unified ALS review feedback
* fix: address remaining unified ALS review feedback
* fix: wrap Pages Router ISR regeneration in unified context
Ensures patched fetch and ALS-based context are available during
background regeneration, matching App Router behavior.
- Creates fresh unified context with execution context for regen
- Calls ensureFetchPatch() to enable cache tagging
- Adds test verifying _runWithUnifiedCtx and ensureFetchPatch presence
- Updates snapshot
Part of Phase 1.5: minimal Pages Router fix for ISR parity.
* Fix dev ISR unified context parity
* fix: address unified ALS review followups
* Fix pages router unified execution context seeding
* Fix Pages Router ISR rerender state isolation
* fix: address unified ALS review feedback
* fix: serve metadata routes in dynamic segments (opengraph-image, icon, etc.)
Metadata routes like opengraph-image.tsx inside dynamic segments
(e.g., app/blog/[slug]/opengraph-image.tsx) returned 404 because the
runtime matching used strict equality against the servedUrl which
contains literal bracket notation ([slug]).
Two fixes:
- Generate patternParts for metadata routes with dynamic segments so
the runtime uses matchPattern() instead of strict equality
- Pass extracted params to the metadata function via makeThenableParams()
matching Next.js behavior where dynamic metadata routes receive { params }
* fix: address review — scope patternParts to dynamic routes, fix indentation
- Only generate patternParts for dynamic metadata routes (static files
don't use params)
- Add "keep in sync" comment for segment-to-pattern conversion
- Fix mis-indented try/catch braces in generated static metadata code
* fix: preserve query params on middleware rewrite in App Router
The middleware rewrite handler in app-rsc-entry.ts only extracted
pathname from the rewrite URL, silently discarding the query string.
All downstream code (searchParams props, useSearchParams(), navigation
context, ISR cache keys) continued using the original request's search
params instead of the rewrite destination's.
Fix: mutate url.search after extracting the rewrite pathname so all
~30 downstream url.searchParams references automatically pick up the
rewrite's query string. This matches the Pages Router's existing
behavior in server/middleware.ts which already preserves pathname+search.
* test: update entry-templates snapshot
* fix: emit hashChangeStart/Complete and beforeHistoryChange events
Next.js emits hashChangeStart/hashChangeComplete for hash-only
navigation and beforeHistoryChange before every history.pushState/
replaceState call. Our router emitted neither.
- Hash-only push/replace now emit hashChangeStart before and
hashChangeComplete after (instead of routeChange* events)
- Normal push/replace now emit beforeHistoryChange between
routeChangeStart and the pushState/replaceState call
- All events now pass { shallow } as the second argument per Next.js
- Popstate handler also emits beforeHistoryChange
- Updated in both useRouter() hook and Router singleton code paths
* fix: emit full URL in hash change events, not just fragment
When router.push("#section") is called, emit the full resolved URL
(e.g., "/page#section") in hashChangeStart/hashChangeComplete events
instead of just "#section". Matches Next.js behavior where the event
URL includes the pathname.
* fix: handle hash-only popstate and address review feedback
- Detect hash-only back/forward in popstate handler by tracking
pathname+search across navigations; emit hashChangeStart/Complete
instead of routeChangeStart/Complete and skip unnecessary page fetch
- Add comment explaining beforeHistoryChange semantics in popstate
(fires after URL change, emitted for API compatibility)
- Cache resolveHashUrl() result in all 4 hash code paths
- Add beforeHistoryChange negative assertions in hash-only tests
- Add E2E test for hash-only back/forward navigation events
* fix: pass { shallow } to routeChangeError, add goForward hash test
- routeChangeError now passes { shallow: false } to match Next.js
signature (err, url, { shallow }), completing the { shallow }
migration across all router events
- Add E2E test for hash-only goForward() popstate detection
* fix: include hash in popstate beforeHistoryChange URL, stabilize forward test
- beforeHistoryChange in popstate now emits appUrl + window.location.hash
for consistency with push/replace paths that preserve the hash fragment
- Add waitForFunction between goBack() and clear-events in the forward
test to prevent race between popstate handler and event clearing
* fix: strip basePath from full-path hash URLs, include hash in popstate event URLs
- resolveHashUrl now handles full-path hash URLs (e.g. /base/page#hash)
by parsing and stripping basePath, not just fragment-only URLs (#foo)
- routeChangeStart and routeChangeComplete in popstate now include
window.location.hash for consistency with beforeHistoryChange and
the push/replace code paths
* fix: update _lastPathnameAndSearch after beforePopState check
Move tracker update to after beforePopState confirms navigation
proceeds. Previously, if beforePopState returned false (cancelling
navigation), the tracker was already updated to the new browserUrl,
causing the next popstate to compare against the wrong baseline and
potentially misdetect a route change as hash-only.
* fix: make useParams() reactive on client-side navigation
useParams() was reading a module-level variable directly instead of
subscribing via useSyncExternalStore, so navigating between dynamic
routes (e.g. /posts/1 → /posts/2) wouldn't re-render components.
Two changes:
- useParams() now uses useSyncExternalStore with the shared listener
system, matching usePathname() and useSearchParams()
- setClientParams() now calls notifyListeners() when params change,
so the external store knows to re-check snapshots
* chore: fix formatting in test fixture
* fix: stabilize useParams server snapshot and deduplicate subscribe
- Add _EMPTY_PARAMS constant to avoid creating a new {} on every
getServerSnapshot call (Object.is would never match, causing
hydration churn)
- Extract getClientParamsSnapshot/getServerParamsSnapshot named
functions for consistency with usePathname/useSearchParams
- Extract subscribeToNavigation helper to deduplicate the identical
subscribe callback across all three hooks
The dev server read middleware custom response bodies with `text()`,
which decodes bytes as UTF-8. This corrupts any binary response body
(images, protobuf, etc.) that middleware returns via `new Response(data)`.
The prod server already uses `arrayBuffer()` + `Buffer.from()` correctly.
Apply the same approach to the dev path.
Also adds middleware fixture routes for binary responses and multi-cookie
responses, with corresponding regression tests.
* feat(build): add Next.js-style route report to vinext build output
- Adds build/report.ts with regex-based route classification (static, ISR, dynamic, unknown, API)
- Clean build logger suppresses Vite/Rollup noise; keeps timing lines and errors
- --verbose flag bypasses filtering and shows raw output
- Routes sorted alphabetically by path (filesystem order)
- Legend sorted alphabetically by label
- Unknown routes show a note explaining classification will improve in a future release
- 55 unit tests covering all classification paths and formatting
* fmt
* fix exmaple
* support -v
* .
* .
* fix(build-report): address review comments
- dynamic='error' now classifies as static (not ssr) — it enforces static
rendering by throwing if dynamic APIs are used, same as force-static
- add TODO comment on extractGetStaticPropsRevalidate regex matching too broadly
- drop -v short alias for --verbose to avoid collision with -v/--version at top level
- classifyPagesRoute now returns unknown on file read failure, consistent with classifyAppRoute
- single-route table now renders with ┌ corner instead of └ (i===0 check takes priority)
- 3 new tests covering the above fixes (58 total)
* test(build-report): replace misleading classifyAppRoute tests with real fixture coverage
The two tests named 'classifies page with revalidate=0 as ssr' and
'classifies revalidate=Infinity as static' both passed null paths and
asserted unknown, not actually exercising the stated code paths.
Replace both with proper integration tests using new fixture files:
- revalidate-zero-test/page.tsx (export const revalidate = 0) → ssr
- revalidate-infinity-test/page.tsx (export const revalidate = Infinity) → static
* test: add regression tests for headers() and cookies() in server actions and route handlers
* test: address review comments on action-headers tests
- Remove ghost test that always passed via 404 bailout
- Remove unused getHeaderFromActionSync fixture export
- Fix extractReturnValue to parse full first line instead of greedy {.+} match
- Fix action ID extraction to match by export name instead of array index
- Add comment clarifying why the route handler test is distinct from existing ones
* test: address second round of review comments
- Use fetchHtml helper for page fetches (consistent with other nextjs-compat tests)
- Clarify page.tsx comments: wrapper closures discard return values, named
export tests are needed for value assertions
* feat: add metadata support for appLinks, iTunes, and Twitter player/app cards
Add four missing Next.js metadata features to the Metadata type and
MetadataHead renderer:
- appLinks: renders al:* property tags (ios, iphone, ipad, android,
windows_phone, windows, windows_universal, web)
- itunes: renders apple-itunes-app name tag with app-id and app-argument
- twitter player cards: renders twitter:player, twitter:player:stream,
twitter:player:width, twitter:player:height
- twitter app cards: renders twitter:app:name/id/url per platform
(iphone, ipad, googleplay)
Includes fixture pages and tests ported from Next.js test suite.
* chore: format metadata files
* fix: resolve twitter player/app and appLinks URLs against metadataBase
* fix: un-nest twitter app card name/url checks to match Next.js behavior
In Next.js, the name, id, and url checks in the twitter app card
platform loop are independent. Our implementation nested name and url
inside the id check, so a platform without an id wouldn't get name or
url tags. This un-nests them to match Next.js parity.
Also adds the missing twitter:app:name:ipad test assertion.
* Update packages/vinext/src/shims/metadata.tsx
---------
Co-authored-by: James Anderson <james@eli.cx>
* Fix Pages API parsing parity
* Fix Pages API body parsing parity
* Fix empty form body parity
* Regenerate entry-templates snapshots after merge
* fix: propagate statusText through prod server sendCompressed non-compressed path
The sendCompressed function had a statusText parameter but only used it in
the compressed response path. The non-compressed else branch called
res.writeHead directly without forwarding statusText, so short error
responses (like 'Invalid JSON' at 12 bytes, well below COMPRESS_THRESHOLD)
lost their custom reason phrase and fell back to the default 'Bad Request'.
Fix: replace the direct res.writeHead call in the else branch with the
writeHead closure that already handles the statusText conditional.
Also set statusText on the ApiBodyParseError response in the pages server
entry template so the value is present for prod-server to forward.
---------
Co-authored-by: James <james@eli.cx>
* Fix Buffer handling in Pages API res
* fix: skip reporting handled Pages API parse errors
* address review: blank line nit, content-length parity for Buffer, fix duplicate Content-Length in sendCompressed
* fix: set content-length for Buffer in production res.send() to match dev parity
---------
Co-authored-by: James <james@eli.cx>
* fix: stabilize instrumentation state and hydration E2E tests
Move Cloudflare instrumentation example state from module-level
variables to globalThis so startup and request paths share one owner
across different module instances.
Add waitForHydration() helper to Pages Router E2E tests to eliminate
flaky button-click assertions that raced against React hydration.
* fix: honor the `as` parameter in router.push/replace
The `as` parameter in push() and replace() was silently ignored
(prefixed as _as) in both the useRouter() hook and the Router
singleton. This broke masked URLs and legacy dynamic-route flows.
Extract resolveNavigationTarget() helper that uses `as` when provided,
falling back to resolveUrl(url) otherwise. Apply to all four call
sites: useRouter().push, useRouter().replace, Router.push, Router.replace.
* fix: address PR review feedback
- Add doc comment to resolveNavigationTarget explaining the url/as
simplification vs Next.js's two-value approach
- Extract waitForHydration to shared tests/e2e/helpers.ts
- Add pathname assertion to verify router.pathname stays as route
pattern (/posts/[id]) after as-based navigation
* fix: remove pathname assertion that tests unimplemented behavior
router.pathname currently returns the resolved path after client-side
navigation, not the route pattern. The correct behavior (returning
/posts/[id] instead of /posts/42) requires the two-value url/as
approach tracked in #462.
* fix: dedup RSC client references to prevent module duplication in dev
When the RSC plugin creates client-in-server-package-proxy modules for
"use client" submodules within packages, it re-exports from absolute
file paths. These bypass Vite's pre-bundling in the client environment,
causing the browser to load two separate module instances — one from the
raw ESM path and one from .vite/deps/. This breaks React context
providers/consumers since createContext() runs twice.
Add vinext:client-reference-dedup plugin that intercepts absolute
node_modules path imports originating from proxy virtual modules in the
client environment and redirects them through bare specifier imports,
which Vite's import analysis routes through pre-bundling automatically.
Closes#409
* fix: make dedup test layout a server component to exercise RSC proxy path
Remove "use client" from layout.tsx so ThemeProvider import goes through
RSC → client-in-server-package-proxy (absolute path), while the page's
useTheme goes through client pre-bundling (bare specifier). This ensures
the integration test actually validates the dedup plugin works.
* docs: address PR review feedback for client-reference-dedup plugin
- Document lossy submodule-to-package mapping assumption in resolveId
- Extract load filter regex to named DEDUP_FILTER constant
- Note default export edge case in generated re-export code
* fix: handle single object for openGraph.images and twitter.images in metadata shim
Next.js allows `openGraph.images` and `twitter.images` to be a single
object (e.g. `{ url, width, height }`) in addition to a string or an
array. The metadata shim only handled string and array cases, causing
"imgList is not iterable" when a single object was passed.
Normalize single objects to arrays before iterating. Also update the
Metadata type to reflect the full range of accepted shapes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fmt
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
* feat: support generateBuildId in next.config and inject build ID at runtime
Resolves generateBuildId() from next.config, falling back to a random UUID (ad-blocker-safe) when not configured or when the function returns null. The resolved ID is injected as process.env.__VINEXT_BUILD_ID via Vite define and embedded into __NEXT_DATA__ for Pages Router (dev, prod, and static export). NextRequest gains a buildId accessor for middleware deployment-skew detection.
* regen snaps
* refactor: extract safeUUID helper and drop buildId param from createSSRHandler
Address review nits: deduplicate the UUID+retry loop into a safeUUID() helper,
and read process.env.__VINEXT_BUILD_ID directly in dev-server instead of
threading it as an explicit parameter through createSSRHandler.
* regen snaps
* fix: stabilize entry-templates snapshot by setting generateBuildId in pages-basic fixture
The random UUID from resolveBuildId was embedded in the server entry snapshot,
making it fail on every run. Set generateBuildId to a fixed string in the
pages-basic test fixture so the snapshot is deterministic.
* fix: move buildId to NextURL to match Next.js API surface
Next.js exposes buildId on nextUrl (request.nextUrl.buildId), not directly
on NextRequest. Move the getter to NextURL and add a pass-through on
NextRequest for convenience. Update tests to assert both paths.
* test: add products/sitemap.ts fixture with generateSitemaps
Adds a nested sitemap fixture that exports generateSitemaps() returning
two IDs (0, 1). The default export receives { id } and returns entries
scoped to that batch. Used by the integration tests for paginated
sitemap support.
* feat: support generateSitemaps() for paginated sitemaps
When a sitemap.ts module exports generateSitemaps(), the runtime
metadata route handler now:
1. Matches URLs at /{prefix}/sitemap/{id}.xml instead of the base
/{prefix}/sitemap.xml (which is no longer served)
2. Calls generateSitemaps() to get the list of valid IDs
3. Validates the requested ID against the list (404 on mismatch)
4. Calls the default export with { id: rawId } and serializes the
result with sitemapToXml()
This matches Next.js behavior: each paginated sitemap is a standalone
<urlset> document. Next.js does not generate a <sitemapindex>.
Closes#397
* test: add integration tests for generateSitemaps and update snapshots
- /products/sitemap/0.xml serves batch-0 entries
- /products/sitemap/1.xml serves batch-1 entries
- /products/sitemap/99.xml returns 404 (invalid ID)
- /products/sitemap.xml returns 404 (base URL not served)
- scanMetadataFiles discovers nested products/sitemap.ts
- Updated entry template snapshots for new generateSitemaps handling
* fix: address Codex review — typed id and single-segment matching
- Pass the original typed id from generateSitemaps() instead of the raw
URL string, so numeric IDs stay numeric in the handler
- Reject multi-segment paths (e.g. /sitemap/a/b.xml) to avoid stealing
requests from legitimate nested routes
- Fix shims.test.ts to find root sitemap by servedUrl, not first match
* fix: address ask-bonk review feedback
- Break long condition into multi-line for readability
- Add TODO for makeThenableParams when upgrading to v16 Promise params
- Add string ID ("featured") to fixture alongside numeric IDs
- Add test: missing .xml extension returns 404 (ported from Next.js)
- Add test: string ID ("featured") is served correctly
- Update entry template snapshots
* add oxfmt formatter: config, scripts, CI, editor setup, docs
* rebuild lockfile
* fix: add Format to required checks list, remove dead ignore pattern
* run fmt
* add format to agents.md again
* fix: resolve NEXT_DATA type conflict with next package types
next/dist/client/index.d.ts declares Window.__NEXT_DATA__ as NEXT_DATA,
which conflicts with our inline type in global.d.ts. The vinext-specific
extensions (__vinext, __pageModule, __appModule) aren't in NEXT_DATA,
causing TS2339 errors.
Extract vinext extensions into a VinextNextData interface and cast at
the two usage sites (client/entry.ts, shims/link.tsx) instead of
re-declaring __NEXT_DATA__ with a conflicting type.
* feat(next-intl): auto-detect next-intl config without createNextIntlPlugin
next-intl's createNextIntlPlugin() crashes in vinext because it calls
require('next/package.json') to check the Next.js version, and next
isn't installed. Instead, vinext now detects next-intl in project
dependencies and registers the next-intl/config alias automatically.
Detection probes i18n/request.{ts,tsx,js,jsx} then
src/i18n/request.{ts,tsx,js,jsx}, matching next-intl's own discovery
order. Explicit aliases from webpack/turbopack config take precedence.
Also adds a targeted warning when config loading fails due to
next-intl/plugin, telling users the wrapper is unnecessary.
Closes#202
* fix: address review feedback
- Update check.ts: next-intl status "partial" → "supported"
- Extend VinextNextData from NEXT_DATA in shims/internal/utils.ts
instead of duplicating fields
- Add JSDoc note about monorepo resolution walk-up behavior
* fix: pass parent promise to generateMetadata() for ancestor metadata access
Implements Next.js's eager-execution-with-serial-resolution pattern for
generateMetadata(): all calls kick off concurrently but each segment's
parent promise resolves serially (root → leaf), so `await parent` inside
a child's generateMetadata correctly gets the accumulated ancestor metadata.
Fixes#375
* regen snaps
* fix: address review comments — remove dead array, pass searchParams to generateMetadata
- Remove unused layoutParentPromises[] array (dead code pointed out in review)
- Pass searchParams to page's resolveModuleMetadata() — the URL query string was
previously always passed as undefined, so generateMetadata() could never read
searchParams from the URL
- Reuse the already-computed spObj for pageProps.searchParams to avoid double iteration
- Add 2 regression tests for searchParams in generateMetadata
* regen snaps
* regen snaps
* fix: pass actual route params to layout generateMetadata() in fallback render
renderHTTPAccessFallbackPage was always passing {} as params to
resolveModuleMetadata(), so layouts with generateMetadata({ params })
would get undefined for all dynamic route segments (e.g. params.slug)
when rendering not-found/forbidden/unauthorized fallback pages.
Fix: use opts?.matchedParams ?? route?.params ?? {} (same expression
already used for the layout component props just below) so that
generateMetadata() in fallback layouts receives the real matched params.
Adds two regression tests to not-found.test.ts: hit an invalid slug that
triggers notFound() and assert the title contains the actual slug value.
Also updates entry-templates snapshots.
* fix: forward params to resolveModuleViewport in renderHTTPAccessFallbackPage
generateViewport({ params }) in a layout wrapping a not-found/error
boundary now receives the actual route params instead of an empty object.
Mirrors the same fix already applied to resolveModuleMetadata.
Adds generateViewport export to the layout-params-notfound fixture and
two regression tests confirming the themeColor includes the real slug.
* fix: preserve middleware redirect headers (Set-Cookie) in production
middleware.ts correctly collects non-internal headers (e.g. Set-Cookie)
on redirect responses, but three consumers dropped them:
- prod-server.ts: sent only Location header, ignored responseHeaders
- deploy.ts: worker entry sent only Location header
- index.ts: inline runMiddleware codegen didn't collect headers on redirect
(it already did for next() and rewrite(), just not redirect)
The dev server (index.ts:2934) handled this correctly, making this a
dev/prod parity bug where cookies set during middleware redirects worked
in development but were silently dropped in production.
* test: update entry template snapshot for redirect header fix
---------
Co-authored-by: James <james@eli.cx>
* feat(og): add @next/og support with Cloudflare Workers compatibility
- Add vinext:og-font-fix transform plugin that patches @vercel/og/dist/index.edge.js
at Vite transform time: inlines the fallback font as base64 (avoids fetch() with
import.meta.url which breaks in workerd) and defers WASM init to first use (avoids
WebAssembly.instantiate() being blocked in Node.js module runner)
- Add @vercel/og to optimizeDeps.exclude so Vite's esbuild pre-bundler doesn't cache
the module before the transform hook runs
- Add vinext:og-assets build plugin that copies font/WASM assets to RSC output dir
- Add /app/api/og route to app-router-cloudflare example and app-basic fixture
- Add shared Playwright e2e spec (tests/e2e/og-image.spec.ts) asserting PNG validity,
correct 1200x630 dimensions, param-driven content, and determinism
- Register og-image.spec.ts in app-router, cloudflare-workers, and cloudflare-dev
Playwright projects (18 tests total, all passing)
* refactor(og): generalize fetch+import.meta.url asset inlining, drop WASM lazy-init patch
Replace the @vercel/og-specific vinext:og-font-fix plugin with a general
vinext:og-inline-fetch-assets plugin that:
- Matches ANY fetch(new URL('./asset', import.meta.url)).then(res => res.arrayBuffer())
pattern, not just the hardcoded noto-sans filename
- Resolves each asset relative to the module's on-disk path (via the transform id)
and inlines it as base64 — works for any font version or additional assets
- Applies to any library using this pattern, not just @vercel/og
Also removes the WASM lazy-init patch: now that @vercel/og is in optimizeDeps.exclude,
the module runs through workerd (not the Node.js pre-bundler cache) where
WebAssembly.instantiate() works fine. The lazy-init workaround was only needed
when the pre-bundler cache bypassed the transform hook.
The og-assets build plugin is updated to only copy resvg.wasm (not the font,
which is now inlined and will no longer appear in the bundle by filename).
* fix(og): inline readFileSync assets from @vercel/og node build
The production RSC bundle resolves @vercel/og under the 'import' export
condition, getting dist/index.node.js instead of dist/index.edge.js.
The node build loads font and WASM via fs.readFileSync(fileURLToPath(
new URL('./file', import.meta.url))), which breaks after bundling because
import.meta.url points to the bundle output file, not the dist/ dir.
Extend vinext:og-inline-fetch-assets with a second regex pattern that
matches readFileSync(fileURLToPath(new URL(...))) and replaces it with
Buffer.from('<base64>', 'base64'), inlining both noto-sans font and
resvg.wasm at transform time. This eliminates the ENOENT errors seen
in the production build CI tests.
* refactor(og): address code review feedback
- Remove Fix 1 (font inlining) from vinext:og-font-patch — it was dead
code since vinext:og-inline-fetch-assets (earlier in the plugin array)
already handles the same fetch(new URL(...)) pattern generically. Only
Fix 2 (yoga WASM extraction) remains in og-font-patch.
- Use split().join() instead of String.replace() in both Pattern 1 and
Pattern 2 loops of og-inline-fetch-assets. String.replace(string, ...)
only replaces the first occurrence; split().join() replaces all of them,
which matters for libraries that may use the same pattern multiple times.
* refactor(og): use replaceAll instead of split().join()
* fix: nav context hydration mismatch
* fix: address bonk review — add __VINEXT_RSC_NAV__ type, serialise searchParams as entry pairs
- Add __VINEXT_RSC_NAV__ global type declaration to global.d.ts with
searchParams typed as [string, string][] (array of entry pairs)
- Change generateSsrEntry() to serialise searchParams via
[...navContext.searchParams.entries()] instead of Object.fromEntries(),
preserving duplicate keys (e.g. ?tag=a&tag=b)
- Update tests to assert the new array-of-pairs shape; new URLSearchParams()
on the client correctly handles both single and duplicate keys
* fix(app-router): don't clear RSC context before lazy stream is consumed
* fix(app-router): remove premature context clears from server action and intercepting route RSC branches
Same bug as fixed for renderHTTPAccessFallbackPage/renderErrorBoundaryPage: context
was cleared immediately after renderToReadableStream() returned, before the stream
was lazily consumed by the client. Async server components running during stream
consumption would see null context and fail.
Two additional instances fixed:
- Server action RSC re-render branch (after action.apply())
- Intercepting route RSC response branch
The getAndClearPendingCookies() / getDraftModeCookieHeader() calls in the server action
branch are intentionally preserved — they read synchronously from context set during
action execution (not during stream rendering) and must run before the response is sent.
Context cleanup is handled naturally by the AsyncLocalStorage scope from
runWithHeadersContext() unwinding after all async continuations complete.
Vinext now automatically detects installed packages with broken ESM
resolution and adds them to resolve.noExternal for both the rsc and
ssr environments. This fixes 'Cannot find module' errors when using
packages like validator that have extensionless internal ESM imports.
Root cause: Vite externalizes dependencies for SSR by default, but
some packages use extensionless imports (e.g. ./util/assertString)
that fail under Node.js strict ESM resolution. Additionally, Vite 7's
Environment API means ssr.noExternal only affects the ssr environment,
not the rsc environment where server components run.
Changes:
- Add KNOWN_PROBLEMATIC_ESM_PACKAGES list (validator, date-fns)
- Add collectNoExternalPackages() to merge user config with auto-detected packages
- Propagate noExternal to both rsc and ssr environments
- Add validator ecosystem test fixture and integration tests
- Add troubleshooting documentation
Fixes#189
The previous depth-based approach sliced the pathname by a numeric URL
depth, which broke when route groups were present — they don't appear
in URLs but Next.js includes them in the segments array. This also
meant dynamic params were returned as raw URL strings rather than
resolved values matching the route tree.
Replace `layoutSegmentDepths: number[]` with two new fields:
- `routeSegments: string[]` — filesystem segments from app/ root
- `layoutTreePositions: number[]` — directory depth per layout
Each layout now receives pre-computed child segments through React
context (including route groups, with dynamic params resolved to
actual values and catch-all segments joined with "/").
Additional fixes found during code review:
- Pass matched params to error/fallback rendering paths so dynamic
segments resolve correctly during error page rendering
- Skip optional catch-all segments when param is empty/undefined
- Extend routeSegments for parallel slot sub-routes
- Update stale type declaration in next-shims.d.ts
Ported from Next.js: test/e2e/app-dir/hooks/hooks.test.ts
https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/hooks/hooks.test.ts
* chore: add .worktrees/ to gitignore
* feat: support `export const revalidate` on App Router GET route handlers
Read the route segment config `revalidate` from route handler modules
and set `Cache-Control: s-maxage=N, stale-while-revalidate` on GET
responses, matching Next.js behavior. Only applies when the handler
does not set its own Cache-Control header.
Unskip two E2E fixme cases and add vitest coverage.
* fix: guard revalidate=0 and add proper test fixtures
- Skip Cache-Control injection when revalidate is 0 (means "never cache")
- Add /api/custom-cache fixture that sets its own Cache-Control header
- Add /api/no-cache fixture with revalidate=0
- Fix "does not override" test to use a fixture that actually sets Cache-Control
- Add revalidate=0 test case
* fix: CSS imports from node_modules crash SSR (#270)
When a node_modules package imports a `.css` file, SSR crashes with
"Unknown file extension .css" because Vite externalizes node_modules
by default — causing Node's native ESM loader to handle the import,
which it can't do for non-JS files.
Set `noExternal: true` on all server environments (Pages Router SSR,
App Router RSC, App Router SSR) to force node_modules through Vite's
transform pipeline. This matches Next.js behavior of bundling
everything for SSR. Explicitly listed `external` packages (react,
satori, resvg, yoga-wasm-web) take precedence per Vite's rules.
Skipped for Cloudflare/Nitro targets which already bundle everything.
* fix: externalize better-sqlite3 and fix test hermiticity
- Add better-sqlite3 to ssr.external in better-auth fixture config.
Native addon packages using `bindings` break when Vite transforms them
(stack trace introspection fails). This mirrors Next.js's
serverExternalPackages — users with native addons must externalize them.
- Fix Pages Router CSS test mutating real workspace node_modules.
Previously symlinked tmpDir/node_modules → repo root, then created
fake-css-lib inside the symlink target, leaking into the real workspace.
Now creates a real node_modules dir and symlinks individual packages.
* fix: propagate user ssr.external into RSC and SSR environments
Vite 6+ treats `ssr.*` config as sugar for `environments.ssr.*` only —
it does NOT propagate to custom environments like `rsc`. This means
packages listed in `ssr.external` (e.g. better-sqlite3) were still
being transformed by Vite in the RSC environment, breaking native
addon resolution via the `bindings` package.
Read user's `ssr.external` array and merge it into both
`environments.rsc.resolve.external` and
`environments.ssr.resolve.external` so native addons are properly
externalized from all server environments.
Fixes CI failures in better-auth ecosystem tests on Linux where the
bindings package's stack trace introspection fails when the native
module is transformed by Vite.
* fix: address review comments on node-modules CSS SSR crash fix
- Fix wrong repo URL in test comment (nicepkg/vinext -> cloudflare/vinext)
- Propagate ssr.external: true into RSC and SSR environments correctly
- Move fake test packages from committed node_modules to __test_packages__
with file: protocol dependencies, removing fragile force-tracked files
- Add CSS module (fake-css-module-lib) coverage to Pages Router test
- Narrow Pages Router test symlinks to only required packages
* fix: address bonk review comments
- Update stale comment on top-level ssr block to reflect both React
externalization and noExternal: true behavior; document that user
ssr.noExternal is intentionally superseded by vinext's setting
- Remove now-redundant noExternal: ['better-auth'] from better-auth
fixture (vinext's blanket noExternal: true already covers it)
- Merge Pages Router test's two separate 'it' blocks (both hit same URL)
and add missing fake-css-module-rendered assertion for .module.css
- Guard symlink .catch() to only swallow EEXIST errors, not all failures
- Add comment explaining appDir: tmpDir usage in Pages Router test
---------
Co-authored-by: James <james@eli.cx>