From dea1e55ec73b8b0891b0f3a557cfae1885e9e615 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 09:46:17 +0100 Subject: [PATCH 1/4] fix(optimize-imports): optimize @heroui/react barrel imports by default Unused leaves of the @heroui/react barrel reach react-aria code that calls createContext() at module scope or imports client-only, which failed Server Components importing from the barrel in dev and in the RSC build (#4007). --- packages/vinext/src/plugins/optimize-imports.ts | 6 +++++- tests/optimize-imports.test.ts | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/plugins/optimize-imports.ts b/packages/vinext/src/plugins/optimize-imports.ts index b5e87fb657..f4e1865ee6 100644 --- a/packages/vinext/src/plugins/optimize-imports.ts +++ b/packages/vinext/src/plugins/optimize-imports.ts @@ -75,7 +75,7 @@ type PluginCtx = { environment?: { name?: string } }; /** * Packages whose barrel imports are automatically optimized. * Matches Next.js's built-in optimizePackageImports defaults plus vinext-specific - * compatibility entries for radix-ui and @chakra-ui/react. + * compatibility entries for radix-ui, @chakra-ui/react, and @heroui/react. * @see https://github.com/vercel/next.js/blob/9c31bbdaa/packages/next/src/server/config.ts#L1301 */ export const DEFAULT_OPTIMIZE_PACKAGES: string[] = [ @@ -156,6 +156,10 @@ export const DEFAULT_OPTIMIZE_PACKAGES: string[] = [ "react-icons/vsc", "react-icons/wi", "radix-ui", + // Unused leaves of the barrel reach react-aria code that calls createContext() + // at module scope or imports "client-only", which fails a Server Component's + // import in dev and the RSC build (https://github.com/cloudflare/vinext/issues/4007). + "@heroui/react", ]; export function createOptimizedImportSourceMatcher( diff --git a/tests/optimize-imports.test.ts b/tests/optimize-imports.test.ts index 545164498c..ad94bace3a 100644 --- a/tests/optimize-imports.test.ts +++ b/tests/optimize-imports.test.ts @@ -422,6 +422,7 @@ export { Button } from "./button";`; expect(DEFAULT_OPTIMIZE_PACKAGES).toContain("lucide-react"); expect(DEFAULT_OPTIMIZE_PACKAGES).toContain("radix-ui"); expect(DEFAULT_OPTIMIZE_PACKAGES).toContain("antd"); + expect(DEFAULT_OPTIMIZE_PACKAGES).toContain("@heroui/react"); }); it("resolves nested subdirectory wildcard re-exports to the correct absolute path", async () => { From 06edc50e2921a4e9ab87b3c6e9423a13ab7f97c0 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 10:06:06 +0100 Subject: [PATCH 2/4] feat(optimize-imports): follow react-aria-components subpath imports in RSC heroui's Toast reaches react-aria-components/Toast, whose other exports call createContext() at module scope and import client-only. Rewrite RSC subpath imports of react-aria-components to the modules that define the imported names, resolving through Vite from the importer and the entry. Re-exports are not followed through use client/use server/use cache modules, and use client modules are skipped in RSC. --- .../vinext/src/plugins/optimize-imports.ts | 149 +++++++++++-- tests/optimize-imports.test.ts | 208 ++++++++++++++++++ 2 files changed, 344 insertions(+), 13 deletions(-) diff --git a/packages/vinext/src/plugins/optimize-imports.ts b/packages/vinext/src/plugins/optimize-imports.ts index f4e1865ee6..18db32a177 100644 --- a/packages/vinext/src/plugins/optimize-imports.ts +++ b/packages/vinext/src/plugins/optimize-imports.ts @@ -11,7 +11,7 @@ * React.createContext() in RSC environments where createContext doesn't exist. */ -import type { ESTree, Plugin } from "vite"; +import type { ESTree, Plugin, Rollup } from "vite"; import { createRequire } from "node:module"; import fs from "node:fs/promises"; import path, { toSlash } from "pathslash"; @@ -162,10 +162,54 @@ export const DEFAULT_OPTIMIZE_PACKAGES: string[] = [ "@heroui/react", ]; +/** + * Packages whose subpath imports (`pkg/Sub`) are also optimized, in the RSC + * environment only. Their subpath entries are themselves barrels: heroui's + * Toast imports `UNSTABLE_ToastQueue` from "react-aria-components/Toast", whose + * other exports call createContext() at module scope and import "client-only" + * (https://github.com/cloudflare/vinext/issues/4007). SSR and the client keep + * the package's own module graph, so context modules are never instantiated + * twice there. + */ +export const SUBPATH_OPTIMIZE_PACKAGES: string[] = ["react-aria-components"]; + +function isOptimizedSubpath(source: string, subpathPackages: Iterable): boolean { + for (const pkg of subpathPackages) { + if (source.length > pkg.length + 1 && source.startsWith(`${pkg}/`)) return true; + } + return false; +} + +/** + * Return the directive that marks a module as an RSC boundary, if any. + * Re-exports must not be followed through such a module: importing its leaves + * directly would bypass the client reference, server reference, or cache wrapper. + */ +function findBoundaryDirective(ast: ESTree.Program): string | null { + for (const statement of ast.body) { + if (statement.type !== "ExpressionStatement" || typeof statement.directive !== "string") { + return null; + } + const directive = statement.directive; + if ( + directive === "use client" || + directive === "use server" || + directive.startsWith("use cache") + ) { + return directive; + } + } + return null; +} + export function createOptimizedImportSourceMatcher( packages: Iterable, + subpathPackages: Iterable = [], ): (code: string) => boolean { - const pattern = [...packages].map(escapeRegExp).join("|"); + const pattern = [ + ...[...packages].map(escapeRegExp), + ...[...subpathPackages].map((pkg) => String.raw`${escapeRegExp(pkg)}/[^"'\s]+`), + ].join("|"); if (!pattern) return () => false; const importFromPattern = new RegExp( @@ -352,6 +396,7 @@ async function buildExportMapFromFile( } const exportMap: BarrelExportMap = new Map(); + const isBoundary = findBoundaryDirective(ast) !== null; // Track import bindings: local name → { source, isNamespace, originalName } const importBindings = new Map< @@ -567,6 +612,14 @@ async function buildExportMapFromFile( } } + if (isBoundary) { + // Every name a boundary module exports must be imported from the module + // itself, including names reached through `export *` into or out of it. + for (const name of exportMap.keys()) { + exportMap.set(name, { source: filePath, isNamespace: false, originalName: name }); + } + } + cache.set(filePath, exportMap); return exportMap; } @@ -623,6 +676,20 @@ export async function buildBarrelExportMap( return exportMap; } +/** + * Resolve a specifier with Vite's resolver, returning the id only when it is a + * file in the module graph (not external or virtual). + */ +async function resolveFileId( + ctx: Pick, + source: string, + importer: string, +): Promise { + const resolved = await ctx.resolve(source, importer, { skipSelf: true }); + if (!resolved || resolved.external || !path.isAbsolute(resolved.id)) return null; + return resolved.id; +} + /** * Creates the vinext:optimize-imports Vite plugin. * @@ -643,6 +710,7 @@ export function createOptimizeImportsPlugin( const entryPathCache = new Map(); let optimizedPackages: Set = new Set(); let hasOptimizedImportSource: (code: string) => boolean = () => false; + let hasOptimizedRscImportSource: (code: string) => boolean = () => false; // Tracks barrel entries whose sub-package origins have already been registered, // so repeated imports of the same barrel (across many files) don't redundantly // iterate the full export map. Keys are `${envKey}:${barrelEntry}` so that RSC @@ -668,6 +736,10 @@ export function createOptimizeImportsPlugin( ...(nextConfig?.optimizePackageImports ?? []), ]); hasOptimizedImportSource = createOptimizedImportSourceMatcher(optimizedPackages); + hasOptimizedRscImportSource = createOptimizedImportSourceMatcher( + optimizedPackages, + SUBPATH_OPTIMIZE_PACKAGES, + ); // Clear all caches across rebuilds so stale data doesn't linger. // exportMapCache and subpkgOrigin hold barrel AST analysis and sub-package // origin mappings which may change if a dependency is updated mid-dev. @@ -701,12 +773,14 @@ export function createOptimizeImportsPlugin( transform: { filter: { id: { - include: /\.(tsx?|jsx?|mjs)$/, + // Dev serves dependencies that are not pre-bundled (e.g. heroui, which + // plugin-rsc excludes for server environments) with a `?v=` query. + include: /\.(tsx?|jsx?|mjs)(?:\?v=\w+)?$/, exclude: VIRTUAL_MODULE_ID_RE, }, code: /\bimport\b[\s\S]*\bfrom\b/, }, - async handler(code) { + async handler(code, id) { // Only apply on server environments (RSC/SSR). The client uses Vite's // dep optimizer which handles barrel imports correctly. const env = (this as PluginCtx).environment; @@ -719,7 +793,9 @@ export function createOptimizeImportsPlugin( // declarations can be rewritten, so skip files that merely mention an // optimized package in ordinary strings, comments, or side-effect imports. const packages = optimizedPackages; - if (!hasOptimizedImportSource(code)) return null; + if (!(preferReactServer ? hasOptimizedRscImportSource : hasOptimizedImportSource)(code)) { + return null; + } let ast: ESTree.Program; try { @@ -727,6 +803,11 @@ export function createOptimizeImportsPlugin( } catch { return null; } + // plugin-rsc replaces a "use client" module with client references in + // RSC, so its imports are never evaluated there. + if (preferReactServer && findBoundaryDirective(ast) === "use client") { + return null; + } const s = new MagicString(code); let hasChanges = false; @@ -736,18 +817,32 @@ export function createOptimizeImportsPlugin( if (node.type !== "ImportDeclaration") continue; const importSource = typeof node.source?.value === "string" ? node.source.value : null; - if (!importSource || !packages.has(importSource)) continue; + if (!importSource) continue; + const isSubpath = + !packages.has(importSource) && + preferReactServer && + isOptimizedSubpath(importSource, SUBPATH_OPTIMIZE_PACKAGES); + if (!isSubpath && !packages.has(importSource)) continue; // Build or retrieve the barrel export map for this package. // Cache the resolved entry path to avoid repeated FS work. // The cache key includes the environment prefix because RSC resolves the // "react-server" export condition while SSR uses the standard conditions — // the same package can have different barrel entry paths in each environment. - const cacheKey = `${preferReactServer ? "rsc" : "ssr"}:${importSource}`; - let barrelEntry: string | null | undefined = entryPathCache.get(cacheKey); - if (barrelEntry === undefined) { - barrelEntry = await resolvePackageEntry(importSource, root, preferReactServer); - entryPathCache.set(cacheKey, barrelEntry ?? null); + let barrelEntry: string | null | undefined; + if (isSubpath) { + // Subpath entries go through Vite's resolver from the importer: it + // applies the environment's conditions and `exports` patterns, and + // finds the copy of the package nested next to the importer. + barrelEntry = await resolveFileId(this, importSource, id); + if (barrelEntry) barrelEntry = barrelEntry.replace(/\?.*$/, ""); + } else { + const cacheKey = `${preferReactServer ? "rsc" : "ssr"}:${importSource}`; + barrelEntry = entryPathCache.get(cacheKey); + if (barrelEntry === undefined) { + barrelEntry = await resolvePackageEntry(importSource, root, preferReactServer); + entryPathCache.set(cacheKey, barrelEntry ?? null); + } } const exportMap = await buildBarrelExportMap( barrelEntry, @@ -771,7 +866,8 @@ export function createOptimizeImportsPlugin( // run the inner loop so it populates its own subpkgOrigin map. const envKey = preferReactServer ? "rsc" : "ssr"; const registeredKey = `${envKey}:${barrelEntry}`; - if (!registeredBarrels.has(registeredKey)) { + // Subpath imports emit already-resolved ids for package sources below. + if (!isSubpath && !registeredBarrels.has(registeredKey)) { registeredBarrels.add(registeredKey); let envOriginMap = barrelCaches.subpkgOrigin.get(envKey); if (!envOriginMap) { @@ -850,6 +946,33 @@ export function createOptimizeImportsPlugin( continue; } + // A subpath entry that defines (or is the boundary for) every imported + // name gains nothing from a rewrite. + if ( + isSubpath && + specifiers.every(({ imported }) => exportMap.get(imported)?.source === barrelEntry) + ) { + continue; + } + + // Package sources of a subpath entry resolve from the entry's own + // location, which may hold a different version than the importer sees. + const resolvedPackageSources = new Map(); + if (isSubpath) { + for (const { imported } of specifiers) { + const source = exportMap.get(imported)?.source; + if (!source || path.isAbsolute(source) || resolvedPackageSources.has(source)) + continue; + const resolvedId = await resolveFileId(this, source, barrelEntry); + if (!resolvedId) { + allResolved = false; + break; + } + resolvedPackageSources.set(source, resolvedId); + } + if (!allResolved) continue; + } + // Group specifiers by their resolved source module const bySource = new Map< string, @@ -869,7 +992,7 @@ export function createOptimizeImportsPlugin( // Vite's resolver handles extension resolution on these paths, so this // works in practice, but a future improvement would be to resolve the // extension here (or verify via the barrel AST that the file exists). - const resolvedSource = entry.source; + const resolvedSource = resolvedPackageSources.get(entry.source) ?? entry.source; // Key on both resolved source and isNamespace: a named import and a // namespace import from the same sub-module must produce separate // import statements. diff --git a/tests/optimize-imports.test.ts b/tests/optimize-imports.test.ts index ad94bace3a..5ac33b04ba 100644 --- a/tests/optimize-imports.test.ts +++ b/tests/optimize-imports.test.ts @@ -13,6 +13,7 @@ import { createOptimizedImportSourceMatcher, createOptimizeImportsPlugin, DEFAULT_OPTIMIZE_PACKAGES, + SUBPATH_OPTIMIZE_PACKAGES, } from "../packages/vinext/src/plugins/optimize-imports.js"; import type { Plugin } from "vite-plus"; import vinext from "../packages/vinext/src/index.js"; @@ -91,6 +92,24 @@ describe("vinext:optimize-imports plugin", () => { expect(hasOptimizedImport(`function f(){}import { Sun } from "lucide-react";`)).toBe(true); }); + it("matches subpath imports only for subpath packages", () => { + const hasOptimizedImport = createOptimizedImportSourceMatcher( + ["radix-ui"], + ["react-aria-components"], + ); + + expect(hasOptimizedImport(`import { X } from "react-aria-components/Toast";`)).toBe(true); + expect(hasOptimizedImport(`import { X } from "react-aria-components";`)).toBe(false); + expect(hasOptimizedImport(`import { X } from "react-aria-components-x/Toast";`)).toBe(false); + expect(hasOptimizedImport(`import { X } from "radix-ui/Slot";`)).toBe(false); + expect(hasOptimizedImport(`import { X } from "radix-ui";`)).toBe(true); + expect( + createOptimizedImportSourceMatcher(["radix-ui"])( + `import { X } from "react-aria-components/Toast";`, + ), + ).toBe(false); + }); + it("rejects unquoted, partial or non-import mentions of optimized packages", () => { const hasOptimizedImport = createOptimizedImportSourceMatcher(["lucide-react", "radix-ui"]); @@ -372,6 +391,30 @@ export { Mo as Listbox };`; expect(map!.get("parse")).toBeDefined(); }); + it("does not follow re-exports through a directive boundary", async () => { + const entryPath = "/fake/wildcard-boundary/index.js"; + const files: Record = { + [entryPath]: `export * from "./client.js";\nexport { Button } from "./button.js";`, + "/fake/wildcard-boundary/client.js": `"use client";\nexport { Dialog } from "./dialog.js";\nexport * as Icons from "./icons.js";`, + }; + + const map = await buildBarrelExportMap(entryPath, (fp) => Promise.resolve(files[fp] ?? null)); + + // Names exported by the "use client" module are imported from it, so the + // client reference boundary is kept. + expect(map!.get("Dialog")).toEqual({ + source: "/fake/wildcard-boundary/client.js", + isNamespace: false, + originalName: "Dialog", + }); + expect(map!.get("Icons")).toEqual({ + source: "/fake/wildcard-boundary/client.js", + isNamespace: false, + originalName: "Icons", + }); + expect(map!.get("Button")!.source).toBe("/fake/wildcard-boundary/button.js"); + }); + it("does not overwrite existing exports when wildcard sub-module has same name", async () => { // If the barrel already defines `format` explicitly, the wildcard should not overwrite it const entryPath = "/fake/wildcard-nooverwrite/index.js"; @@ -1050,3 +1093,168 @@ describe("vinext:optimize-imports transform", () => { expect(ssrResultKnown!.code).toContain("btn"); }); }); + +describe("vinext:optimize-imports subpath imports", () => { + const tmpDirs: string[] = []; + + afterEach(() => { + for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + /** + * Lay out react-aria-components the way heroui reaches it: the "./Toast" + * subpath entry re-exports a private module (module-scope createContext), + * imports "client-only", and re-exports ToastQueue from a react-stately copy + * nested under react-aria-components. + */ + async function setup(toastEntry?: string) { + const tmpDir = toSlash( + fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "vinext-optimize-subpath-"))), + ); + tmpDirs.push(tmpDir); + fs.writeFileSync( + path.join(tmpDir, "package.json"), + JSON.stringify({ name: "test-app", type: "module" }), + ); + const racDir = `${tmpDir}/node_modules/react-aria-components`; + fs.mkdirSync(`${racDir}/dist/exports`, { recursive: true }); + fs.mkdirSync(`${racDir}/dist/private`, { recursive: true }); + const toastEntryPath = `${racDir}/dist/exports/Toast.mjs`; + fs.writeFileSync( + toastEntryPath, + toastEntry ?? + [ + `import {UNSTABLE_Toast as $t, UNSTABLE_ToastRegion as $r} from "../private/Toast.mjs";`, + `import "client-only";`, + `import {ToastQueue as $q$re_export$UNSTABLE_ToastQueue} from "react-stately/useToastState";`, + `export {$t as UNSTABLE_Toast, $r as UNSTABLE_ToastRegion, $q$re_export$UNSTABLE_ToastQueue as UNSTABLE_ToastQueue};`, + ].join("\n"), + ); + const nestedStately = `${racDir}/node_modules/react-stately/dist/exports/useToastState.mjs?v=abc`; + const resolveCalls: Array<[string, string]> = []; + const resolve = async (source: string, importer: string) => { + resolveCalls.push([source, importer]); + if (source === "react-aria-components/Toast") return { id: `${toastEntryPath}?v=abc` }; + if (source === "react-stately/useToastState" && importer === toastEntryPath) { + return { id: nestedStately }; + } + if (source === "react-aria-components/External") + return { id: "rac-external", external: true }; + return null; + }; + + const plugin = createOptimizeImportsPlugin( + () => undefined, + () => tmpDir, + ) as Plugin; + await unwrapHook((plugin as any).buildStart)!.call(plugin); + const transform = unwrapHook(plugin.transform)!; + const call = async (envName: string, code: string, id: string) => + await (transform as any).call( + { ...plugin, environment: { name: envName }, resolve }, + code, + id, + ); + return { call, racDir, nestedStately, resolveCalls }; + } + + const heroToastQueue = `${"/app"}/node_modules/@heroui/react/dist/components/toast/toast-queue.js?v=abc`; + + it("optimizes react-aria-components subpaths by default", () => { + expect(SUBPATH_OPTIMIZE_PACKAGES).toContain("react-aria-components"); + }); + + it("accepts dev ids with a ?v= query", () => { + const plugin = createOptimizeImportsPlugin( + () => undefined, + () => "/fake/root", + ) as Plugin; + const include = (plugin.transform as any).filter.id.include as RegExp; + expect(include.test("/app/node_modules/pkg/dist/index.js?v=1a2b3c")).toBe(true); + expect(include.test("/app/page.tsx")).toBe(true); + expect(include.test("/app/style.css?v=1a2b3c")).toBe(false); + }); + + it("rewrites a subpath import in RSC to the nested package the entry resolves", async () => { + const { call, nestedStately, resolveCalls, racDir } = await setup(); + const result = await call( + "rsc", + `"use strict";\nimport { UNSTABLE_ToastQueue } from 'react-aria-components/Toast';\nnew UNSTABLE_ToastQueue();`, + heroToastQueue, + ); + + expect(result!.code).toContain( + `import { ToastQueue as UNSTABLE_ToastQueue } from ${JSON.stringify(nestedStately)};`, + ); + expect(result!.code).not.toContain("react-aria-components/Toast"); + // The subpath resolves from the importer; its package sources from the entry. + expect(resolveCalls).toEqual([ + ["react-aria-components/Toast", heroToastQueue], + ["react-stately/useToastState", `${racDir}/dist/exports/Toast.mjs`], + ]); + }); + + it("rewrites names defined in a private module to its file", async () => { + const { call, racDir } = await setup(); + const result = await call( + "rsc", + `import { UNSTABLE_Toast } from "react-aria-components/Toast";`, + "/app/page.tsx", + ); + expect(result!.code).toContain( + `import { UNSTABLE_Toast } from ${JSON.stringify(`${racDir}/dist/private/Toast.mjs`)};`, + ); + }); + + it("leaves subpath imports alone outside RSC", async () => { + const { call } = await setup(); + const code = `import { UNSTABLE_ToastQueue } from "react-aria-components/Toast";`; + expect(await call("ssr", code, heroToastQueue)).toBeNull(); + expect(await call("client", code, heroToastQueue)).toBeNull(); + }); + + it('skips "use client" importers in RSC', async () => { + const { call } = await setup(); + const result = await call( + "rsc", + `"use client";\nimport { UNSTABLE_ToastQueue } from "react-aria-components/Toast";`, + "/app/node_modules/@heroui/react/dist/components/toast/toast.js?v=abc", + ); + expect(result).toBeNull(); + }); + + it("does not follow re-exports out of a boundary subpath entry", async () => { + const { call } = await setup( + `"use client";\nexport { UNSTABLE_Toast } from "../private/Toast.mjs";`, + ); + const result = await call( + "rsc", + `import { UNSTABLE_Toast } from "react-aria-components/Toast";`, + "/app/page.tsx", + ); + expect(result).toBeNull(); + }); + + it("leaves the import unchanged when a source cannot be resolved", async () => { + const { call } = await setup(); + // External subpath entry. + expect( + await call("rsc", `import { X } from "react-aria-components/External";`, "/app/page.tsx"), + ).toBeNull(); + // Unknown name in the entry. + expect( + await call("rsc", `import { Missing } from "react-aria-components/Toast";`, "/app/page.tsx"), + ).toBeNull(); + // Package source that does not resolve from the entry. + const { call: call2 } = await setup( + `export { ToastQueue as UNSTABLE_ToastQueue } from "react-stately/missing";`, + ); + expect( + await call2( + "rsc", + `import { UNSTABLE_ToastQueue } from "react-aria-components/Toast";`, + "/app/page.tsx", + ), + ).toBeNull(); + }); +}); From 2999215885559c205f858d16c01621d39b8d18a6 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 10:42:02 +0100 Subject: [PATCH 3/4] fix(optimize-imports): only follow the server-safe Toast queue subpath export RAC subpath entries import client-only on purpose, so following any subpath export dropped that guard for client components imported from a Server Component. Limit the RSC subpath rewrite to UNSTABLE_ToastQueue from react-aria-components/Toast, which heroui's toast queue needs. --- .../vinext/src/plugins/optimize-imports.ts | 59 +++++++------- tests/optimize-imports.test.ts | 78 +++++++------------ 2 files changed, 58 insertions(+), 79 deletions(-) diff --git a/packages/vinext/src/plugins/optimize-imports.ts b/packages/vinext/src/plugins/optimize-imports.ts index 18db32a177..ea5a192baa 100644 --- a/packages/vinext/src/plugins/optimize-imports.ts +++ b/packages/vinext/src/plugins/optimize-imports.ts @@ -163,22 +163,18 @@ export const DEFAULT_OPTIMIZE_PACKAGES: string[] = [ ]; /** - * Packages whose subpath imports (`pkg/Sub`) are also optimized, in the RSC - * environment only. Their subpath entries are themselves barrels: heroui's - * Toast imports `UNSTABLE_ToastQueue` from "react-aria-components/Toast", whose - * other exports call createContext() at module scope and import "client-only" - * (https://github.com/cloudflare/vinext/issues/4007). SSR and the client keep - * the package's own module graph, so context modules are never instantiated - * twice there. + * Server-safe exports of package subpath entries that are optimized in the RSC + * environment only. heroui's Toast imports `UNSTABLE_ToastQueue` (a plain class + * re-exported from react-stately) from "react-aria-components/Toast", whose + * other exports call createContext() at module scope + * (https://github.com/cloudflare/vinext/issues/4007). The entry also imports + * "client-only" on purpose, so importing any other export from a Server + * Component keeps failing. SSR and the client keep the package's own module + * graph, so context modules are never instantiated twice there. */ -export const SUBPATH_OPTIMIZE_PACKAGES: string[] = ["react-aria-components"]; - -function isOptimizedSubpath(source: string, subpathPackages: Iterable): boolean { - for (const pkg of subpathPackages) { - if (source.length > pkg.length + 1 && source.startsWith(`${pkg}/`)) return true; - } - return false; -} +export const OPTIMIZED_SUBPATH_EXPORTS: ReadonlyMap> = new Map([ + ["react-aria-components/Toast", new Set(["UNSTABLE_ToastQueue"])], +]); /** * Return the directive that marks a module as an RSC boundary, if any. @@ -204,12 +200,8 @@ function findBoundaryDirective(ast: ESTree.Program): string | null { export function createOptimizedImportSourceMatcher( packages: Iterable, - subpathPackages: Iterable = [], ): (code: string) => boolean { - const pattern = [ - ...[...packages].map(escapeRegExp), - ...[...subpathPackages].map((pkg) => String.raw`${escapeRegExp(pkg)}/[^"'\s]+`), - ].join("|"); + const pattern = [...packages].map(escapeRegExp).join("|"); if (!pattern) return () => false; const importFromPattern = new RegExp( @@ -736,10 +728,10 @@ export function createOptimizeImportsPlugin( ...(nextConfig?.optimizePackageImports ?? []), ]); hasOptimizedImportSource = createOptimizedImportSourceMatcher(optimizedPackages); - hasOptimizedRscImportSource = createOptimizedImportSourceMatcher( - optimizedPackages, - SUBPATH_OPTIMIZE_PACKAGES, - ); + hasOptimizedRscImportSource = createOptimizedImportSourceMatcher([ + ...optimizedPackages, + ...OPTIMIZED_SUBPATH_EXPORTS.keys(), + ]); // Clear all caches across rebuilds so stale data doesn't linger. // exportMapCache and subpkgOrigin hold barrel AST analysis and sub-package // origin mappings which may change if a dependency is updated mid-dev. @@ -818,11 +810,22 @@ export function createOptimizeImportsPlugin( const importSource = typeof node.source?.value === "string" ? node.source.value : null; if (!importSource) continue; - const isSubpath = - !packages.has(importSource) && - preferReactServer && - isOptimizedSubpath(importSource, SUBPATH_OPTIMIZE_PACKAGES); + const subpathExports = + preferReactServer && !packages.has(importSource) + ? OPTIMIZED_SUBPATH_EXPORTS.get(importSource) + : undefined; + const isSubpath = subpathExports !== undefined; if (!isSubpath && !packages.has(importSource)) continue; + if ( + subpathExports && + !node.specifiers.every((spec) => { + if (spec.type !== "ImportSpecifier") return false; + const imported = getAstName(spec.imported); + return imported !== null && subpathExports.has(imported); + }) + ) { + continue; + } // Build or retrieve the barrel export map for this package. // Cache the resolved entry path to avoid repeated FS work. diff --git a/tests/optimize-imports.test.ts b/tests/optimize-imports.test.ts index 5ac33b04ba..97976da9f1 100644 --- a/tests/optimize-imports.test.ts +++ b/tests/optimize-imports.test.ts @@ -13,7 +13,7 @@ import { createOptimizedImportSourceMatcher, createOptimizeImportsPlugin, DEFAULT_OPTIMIZE_PACKAGES, - SUBPATH_OPTIMIZE_PACKAGES, + OPTIMIZED_SUBPATH_EXPORTS, } from "../packages/vinext/src/plugins/optimize-imports.js"; import type { Plugin } from "vite-plus"; import vinext from "../packages/vinext/src/index.js"; @@ -92,24 +92,6 @@ describe("vinext:optimize-imports plugin", () => { expect(hasOptimizedImport(`function f(){}import { Sun } from "lucide-react";`)).toBe(true); }); - it("matches subpath imports only for subpath packages", () => { - const hasOptimizedImport = createOptimizedImportSourceMatcher( - ["radix-ui"], - ["react-aria-components"], - ); - - expect(hasOptimizedImport(`import { X } from "react-aria-components/Toast";`)).toBe(true); - expect(hasOptimizedImport(`import { X } from "react-aria-components";`)).toBe(false); - expect(hasOptimizedImport(`import { X } from "react-aria-components-x/Toast";`)).toBe(false); - expect(hasOptimizedImport(`import { X } from "radix-ui/Slot";`)).toBe(false); - expect(hasOptimizedImport(`import { X } from "radix-ui";`)).toBe(true); - expect( - createOptimizedImportSourceMatcher(["radix-ui"])( - `import { X } from "react-aria-components/Toast";`, - ), - ).toBe(false); - }); - it("rejects unquoted, partial or non-import mentions of optimized packages", () => { const hasOptimizedImport = createOptimizedImportSourceMatcher(["lucide-react", "radix-ui"]); @@ -1107,7 +1089,7 @@ describe("vinext:optimize-imports subpath imports", () => { * imports "client-only", and re-exports ToastQueue from a react-stately copy * nested under react-aria-components. */ - async function setup(toastEntry?: string) { + async function setup(toastEntry?: string, { externalEntry = false } = {}) { const tmpDir = toSlash( fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "vinext-optimize-subpath-"))), ); @@ -1134,12 +1116,14 @@ describe("vinext:optimize-imports subpath imports", () => { const resolveCalls: Array<[string, string]> = []; const resolve = async (source: string, importer: string) => { resolveCalls.push([source, importer]); - if (source === "react-aria-components/Toast") return { id: `${toastEntryPath}?v=abc` }; + if (source === "react-aria-components/Toast") { + return externalEntry + ? { id: "react-aria-components/Toast", external: true } + : { id: `${toastEntryPath}?v=abc` }; + } if (source === "react-stately/useToastState" && importer === toastEntryPath) { return { id: nestedStately }; } - if (source === "react-aria-components/External") - return { id: "rac-external", external: true }; return null; }; @@ -1160,8 +1144,10 @@ describe("vinext:optimize-imports subpath imports", () => { const heroToastQueue = `${"/app"}/node_modules/@heroui/react/dist/components/toast/toast-queue.js?v=abc`; - it("optimizes react-aria-components subpaths by default", () => { - expect(SUBPATH_OPTIMIZE_PACKAGES).toContain("react-aria-components"); + it("optimizes only the server-safe Toast queue export by default", () => { + expect([...OPTIMIZED_SUBPATH_EXPORTS]).toEqual([ + ["react-aria-components/Toast", new Set(["UNSTABLE_ToastQueue"])], + ]); }); it("accepts dev ids with a ?v= query", () => { @@ -1194,16 +1180,17 @@ describe("vinext:optimize-imports subpath imports", () => { ]); }); - it("rewrites names defined in a private module to its file", async () => { - const { call, racDir } = await setup(); - const result = await call( - "rsc", + it("keeps the client-only guard for other exports and subpaths", async () => { + const { call, resolveCalls } = await setup(); + for (const code of [ `import { UNSTABLE_Toast } from "react-aria-components/Toast";`, - "/app/page.tsx", - ); - expect(result!.code).toContain( - `import { UNSTABLE_Toast } from ${JSON.stringify(`${racDir}/dist/private/Toast.mjs`)};`, - ); + `import { UNSTABLE_ToastQueue, UNSTABLE_Toast } from "react-aria-components/Toast";`, + `import * as Toast from "react-aria-components/Toast";`, + `import { Collection } from "react-aria-components/Collection";`, + ]) { + expect(await call("rsc", code, "/app/page.tsx")).toBeNull(); + } + expect(resolveCalls).toEqual([]); }); it("leaves subpath imports alone outside RSC", async () => { @@ -1225,36 +1212,25 @@ describe("vinext:optimize-imports subpath imports", () => { it("does not follow re-exports out of a boundary subpath entry", async () => { const { call } = await setup( - `"use client";\nexport { UNSTABLE_Toast } from "../private/Toast.mjs";`, + `"use client";\nexport { ToastQueue as UNSTABLE_ToastQueue } from "react-stately/useToastState";`, ); const result = await call( "rsc", - `import { UNSTABLE_Toast } from "react-aria-components/Toast";`, + `import { UNSTABLE_ToastQueue } from "react-aria-components/Toast";`, "/app/page.tsx", ); expect(result).toBeNull(); }); it("leaves the import unchanged when a source cannot be resolved", async () => { - const { call } = await setup(); + const code = `import { UNSTABLE_ToastQueue } from "react-aria-components/Toast";`; // External subpath entry. - expect( - await call("rsc", `import { X } from "react-aria-components/External";`, "/app/page.tsx"), - ).toBeNull(); - // Unknown name in the entry. - expect( - await call("rsc", `import { Missing } from "react-aria-components/Toast";`, "/app/page.tsx"), - ).toBeNull(); + const { call } = await setup(undefined, { externalEntry: true }); + expect(await call("rsc", code, "/app/page.tsx")).toBeNull(); // Package source that does not resolve from the entry. const { call: call2 } = await setup( `export { ToastQueue as UNSTABLE_ToastQueue } from "react-stately/missing";`, ); - expect( - await call2( - "rsc", - `import { UNSTABLE_ToastQueue } from "react-aria-components/Toast";`, - "/app/page.tsx", - ), - ).toBeNull(); + expect(await call2("rsc", code, "/app/page.tsx")).toBeNull(); }); }); From cff6c2c1912d26c0e7b160fee7d9271e880ee115 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 10:47:02 +0100 Subject: [PATCH 4/4] fix(optimize-imports): follow all react-aria-components subpath exports again This reverts the export allowlist from 2999215. Next.js's default Turbopack build follows re-exports out of react-aria-components' side-effect-free subpath entries and drops their client-only import, so server-safe exports work and client components still fail when their own modules run. The allowlist was stricter than Next.js. --- .../vinext/src/plugins/optimize-imports.ts | 61 +++++++-------- tests/optimize-imports.test.ts | 78 ++++++++++++------- 2 files changed, 81 insertions(+), 58 deletions(-) diff --git a/packages/vinext/src/plugins/optimize-imports.ts b/packages/vinext/src/plugins/optimize-imports.ts index ea5a192baa..4c7b1757f2 100644 --- a/packages/vinext/src/plugins/optimize-imports.ts +++ b/packages/vinext/src/plugins/optimize-imports.ts @@ -163,18 +163,24 @@ export const DEFAULT_OPTIMIZE_PACKAGES: string[] = [ ]; /** - * Server-safe exports of package subpath entries that are optimized in the RSC - * environment only. heroui's Toast imports `UNSTABLE_ToastQueue` (a plain class - * re-exported from react-stately) from "react-aria-components/Toast", whose - * other exports call createContext() at module scope - * (https://github.com/cloudflare/vinext/issues/4007). The entry also imports - * "client-only" on purpose, so importing any other export from a Server - * Component keeps failing. SSR and the client keep the package's own module - * graph, so context modules are never instantiated twice there. + * Packages whose subpath imports (`pkg/Sub`) are also optimized, in the RSC + * environment only. Their subpath entries are themselves barrels: heroui's + * Toast imports `UNSTABLE_ToastQueue` from "react-aria-components/Toast", whose + * other exports call createContext() at module scope and import "client-only" + * (https://github.com/cloudflare/vinext/issues/4007). Like Next.js's default + * Turbopack build, this drops the side-effect-free entry's "client-only" + * import: server-safe exports work, and client components still fail when + * their own modules are evaluated. SSR and the client keep the package's own + * module graph, so context modules are never instantiated twice there. */ -export const OPTIMIZED_SUBPATH_EXPORTS: ReadonlyMap> = new Map([ - ["react-aria-components/Toast", new Set(["UNSTABLE_ToastQueue"])], -]); +export const SUBPATH_OPTIMIZE_PACKAGES: string[] = ["react-aria-components"]; + +function isOptimizedSubpath(source: string, subpathPackages: Iterable): boolean { + for (const pkg of subpathPackages) { + if (source.length > pkg.length + 1 && source.startsWith(`${pkg}/`)) return true; + } + return false; +} /** * Return the directive that marks a module as an RSC boundary, if any. @@ -200,8 +206,12 @@ function findBoundaryDirective(ast: ESTree.Program): string | null { export function createOptimizedImportSourceMatcher( packages: Iterable, + subpathPackages: Iterable = [], ): (code: string) => boolean { - const pattern = [...packages].map(escapeRegExp).join("|"); + const pattern = [ + ...[...packages].map(escapeRegExp), + ...[...subpathPackages].map((pkg) => String.raw`${escapeRegExp(pkg)}/[^"'\s]+`), + ].join("|"); if (!pattern) return () => false; const importFromPattern = new RegExp( @@ -728,10 +738,10 @@ export function createOptimizeImportsPlugin( ...(nextConfig?.optimizePackageImports ?? []), ]); hasOptimizedImportSource = createOptimizedImportSourceMatcher(optimizedPackages); - hasOptimizedRscImportSource = createOptimizedImportSourceMatcher([ - ...optimizedPackages, - ...OPTIMIZED_SUBPATH_EXPORTS.keys(), - ]); + hasOptimizedRscImportSource = createOptimizedImportSourceMatcher( + optimizedPackages, + SUBPATH_OPTIMIZE_PACKAGES, + ); // Clear all caches across rebuilds so stale data doesn't linger. // exportMapCache and subpkgOrigin hold barrel AST analysis and sub-package // origin mappings which may change if a dependency is updated mid-dev. @@ -810,22 +820,11 @@ export function createOptimizeImportsPlugin( const importSource = typeof node.source?.value === "string" ? node.source.value : null; if (!importSource) continue; - const subpathExports = - preferReactServer && !packages.has(importSource) - ? OPTIMIZED_SUBPATH_EXPORTS.get(importSource) - : undefined; - const isSubpath = subpathExports !== undefined; + const isSubpath = + !packages.has(importSource) && + preferReactServer && + isOptimizedSubpath(importSource, SUBPATH_OPTIMIZE_PACKAGES); if (!isSubpath && !packages.has(importSource)) continue; - if ( - subpathExports && - !node.specifiers.every((spec) => { - if (spec.type !== "ImportSpecifier") return false; - const imported = getAstName(spec.imported); - return imported !== null && subpathExports.has(imported); - }) - ) { - continue; - } // Build or retrieve the barrel export map for this package. // Cache the resolved entry path to avoid repeated FS work. diff --git a/tests/optimize-imports.test.ts b/tests/optimize-imports.test.ts index 97976da9f1..5ac33b04ba 100644 --- a/tests/optimize-imports.test.ts +++ b/tests/optimize-imports.test.ts @@ -13,7 +13,7 @@ import { createOptimizedImportSourceMatcher, createOptimizeImportsPlugin, DEFAULT_OPTIMIZE_PACKAGES, - OPTIMIZED_SUBPATH_EXPORTS, + SUBPATH_OPTIMIZE_PACKAGES, } from "../packages/vinext/src/plugins/optimize-imports.js"; import type { Plugin } from "vite-plus"; import vinext from "../packages/vinext/src/index.js"; @@ -92,6 +92,24 @@ describe("vinext:optimize-imports plugin", () => { expect(hasOptimizedImport(`function f(){}import { Sun } from "lucide-react";`)).toBe(true); }); + it("matches subpath imports only for subpath packages", () => { + const hasOptimizedImport = createOptimizedImportSourceMatcher( + ["radix-ui"], + ["react-aria-components"], + ); + + expect(hasOptimizedImport(`import { X } from "react-aria-components/Toast";`)).toBe(true); + expect(hasOptimizedImport(`import { X } from "react-aria-components";`)).toBe(false); + expect(hasOptimizedImport(`import { X } from "react-aria-components-x/Toast";`)).toBe(false); + expect(hasOptimizedImport(`import { X } from "radix-ui/Slot";`)).toBe(false); + expect(hasOptimizedImport(`import { X } from "radix-ui";`)).toBe(true); + expect( + createOptimizedImportSourceMatcher(["radix-ui"])( + `import { X } from "react-aria-components/Toast";`, + ), + ).toBe(false); + }); + it("rejects unquoted, partial or non-import mentions of optimized packages", () => { const hasOptimizedImport = createOptimizedImportSourceMatcher(["lucide-react", "radix-ui"]); @@ -1089,7 +1107,7 @@ describe("vinext:optimize-imports subpath imports", () => { * imports "client-only", and re-exports ToastQueue from a react-stately copy * nested under react-aria-components. */ - async function setup(toastEntry?: string, { externalEntry = false } = {}) { + async function setup(toastEntry?: string) { const tmpDir = toSlash( fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "vinext-optimize-subpath-"))), ); @@ -1116,14 +1134,12 @@ describe("vinext:optimize-imports subpath imports", () => { const resolveCalls: Array<[string, string]> = []; const resolve = async (source: string, importer: string) => { resolveCalls.push([source, importer]); - if (source === "react-aria-components/Toast") { - return externalEntry - ? { id: "react-aria-components/Toast", external: true } - : { id: `${toastEntryPath}?v=abc` }; - } + if (source === "react-aria-components/Toast") return { id: `${toastEntryPath}?v=abc` }; if (source === "react-stately/useToastState" && importer === toastEntryPath) { return { id: nestedStately }; } + if (source === "react-aria-components/External") + return { id: "rac-external", external: true }; return null; }; @@ -1144,10 +1160,8 @@ describe("vinext:optimize-imports subpath imports", () => { const heroToastQueue = `${"/app"}/node_modules/@heroui/react/dist/components/toast/toast-queue.js?v=abc`; - it("optimizes only the server-safe Toast queue export by default", () => { - expect([...OPTIMIZED_SUBPATH_EXPORTS]).toEqual([ - ["react-aria-components/Toast", new Set(["UNSTABLE_ToastQueue"])], - ]); + it("optimizes react-aria-components subpaths by default", () => { + expect(SUBPATH_OPTIMIZE_PACKAGES).toContain("react-aria-components"); }); it("accepts dev ids with a ?v= query", () => { @@ -1180,17 +1194,16 @@ describe("vinext:optimize-imports subpath imports", () => { ]); }); - it("keeps the client-only guard for other exports and subpaths", async () => { - const { call, resolveCalls } = await setup(); - for (const code of [ + it("rewrites names defined in a private module to its file", async () => { + const { call, racDir } = await setup(); + const result = await call( + "rsc", `import { UNSTABLE_Toast } from "react-aria-components/Toast";`, - `import { UNSTABLE_ToastQueue, UNSTABLE_Toast } from "react-aria-components/Toast";`, - `import * as Toast from "react-aria-components/Toast";`, - `import { Collection } from "react-aria-components/Collection";`, - ]) { - expect(await call("rsc", code, "/app/page.tsx")).toBeNull(); - } - expect(resolveCalls).toEqual([]); + "/app/page.tsx", + ); + expect(result!.code).toContain( + `import { UNSTABLE_Toast } from ${JSON.stringify(`${racDir}/dist/private/Toast.mjs`)};`, + ); }); it("leaves subpath imports alone outside RSC", async () => { @@ -1212,25 +1225,36 @@ describe("vinext:optimize-imports subpath imports", () => { it("does not follow re-exports out of a boundary subpath entry", async () => { const { call } = await setup( - `"use client";\nexport { ToastQueue as UNSTABLE_ToastQueue } from "react-stately/useToastState";`, + `"use client";\nexport { UNSTABLE_Toast } from "../private/Toast.mjs";`, ); const result = await call( "rsc", - `import { UNSTABLE_ToastQueue } from "react-aria-components/Toast";`, + `import { UNSTABLE_Toast } from "react-aria-components/Toast";`, "/app/page.tsx", ); expect(result).toBeNull(); }); it("leaves the import unchanged when a source cannot be resolved", async () => { - const code = `import { UNSTABLE_ToastQueue } from "react-aria-components/Toast";`; + const { call } = await setup(); // External subpath entry. - const { call } = await setup(undefined, { externalEntry: true }); - expect(await call("rsc", code, "/app/page.tsx")).toBeNull(); + expect( + await call("rsc", `import { X } from "react-aria-components/External";`, "/app/page.tsx"), + ).toBeNull(); + // Unknown name in the entry. + expect( + await call("rsc", `import { Missing } from "react-aria-components/Toast";`, "/app/page.tsx"), + ).toBeNull(); // Package source that does not resolve from the entry. const { call: call2 } = await setup( `export { ToastQueue as UNSTABLE_ToastQueue } from "react-stately/missing";`, ); - expect(await call2("rsc", code, "/app/page.tsx")).toBeNull(); + expect( + await call2( + "rsc", + `import { UNSTABLE_ToastQueue } from "react-aria-components/Toast";`, + "/app/page.tsx", + ), + ).toBeNull(); }); });