* fix(app-router): preload next/dynamic chunks with CSP nonce
Rendered next/dynamic boundaries produced no SSR preload links for their client chunks. That diverged from Next.js and broke nonce-based CSP tests that inspect preload tags before hydration.
The runtime assumed React.lazy alone was enough for dynamic imports. It never carried the dynamic module IDs from the source call into SSR, and client JS chunk URLs did not use Next.js's static/chunks path shape.
Add a focused dynamic metadata transform, resolve boundary files from the client build manifest, emit nonce-bearing preload hints during SSR, and keep client JS assets under _next/static/chunks. Ported regression coverage verifies the nonce and the emitted path shape.
* fix: honor assetPrefix for dynamic preload globals
* test: cover asset-prefixed dynamic preload nonces
App Router production only covered the default dynamic preload URL path. That left assetPrefix regressions at the manifest-to-runtime emission boundary unguarded.
Add a startProdServer regression that builds app-basic with assetPrefix "/cdn" and asserts nonce-bearing next/dynamic chunk preloads render under /cdn/_next/static/chunks/.
* test: isolate asset-prefixed prod server globals
The assetPrefix regression starts a second production server in the same Vitest process. startProdServer and the generated RSC runtime install global manifest and module-loader state, so leaving the temporary build's globals in place breaks later production requests in the shared server.
Snapshot and restore the relevant globals around the temporary /cdn server while keeping the integration assertion at the real runtime boundary.
* refactor: reuse shared record guard in dynamic preload metadata
The dynamic preload metadata transform carried a local object guard even though the codebase already has a shared record predicate. That made the AST helper drift from the existing non-array record invariant used elsewhere.
Reuse isUnknownRecord from utils/record and keep the plugin-specific AST alias local to the transform.
* fix: make dynamic preload metadata binding-aware
The next/dynamic metadata transform matched calls by imported identifier text. Shadowed parameters and block bindings with the same name were therefore mutated even when they no longer referred to the next/dynamic import.
Walk the parsed module with lexical binding state so shadowed names are excluded, and collect object-form imports only from loader/modules rather than every property on the options object.
* fix: model switch and class scopes in dynamic preload transform
The dynamic preload metadata transform still treated imported next/dynamic names as active inside switch case lexical scopes and named class expression bodies. That could inject loadableGenerated metadata into calls that resolve to local bindings rather than the import.
The scope walker now removes switch case-block bindings while traversing case tests and consequents, leaves the switch discriminant in the outer scope, and scopes named class declarations and expressions through their children. Regression tests cover switch case shadowing and named class expressions.
* fix: address PR review feedback for next/dynamic CSP nonce
- Drop `as="style"` from `<link rel="stylesheet">` — `as` is a
preload attribute per the HTML spec, semantically incorrect on
stylesheet links. Browsers ignore it but it produces bad HTML.
- Remove brittle `import(` string gate from the transform — it could
miss `import ("./x")` (whitespace between import and paren). The
Vite filter already gates on "next/dynamic" and the AST parse is the
real validation.
- Add comment explaining MagicString mutation safety in Promise.all.
- Add comment explaining AST end-exclusive assumption on closeParen.
- Add test for whitespace-separated dynamic import syntax.
* fix: update global.d.ts comment to mention asset prefix, fix formatting
* refactor: extract computeClientRuntimeMetadata shared helper
Extract the duplicated client-manifest runtime metadata computation
(lazy chunks, dynamic preloads, client entry) into a single shared
helper in utils/client-runtime-metadata.ts.
Both prod-server.ts (Pages Router Node.js production server) and
index.ts (Cloudflare closeBundle hook) now call the same helper,
eliminating the highest-risk seam in the PR where basePath/
assetPrefix handling produced bugs.
* test: add TSX-generic and object-form metadata regression tests
- Add post-type-strip generic call test verifying multi-line
dynamic() calls work after TS/JSX stripping by esbuild.
- Add object-form existing loadableGenerated preservation test
to guard against duplicate injection in the object overload.
* chore: remove unused ClientRuntimeMetadata type export
* revert: discard unrelated lockfile change from previous commit
* test: replace deploy test simulators with computeClientRuntimeMetadata
simulateCloseBundleAppRouter and simulateCloseBundlePagesRouter
no longer hand-roll lazy chunk and dynamic preload computation.
They call the shared computeClientRuntimeMetadata helper directly,
matching production wiring.
Also:
- Update helper doc comment to mention Node server startup
- Remove unused imports (manifestFileWithAssetPrefix,
manifestFileWithBase, computeDynamicImportPreloads,
dynamicImportPreloadsWithBase) from deploy.test.ts
* test: add absolute assetPrefix CSP nonce tests and clean up redundant globals
* fix(app-router): keep CSP nonce on next/dynamic preloads from Server Component call sites
DynamicPreloadChunks rendered in the environment of the dynamic() call site. For a Server Component call site it ran in the RSC environment, where the script-nonce React context is unavailable, so the emitted dynamic preload <link>s dropped the request nonce — a CSP violation under `script-src 'nonce-…' 'strict-dynamic'`. The existing tests missed this because every fixture called dynamic() from "use client" modules, which render in the SSR pass where the nonce is present.
Extract DynamicPreloadChunks into its own "use client" module (mirroring Next.js's PreloadChunks and vinext's next/script shim) so it always renders in the SSR pass where withScriptNonce() installs the provider, regardless of whether dynamic() is called from a Server or Client Component.
Add fixture regression tests: server-call-site JS and CSS preloads carry the nonce, an ssr:false boundary emits no server preload (Next.js loadable.tsx parity), and preloads are still emitted without a nonce when no CSP is set.
* test(app-router): add browser CSP-nonce e2e for Server Component next/dynamic call site
The existing csp.spec.ts browser test only exercises the client call-site page (/nextjs-compat/dynamic). Add a Playwright test for the Server Component call site (/nextjs-compat/dynamic/rsc-imports-client) asserting the lazily-loaded client widget hydrates under script-src 'nonce-…' 'strict-dynamic' with no CSP violations in the console.
* fix(app-router): correct next/dynamic preload key-spaces and harden the transform
Addresses review findings on the next/dynamic CSP-nonce feature.
- Lazy chunks must stay in the SSR-manifest key-space (basePath only); only dynamic preloads take the assetPrefix. Applying an absolute-URL assetPrefix to lazy chunks broke the Pages Router modulepreload-exclusion membership test and leaked lazy chunks into <link rel=modulepreload>.
- Resolve symlinks on both sides when computing a dynamic boundary's module ID (reusing relativeWithinRoot/tryRealpathSync), so the key matches the client manifest under pnpm/Cloudflare symlinked layouts instead of silently dropping the preload.
- Throw on dynamic() calls with >2 args (Next.js react-loadable parity).
- Insert the generated options arg right after the first arg, preserving comments/whitespace (the previous substring-comma scan ate commas inside comments).
- Recurse into _next/static/chunks/ in the on-disk client-entry fallback (client JS now lands under chunks/).
- Add typeof-window guard to DynamicPreloadChunks and document the deliberate as="style" omission for stylesheet links.
- Extract buildRuntimeGlobalsScript so the Cloudflare closeBundle injection shares one source of truth with the deploy tests (which now mirror includeClientEntry + the mixed app+pages branch).
Tests: lazy/dynamic key-space split incl. absolute-assetPrefix leak regression and a realistic already-prefixed manifest; mixed app+pages client-entry injection; symlinked module-id resolution; nested dynamic() calls; >2-arg throw; comment-safe insertion; ssr:false positive content assertion; honest dev-mode E2E naming (preload-nonce coverage lives in the prod-server Vitest suite).
* fix(app-router): fix parenthesized next/dynamic loader corruption + harden re-review findings
Follow-up to the re-review of 1709db35.
- Fix a regression where inserting the options arg at the first argument's AST end corrupted a parenthesized loader (dynamic((() => import('./x')))) into a sequence expression that DROPPED the loader. Insert at the call's closing paren (paren-safe) with a comment-aware trailing-comma check (keeps comment-safety, avoids ',,'). Verified all four arg shapes via the real transform.
- Add an end-to-end round-trip test: real computeClientRuntimeMetadata -> globalThis.__VINEXT_LAZY_CHUNKS__ -> real collectAssetTags, asserting a lazy chunk is excluded from modulepreload under basePath+assetPrefix (consumer-side guard for the lazy-chunk key-space fix).
- Replace the hand-rolled simulateAssetTagFiltering with the real exported collectAssetTags (kills drift + a stale 'in index.ts/can't import' comment).
- Add an on-disk client-entry fallback test for the real _next/static/chunks/ layout (prior tests only used the flat layout).
- Hardening: memoize root realpath in toManifestModuleId; .length/keys guards in buildRuntimeGlobalsScript; DEBUG-gated parse-failure log; line:col in the >2-arg error; document the preserveSymlinks:false dependency; negative test for a shadowed dynamic(a,b,c) not throwing; ssr:false asserts visible text; honest typeof-window guard + E2E comments.
* test(app-router): fix stale comments + harden next/dynamic tests (3rd review pass)
Addresses the 3rd re-review of the next/dynamic CSP-nonce work. No runtime behavior change (doc + test hardening + one behavior-neutral nonce-prop cleanup).
- Fix two stale comments claiming DynamicPreloadChunks is NOT 'use client' (the opposite of the implemented, correct behavior) in the prod-server test and the rsc-imports-client fixture — leftovers from when the bug was first reproduced.
- ssr:false / assetPrefix tests: match the fetchPriority attribute case-INSENSITIVELY so a future React lowercasing can't make the ssr:false negative assertion pass vacuously.
- Add bare-promise dynamic(import(...)) transform tests (+ parenthesized variant) via a shared firstDynamicCallArgTypes AST helper; strengthen the double-comma test with an exact-output assertion.
- Make the basePath+assetPrefix round-trip test assert lazy-exclusion by basename, not the exact modulepreload href (that URL is subject to a separate, pre-existing collectAssetTags asset-URL bug).
- Nits: 'absent/undefined map' wording; unify JS/CSS nonce-prop handling; document the process-lifetime root-realpath cache.
Investigation note: a sub-agent confirmed a separate PRE-EXISTING bug (out of scope here): under basePath + a distinct path-style assetPrefix, collectAssetTags emits modulepreload hrefs as /<basePath>/<assetPrefix>/_next/... (404) while assets serve at /<assetPrefix>/_next/...; DynamicPreloadChunks already emits the correct assetPrefix-only URL.
* fix(pages-router): serve assets from assetPrefix (not basePath) when both are set
When basePath and a distinct path/absolute assetPrefix were both configured, the Pages Router emitted asset hrefs as /<basePath>/<assetPrefix>/_next/... (and the client-nav pageModuleUrl likewise), which 404 — assets actually serve at /<assetPrefix>/_next/... . assetPrefix REPLACES basePath for asset URLs in Next.js; App Router (React bootstrapModules) and next/dynamic preloads were already correct — only the Pages Router collectAssetTags / resolveClientModuleUrl paths were wrong.
- Add assetServingUrlFromBaseAnchored(value, basePath, assetPrefix): strips the basePath segment from a base-anchored SSR-manifest value and re-anchors under the assetPrefix (no-op when assetPrefix is unset). Mirrors how next/dynamic preloads are computed.
- collectAssetTags: render <link>/<script> hrefs via the re-anchoring helper while KEEPING the base-anchored value for the lazy-chunk membership test + dedup. Thread basePath/assetPrefix from vinextConfig.
- resolveClientModuleUrl (pageModuleUrl/appModuleUrl, import()ed client-side for navigation): re-anchor the same way.
Tests: collectAssetTags re-anchoring unit tests (path/absolute assetPrefix, client entry, base-only no-op, lazy-exclusion still keyed on the base-anchored value) + an end-to-end Pages Router build with basePath:'/docs' + assetPrefix:'/cdn' that fetches every emitted asset URL and asserts 200 (the previously-missing guard). Found via the sub-agent investigation in the prior review round.
* chore(app-router): address bonk review comments on next/dynamic preloads
No behavior change — review-comment cleanup (comments + one behavior-neutral refactor).
- dynamic-preload-metadata: honor the ResolveDynamicImport `importer` arg in the plugin resolver closure instead of closing over `id` (equivalent today; removes the signature/impl footgun bonk flagged).
- dynamic-preload-metadata: expand the dynamicLoaderNode `?? modules` comment to explain the react-loadable parity (it's a harmless no-op for string-array modules), per the bonk note raised twice.
- lazy-chunks: document that computeDynamicImportPreloads intentionally does NOT subtract shared eager chunks from a boundary's preload set — harmless (browser/ReactDOM.preload/React stylesheet model all dedupe by URL) and mirrors Next.js's per-module file listing (the bonk 'redundant preloads' observation).
Already-resolved bonk items verified: the redundant installClientBuildManifestGlobals call is already guarded behind the App-Router-only else branch (prod-server.ts), and the absolute-assetPrefix CSP test already asserts the nonce on cross-origin https://cdn… preload hrefs.
---------
Co-authored-by: James <james@eli.cx>
* fix(app-router): emit pages client entry for hybrid builds
* fix pages router navigation params readiness
* fix(pages-router): track hashed fallback client entry
* fix(pages-router): honor navigation readiness in hooks
* fix(pages-router): derive server router.isReady from SSR readiness
Address review feedback on #1741:
- getRouterSnapshot() now reads navigationIsReady from the SSR context on
the server instead of unconditionally returning true. For pre-ready Pages
routes (auto-export dynamic / query-string / rewrite-capable builds) this
keeps useRouter().isReady consistent between SSR and client hydration,
avoiding a hydration mismatch for components that read it in JSX. Mirrors
Next.js render.tsx's server readiness rule.
- Drop the redundant guard branch in getClientParamsSnapshot.
- Add a regression test asserting server useRouter().isReady reflects the
SSR navigationIsReady context.
* fix(pages-router): align param resolution and dev/prod readiness parity
Address review feedback (ask-bonk + self-review) on #1741:
- Align client Pages navigation params to query-first, matching the server
snapshot and Next.js's adaptForPathParams (derives path params from
router.query). __NEXT_DATA__.query is kept current on client navigation, so
the two snapshots are now provably identical and avoid a latent post-
hydration mismatch.
- Populate window.__VINEXT_PAGE_PATTERNS__ in the dev server (own script tag)
so the next/navigation compat hooks can resolve a dynamic pattern from a
resolved path in dev exactly as in production. Closes a dev/prod parity gap
(prod set this via the client entry; dev never did).
- Drop the redundant pre-readiness setSSRContext in dev; the single post-
module-load call now publishes navigationIsReady before rendering, matching
the prod handler.
- Document that installPagesClientAssetGlobals is intentionally inert for
App-only builds, and that getReadonlyPagesSearchParams' string-keyed cache
is safe to share across concurrent SSR requests (immutable wrappers).
- Add a dev regression test asserting __VINEXT_PAGE_PATTERNS__ exposure.
* fix(pages-router): prefer URL params for client snapshots
* fix(app-router): gate pages asset globals
* test(app-router): add production test for hybrid basePath + assetPrefix
* fix(navigation): widen usePathname return type to string | null
* refactor(pages-router): dedupe navigation readiness modeling and cache pattern scan
Addresses code-review cleanup findings on the Pages Router navigation
readiness work:
- Extract buildPagesReadinessNextData() so the dev SSR handler and the
production Pages page handler derive the gssp/gsp/gip/appGip/autoExport
__NEXT_DATA__ readiness flags from one place, instead of two hand-kept
copies that could drift and produce a dev/prod hydration mismatch.
- Extract resolvePagesNavigationPathname() shared by the client and SSR
branches of getPagesNavigationContext, so the pathname null-ness rule
(fallback / pre-ready auto-export dynamic) cannot diverge between them.
- Memoize resolvePagesRoutePatternForPath()'s pattern scan, which ran an
uncached O(routes) loop on every useSyncExternalStore snapshot read.
- In getServerSearchParamsSnapshot, serialize the source only when the
object-identity fast path misses, avoiding a toString() on every read.
* refactor(pages-router): consistent ISR readiness data + robustness cleanups
Addresses further code-review findings:
- Thread the readiness flags (gssp/gsp/gip/appGip/autoExport) through the ISR
background-regeneration path: renderPagesIsrHtml now serializes the same
__NEXT_DATA__ readiness state the initial render emits, so a regenerated,
re-cached page hydrates with the identical initial router.isReady the server
computed. Previously these flags were dropped on that path — benign today
(ISR-eligible getStaticProps routes never trigger the flag-gated early-ready
branches) but a latent dev/prod hydration-readiness drift. Adds a shared
PagesNextDataExtras type so every render path stays in lockstep.
- readSsrManifest: degrade a malformed/unparseable SSR manifest to "no SSR
manifest" with a warning instead of throwing and aborting server startup,
restoring the previous tolerant behavior.
- pages-readiness: project PagesReadinessNextData from the canonical
VinextNextData and accept PagesPageModule, instead of bespoke types that
could drift from the __NEXT_DATA__ shape.
- getReadonlyPagesSearchParams: drop the redundant string-keyed cache slot; the
per-object WeakMap is authoritative and concurrency-safe on its own.
- isPagesRouterReady: drop the redundant server-side window check.
* fix(pages-router): restore string-keyed readonly search-params cache
Reverts the cache simplification from the previous commit. The string-keyed
slot is load-bearing, not redundant: across the Pages Router pre-ready → ready
transition the navigation context swaps in a new URLSearchParams instance even
when the query string is unchanged, so the per-object WeakMap alone returns a
fresh ReadonlyURLSearchParams wrapper and useSearchParams() loses Object.is
stability — re-firing a [searchParams] effect. Caught by the rewrite-driven
pre-ready static page e2e (search-params-snapshots saw ['',''] not ['']).
Keeps the improved comment explaining why both cache levels are needed.
---------
Co-authored-by: James <james@eli.cx>
* fix(build): correct CSS ordering for global-not-found (#1549)
* test(build): assert global-not-found CSS ordering in production (#1549)
Adds a production-build regression test for #1549: builds the global-not-found-css-order fixture, serves it via the Vite preview server, and asserts the route-miss 404 document serves global-not-found's own CSS (red wins) without leaking the root layout's stylesheet (green), while matched routes still serve the layout's CSS.
Verified the test fails without the framework-chunk split in client-build-config.ts (the 404 leaks the layout's green) and passes with it.
* chore: update lockfile for global-not-found-css-order test fixture (#1549)
* refactor(build): migrate RSC framework chunk to codeSplitting; add unit tests
Address bonk review on #1858 (issue #1549):
- Replace the deprecated rolldown `advancedChunks` output config with
the non-deprecated `codeSplitting` form for Vite 8+, mirroring the
client build. Stops the per-prod-build deprecation warning.
- Derive both RSC_FRAMEWORK_CHUNK_TEST (regex) and isRscFrameworkModule
(predicate) from a single FRAMEWORK_PACKAGES list so they can't drift.
- Add focused unit tests for createRscFrameworkChunkOutputConfig (both
the Vite 7 manualChunks and Vite 8+ codeSplitting branches) and for the
framework package matching (matches react/react-dom/scheduler/
react-server-dom-webpack, excludes react-icons / @react-aria/*).
A Pages Router app built with a `basePath` emits its page and `_app`
chunk URLs with the basePath applied twice, e.g.
`/docs/docs/_next/static/_slug_.js`. Those URLs 404, so the page never
hydrates, `next.router` never initializes, and any client-side
navigation (router.push to /404, /_error, etc.) silently does nothing.
Vite+ bakes the configured `base` into emitted chunk fileNames on disk
(`dist/client/docs/_next/static/...`) so the Cloudflare ASSETS binding
can serve them directly. Vite then prepends `base` a second time when it
writes `ssr-manifest.json`, producing `docs/docs/_next/static/...`
entries. `augmentSsrManifestFromBundle` preserved those doubled entries
verbatim (it only stripped a leading slash), while its own bundle
backfill produced the correct single-prefix path. The merged manifest
held both, and `resolveClientModuleUrl` returned the doubled one.
The on-disk chunk fileName carries the basePath exactly once, so the
public URL must too. Collapse an exact `<base>/<base>/` prefix when
normalizing the Vite-sourced manifest entries so they match the
single-base paths the backfill produces. A correctly single-prefixed
entry is left untouched.
Regression covered with a focused unit test on the manifest builder and
verified end to end against the Next.js basepath/error-pages deploy
suite (previously 2 failing client-navigation cases, now all green).
* fix(css): preserve distinct media filenames for CSS url() assets
Match Next.js `asset/resource` behaviour for CSS `url()` dependencies:
emit them as files under `/_next/static/media/` instead of inlining, and
keep byte-identical sources (e.g. `dark.svg` / `dark2.svg`) as separate
output files instead of letting Vite/Rolldown's content-based asset
dedupe collapse them onto one filename.
There is no config switch to disable that dedupe (vitejs/vite#8632). The
native escape hatch is `this.emitFile({ fileName })`, which is never
deduped. A build-only, client-scoped pair of plugins:
- mark: during the client CSS transform, tag each relative asset
`url()` with a private `?vinext_css_url_asset=<source-basename>`
query — the only durable carrier of per-reference provenance, since
once Rolldown dedupes, the bundle can't tell which reference came
from which source.
- restore: at `generateBundle`, read the marker back, emit a sibling
file under the wanted source name via the `fileName` escape hatch,
rewrite the reference, and strip the marker.
Each reference resolves from its own marker, so split CSS chunks stay
correct without a shared cursor or bundle-iteration-order dependence.
This is a slimmer reimplementation of the approach explored in #1703:
same mark/restore mechanism and public surface, but the restore side
drops the AssetIndexes machinery, filename-reservation loop, and
multi-fallback lookups in favour of a single basename map + emitted-set,
since the sibling-filename construction (source-stem + content-hash +
ext) makes those guarded cases unreachable.
Tests port the Next.js scss/url-global e2e into the committed
`pages-basic` and `app-basic` fixtures (built in isolation), covering
the single-stylesheet two-identical-urls case and cross-chunk provenance.
Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
* fix(css): merge duplicate vite import; drop ReDoS-flagged regex; clarify comments
- tests/css-url-assets.test.ts: merge the two `vite` imports into one
(import/no-duplicates lint error that failed CI Check).
- client-build-config.ts: replace `/\/+$/` trailing-slash strip with a
linear loop so CodeQL stops flagging a (false-positive) ReDoS.
- index.ts: reword the css-url mark transform's `map: null` comment — the
marker shifts columns transiently but is stripped before final output.
- css-url-assets.ts: note the basename-uniqueness assumption in assetsByBase.
Addresses review feedback on #1725.
Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
* test(css): use dedicated minimal fixtures for url() asset tests
The url() asset tests previously added routes to the shared app-basic /
pages-basic fixtures and built those whole apps in isolation. That made
the tests CPU-heavy and, run concurrently under CI, contended with
app-router.test.ts's static-export test enough to push it past its 30s
timeout (it passes comfortably locally and on main; the extra parallel
load was the trigger). The added app-basic route also slightly inflated
every app-basic-wide test (static export renders all routes).
Move the fixtures into dedicated minimal committed apps
(tests/fixtures/css-url-assets-{app,pages}) that build in ~1-2s. Same
observable contract (byte-identical dark.svg/dark2.svg referenced from
one stylesheet, plus the split-chunk provenance case), now isolated from
the heavyweight shared fixtures and fast enough not to contend.
Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
* docs(test): update css-url-assets header to reference the dedicated fixtures
The file header still described the pre-refactor layout (pages-basic /
app-basic paths that no longer exist). Point it at the dedicated
css-url-assets-{pages,app} fixtures. Comment-only.
Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
---------
Co-authored-by: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
When a page declared `getServerSideProps` (or `getStaticProps` /
`getStaticPaths`) as a local binding and re-exported it via
`export { getServerSideProps }`, the client-bundle transform overwrote
the `export { ... }` statement with `export const getServerSideProps =
undefined;`. The original local `const getServerSideProps = ...` was
left in place, producing two declarations of the same identifier in
module scope. Rolldown/OXC rejects this with `Identifier
'getServerSideProps' has already been declared`, breaking the entire
production build (and cascading to 48 test failures in Next.js's
`test/e2e/getserversideprops` suite).
Drop the export specifier without emitting a stub, matching Next.js's
`next-ssg-transform` behaviour. The now-unused local declaration becomes
dead code and is tree-shaken later.
Closes#1354
The next/image client shim imports ipaddr.js for private-IP validation.
It's already in ssr.resolve.external, but the SSR dep optimizer would
still pre-bundle it on first request, producing
(ssr) ✨ new dependencies optimized: ipaddr.js
and the accompanying full reload. Add ipaddr.js to optimizeDeps.exclude
for the App Router and Pages Router SSR environments so the optimizer
skips it; runtime resolution still works via resolve.external on Node
and via the worker bundle on Cloudflare/Nitro. The client environment
intentionally keeps pre-bundling ipaddr.js since next/image is a 'use
client' component and needs the CJS→ESM conversion in the browser.
* fix(config): exclude React from ssr optimizeDeps when ssr.external: true
When users set `ssr.external: true` in vite.config.ts (the only practical
way to keep CJS-quirky packages like pg, protobufjs, keyv,
@launchdarkly/js-server-sdk-common, @opentelemetry/otlp-transformer
working in dev), `'use client'` components calling React.useContext from
external packages (nuqs, next-themes, launchdarkly-react, etc.) crashed
in dev with `Cannot read properties of null (reading 'useContext')`.
Root cause: @vitejs/plugin-rsc populates
environments.ssr.optimizeDeps.include with React entries via
crawlFrameworkPkgs. With external: true, the SSR env loads React via
Node's resolver, but Vite still pre-bundles a duplicate React copy into
deps_ssr/ because optimizeDeps.include overrides external: true.
react-dom-server.edge then sets the dispatcher on its bundled React
while externalized callers (vinext's runtime) see a different React,
leaving React.H null and crashing every hook call from a 'use client'
module SSR'd through it.
Fix: when userSsrExternal === true, add React entries to
environments.ssr.optimizeDeps.exclude so plugin-rsc's auto-includes
cannot pre-bundle a duplicate copy. The renderer and the runtime then
share one Node-loaded React.
The RSC env is unaffected (it still pre-bundles React with the
react-server condition), and the default mode (`noExternal: true`) is
unaffected since external: true gating the strip.
Closes#1103
* fix(config): also skip top-level ssr.noExternal: true when ssr.external: true
The previous fix only stripped React entries from
environments.ssr.optimizeDeps.exclude, but Vite uses top-level `ssr.*`
as defaults for `environments.ssr.*`. With vinext setting top-level
`ssr.noExternal: true` unconditionally (when not Cloudflare/Nitro),
that `true` leaked into `environments.ssr.resolve.noExternal` and
@vitejs/plugin-rsc's later expansion to an array (or the user's own
configResolved mirror) brought React back into the bundled list,
recreating the duplicate-React crash.
Two changes:
1. When `config.ssr.external === true`, vinext only sets the top-level
`ssr: { external: true }` block — no `noExternal: true` to leak into
the per-env config.
2. Defensive configResolved hook that strips React entries from
`environments.ssr.resolve.noExternal` if a later plugin (or
plugin-rsc itself) added them back. The strip is gated on
`external === true` so it only applies in the all-external mode.
Verified against a real-world App Router app (~80 deps including nuqs,
next-themes, launchdarkly-react, blocknote, opentelemetry, pg,
protobufjs, keyv, etc.): with ssr.external: true and these fixes
applied, 'use client' routes now render correctly in dev — the
`Cannot read properties of null (reading 'useContext')` /
`Invalid hook call` crashes are gone.
Refs #1103
* fix(rsc): exclude client shims from dep optimization
App Router dev config currently excludes the vinext package but not its client shim subpaths. @vitejs/plugin-rsc tracks package client references by the original bare import source, so optimized vinext/shims/* modules can lose the matching export metadata and crash client-package proxy loading.
Add a shared optimizeDeps exclude set for vinext's known RSC client shims and merge it into the top-level, RSC, SSR, and client optimizer configs while preserving existing user and server-external excludes.
* Update rsc-client-shim-excludes.ts
Co-authored-by: James Anderson <james@eli.cx>
* Update rsc-client-shim-excludes.ts
Co-authored-by: James Anderson <james@eli.cx>
* Update build-optimization.test.ts
Co-authored-by: James Anderson <james@eli.cx>
---------
Co-authored-by: James Anderson <james@eli.cx>
* fix(build): eliminate Vite 8 treeshake.preset warning
Add version-gated getClientTreeshakeConfigForVite() function that returns
Rollup-compatible config (with preset) for Vite 7 and Rolldown-compatible
config (without preset) for Vite 8+.
The treeshake.preset option is Rollup-specific and causes warnings in Vite 8
which uses Rolldown. The moduleSideEffects: 'no-external' option is valid in
both bundlers and is preserved in all configurations.
Changes:
- Add getClientTreeshakeConfigForVite(viteMajorVersion) function
- Update 4 usage sites to use version-gated function
- Mark clientTreeshakeConfig as @deprecated for backward compatibility
- Export getClientTreeshakeConfigForVite for testing
- Add comprehensive tests for Vite 7/8/9+ compatibility
- Update existing tests to expect Vite 8 format
Fixes#540
* refactor(build): address PR review feedback for treeshake config
Remove dead workaround plugin from playground
- Deleted strip-rolldown-incompatible-rollup-options plugin from
examples/app-router-playground/vite.config.ts
- This workaround is no longer needed since getClientTreeshakeConfigForVite()
never emits treeshake.preset for Vite 8+
Add documentation comment about Rolldown defaults
- Enhanced JSDoc for getClientTreeshakeConfigForVite() to explain behavior gap
- Documents how Rolldown defaults compare to Rollup's recommended preset
- Clarifies that moduleSideEffects: no-external is the key optimization
Fix test import style
- Changed from dynamic await import() to static imports
- Matches existing test patterns in build-optimization.test.ts
- Removes async from test cases that don't need it
Addresses review comments from PR #746
* docs(build): clarify Rolldown treeshake divergence in comments
Address PR #746 review feedback from @ask-bonk:
- JSDoc: Explicitly call out unknownGlobalSideEffects as a known acceptable
divergence (Rolldown defaults to true vs Rollup recommended preset's false).
Makes it clear this is intentional, not an oversight.
- Inline comment: Note that Rolldown's built-in defaults already cover what
Rollup's 'recommended' preset provides (annotations, correctContext,
tryCatchDeoptimization). Provides immediate context at the code site.
Both changes are documentation-only; no functional impact.
* chore: address PR review feedback on treeshake config
- Update comment to reference getClientTreeshakeConfigForVite instead of deprecated clientTreeshakeConfig
- Remove redundant not.toHaveProperty('preset') assertions from tests (toEqual already does exact matching)
Addresses review feedback from @ask-bonk[bot] on PR #745
* fix: merge top-level optimizeDeps with per-environment config
When vinext's config() hook builds per-environment optimizeDeps, it now
merges any exclude/include entries from config.optimizeDeps (populated by
earlier plugins like @lingui/vite-plugin) instead of overwriting them.
This ensures packages excluded by other plugins remain excluded across
all three environments (rsc, ssr, client).
Fixes#538https://claude.ai/code/session_01T7kpjyEcsZTUG6wHscx3nb
* fix: merge top-level optimizeDeps with per-environment config
When vinext's config() hook builds per-environment optimizeDeps, it now
merges any exclude/include entries from config.optimizeDeps (populated by
earlier plugins like @lingui/vite-plugin) instead of overwriting them.
This ensures packages excluded by other plugins remain excluded across
all three environments (rsc, ssr, client).
Add test verifying top-level optimizeDeps entries are preserved in all
per-environment configs after vinext's config hook runs.
Fixes#538https://claude.ai/code/session_01T7kpjyEcsZTUG6wHscx3nb
* docs: add PR description for optimizeDeps merge fix
https://claude.ai/code/session_01T7kpjyEcsZTUG6wHscx3nb
* chore: remove pr-description.md
https://claude.ai/code/session_01T7kpjyEcsZTUG6wHscx3nb
* fix: merge optimizeDeps for Pages Router + improve test coverage
Address PR review feedback:
- Move incomingExclude/incomingInclude capture above the hasAppDir
branch so Pages Router builds also merge entries from earlier plugins
(e.g. @lingui/vite-plugin) instead of overwriting them.
- Add @vercel/og assertion to confirm vinext's own entries survive merging.
- Add Set-based deduplication assertions to all environments.
- Test overlapping entries (incoming "vinext" + vinext's own "vinext")
to validate the Set dedup logic.
https://claude.ai/code/session_01T7kpjyEcsZTUG6wHscx3nb
* fix: preserve optimizeDeps.include for Pages Router + fix formatting
Address upstream PR review feedback:
- Spread incomingInclude into top-level optimizeDeps so Pages Router
builds preserve include entries from upstream plugins (e.g. @lingui/vite-plugin)
- Run pnpm run fmt to fix formatting in test file
https://claude.ai/code/session_01T7kpjyEcsZTUG6wHscx3nb
---------
Co-authored-by: Claude <noreply@anthropic.com>
* chore: migrate to vite plus
* Disable typeAware and typeCheck
* Update CI
* Fix CI
* Fix test
* Clean
* Run test with vp
* Try revert
* react: false In test
* Fix test
* Revert "Try revert"
This reverts commit 009da10473.
* Update
* Update
* Try revert ci changes
* revert
* Run vp migrate
* Disable typeAware and typeCheck for now
* Better resolve for test
* Use vp dev instead of vite
* Update expect
* Fix NormalizeManifestModuleId
* Try increase timeout
* Update to use vp
* Try new check
* Bring back npx vp
* Migrate CI
* Make next-intl resolvable
* Update
* Update
* Update
* 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: stub node:async_hooks in client builds via virtual module
Several shims (headers, cache, navigation-state, etc.) import
AsyncLocalStorage from node:async_hooks. Since resolve.alias applies
globally, these shims resolve in every environment including client.
Vite externalizes node:async_hooks to __vite-browser-external in browser
builds — an empty stub with no named exports — causing Rollup errors.
Add a vinext:async-hooks-stub plugin that intercepts node:async_hooks
in the client environment and provides a no-op AsyncLocalStorage via a
virtual module. This is semantically correct: shims already guard with
`_als.getStore() ?? fallback` patterns, so undefined store returns
produce correct client-side behavior.
Unlike the external file approach in #293, this uses a virtual module
inline in the plugin — no additional files, no build artifacts.
Closes#293
* fix: address async-hooks-stub code review feedback
- Convert load hook to object form with handler() for consistency
- Match bare `async_hooks` specifier in addition to `node:async_hooks`
- Add intentionally-minimal comment explaining stub scope
- Add regression tests for resolveId and load hook behavior
* refactor: extract async-hooks-stub plugin to plugins/ and test against real implementation
Move the vinext:async-hooks-stub plugin object from an inline definition in
index.ts to packages/vinext/src/plugins/async-hooks-stub.ts, exporting it as
asyncHooksStubPlugin. Import and use it directly in index.ts.
Update the regression test to import the real plugin via _asyncHooksStubPlugin
and invoke its resolveId/load handlers with a mock context, eliminating the
duplicated logic that the test previously used as a workaround for the plugin
being unexported and inline.
* fix: add filter to load hook, evaluate stub at runtime in test
- Add filter to load hook object form for consistency with resolveId
and to opt into Vite/Rolldown hook optimization path
- Replace overlapping string-assertion test with one that evaluates
the generated source via new Function, testing actual runtime
behavior (getStore returns undefined, run/exit pass through return values)
* fix: remove control-char regex from load filter to fix lint
* fix: suppress no-control-regex for load filter via eslint-disable comment
* refactor: derive load filter regex from ASYNC_HOOKS_STUB_ID constant
* Update build-optimization.test.ts
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
* Update index.ts
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
* fix: widen run() type signature to accept rest args and typed callback
---------
Co-authored-by: James <james@eli.cx>
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
* fix: strip getServerSideProps/getStaticProps from client bundles
Pages Router page modules export getServerSideProps, getStaticProps,
and getStaticPaths for server-side use. These functions often import
server-only modules (database drivers, fs, etc.) that would break or
bloat the client bundle.
Added a new Vite transform plugin (vinext:strip-server-exports) that
replaces these exports with no-op stubs when the module is being
processed for the client environment. The plugin only applies to
files under the pages/ directory and skips API routes, _app,
_document, and _error files.
The transform handles multiple export patterns:
- export (async) function declarations
- export const arrow functions
- export const simple references
- TypeScript type annotations in parameters
Extracted the core logic into a standalone stripServerExports()
function for testability. Added 11 unit tests covering all patterns
including edge cases with nested braces, string literals, TypeScript
types, and multiple server exports in one file.
* Rewrite stripServerExports to use AST instead of regex
Replace the regex-based approach with parseAst (Rollup/acorn) and
MagicString. The AST approach correctly handles all export patterns:
- Function declarations (export function / export async function)
- Variable declarations with any initializer (arrow functions,
function expressions, async named function expressions, simple refs)
- Re-export syntax (export { getServerSideProps })
- Mixed re-exports (export { getServerSideProps, config })
The old regex approach had bugs with function expressions, arrow
functions with TS return type annotations, re-exports, and regex
literals in function bodies. Since this transform runs after Vite's
JSX/TS compilation, parseAst always receives plain JavaScript.
Updated tests to use post-compiled JS (no JSX/TS) matching what the
transform actually receives. Added 6 new test cases for the patterns
that broke the regex approach.
* fix: set NODE_ENV
* address review feedback: readability, test coverage, and nextConfig.env guard
- Replace nested ternary with if/else for NODE_ENV resolution
- Add comment explaining why beforeEach/afterEach is file-scoped
- Add test cases: serve (development), build without mode, user override
- Skip NODE_ENV key in nextConfig.env loop to prevent silent override
- Extract setupTmpProject helper to reduce test boilerplate
---------
Co-authored-by: Steve Faulkner <sfaulkner@cloudflare.com>
* fix: exclude vinext from optimizeDeps to prevent virtual module resolution errors
When vinext is installed from npm (not symlinked), esbuild's dependency
optimization scans vinext/dist/ and hits virtual:vinext-* imports that
only exist at Vite plugin resolution time. This causes build failures
in all three environments (client, rsc, ssr).
Adding optimizeDeps.exclude: ['vinext'] at the top level and in each
environment prevents esbuild from scanning vinext's dist files entirely.
Fixes#1
* test: verify optimizeDeps.exclude contains vinext for all environments