feat: optimize barrel imports for RSC-incompatible packages#138
feat: optimize barrel imports for RSC-incompatible packages#138gentritbiba wants to merge 18 commits intocloudflare:mainfrom
Conversation
d24b6fb to
45a4fd4
Compare
|
/bonk Can you review this PR? |
Barrel imports like `import { Slot } from "radix-ui"` cause RSC crashes
because Vite eagerly evaluates all re-exported sub-packages, some of which
call React.createContext() — unavailable in the react-server condition.
This plugin rewrites barrel imports into direct sub-module imports on the
server, matching Next.js's optimizePackageImports behavior:
- Parses barrel entry files to build export maps (namespace, named,
default re-exports, and import-then-export patterns)
- resolveId hook handles pnpm strict hoisting via Vite's own resolver
- Only runs on server environments; client uses Vite's dep optimizer
- Lazy-initialized Set<string> for O(1) package lookups
- Respects experimental.optimizePackageImports from next.config
- Includes Next.js default package list plus radix-ui
Closes cloudflare#100
Closes cloudflare#137
45a4fd4 to
8584d7a
Compare
|
I don't think bonk is reviewing this one |
zebp
left a comment
There was a problem hiding this comment.
Need to look closer at the next implementation before I finalize the review, but so far this looks good
commit: |
|
Updated the |
Add the full effect ecosystem (17 packages) and all react-icons/* sub-packages (32 packages) to match Next.js's built-in defaults.
a8bed35 to
670812f
Compare
|
/bigbonk review this please |
There was a problem hiding this comment.
Review: Barrel Import Optimization
Solid implementation overall. The approach of parsing barrel entry files and rewriting imports to direct sub-module paths is the right strategy for RSC/SSR. The pnpm strict mode handling via resolveId + barrelSubpkgOrigin is thoughtful, and the decision to skip export * (leaving those imports untouched) is the correct safe default.
A few issues worth addressing, ranging from a potential perf regression in dev to stale cache risks.
Summary of feedback
- Module-level caches never cleared —
barrelExportMapCacheandbarrelSubpkgOriginpersist across HMR andpnpm installin dev "effect"in the quick-check causes unnecessary parsing of nearly every React file in RSC/SSRresolveExportsValuemissing"node"condition — could resolve the wrong barrel entry file for server environments- Minor: inconsistent
this.environmentaccess patterns, cosmetic nit on IIFE indentation
|
Review posted successfully. Here's a summary of what I flagged on PR #138: Key findings:
|
- Resolve merge conflict in imports (loadEnv + Environment type) - Fix "effect" false-positive in quick-check: use quoted string matching to avoid triggering AST parsing for useEffect/sideEffect/etc. - Add "node" export condition to resolveExportsValue for correct server-side barrel entry resolution - Scope barrel caches (exportMapCache, subpkgOrigin) to plugin instance instead of module level, preventing stale cache across dev restarts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Addressed Bonk's review feedback:
Also merged upstream/main to resolve conflicts. All 2158 tests pass, typecheck/build/lint clean. |
|
Need anything else from me to merge this? |
|
@james-elicx @southpolesteve Sorry to bother you, but could you have the bot review this again? It would help improve compatibility. |
|
Yeah of course no worries. Let me resolve the conflicts and then I'll re-request it. Sorry for the delay |
|
/bigbonk review |
There was a problem hiding this comment.
Review: Barrel Import Optimization (second pass)
This is a well-structured feature. The approach of parsing barrel entry files, building an export map, and rewriting imports to direct sub-module paths is sound. The previous review feedback (cache scoping, "effect" false-positive, "node" export condition) has been addressed. I have a few remaining issues — one correctness concern and a few improvements.
Summary
resolvePackageEntryresolves its own entry independently ofresolveExportsValue— for packages with strictexportsthat don't expose./package.json, the fallbackreq.resolve(packageName)may return a CJS entry that differs from the ESM barrel entry found viaexports["."]. This creates a mismatch where the AST is parsed from the wrong file.resolveExportsValuedoesn't handle the"types"condition — it will incorrectly resolve.d.tsfiles as barrel entries for packages that list"types"before"import"(increasingly common).- Synchronous FS in
transform—resolvePackageEntrydoes multiplereadFileSync/existsSynccalls per import per file. This is fine for the initial pass but will block the event loop on large codebases with many barrel imports. Consider caching the resolved entry path (not just the export map). - Test gap — no end-to-end transform test that verifies actual code rewriting (MagicString output). The "transform" describe block only tests
buildBarrelExportMapin isolation.
packages/vinext/src/index.ts
Outdated
| if (typeof value === "string") return value; | ||
| if (typeof value === "object" && value !== null) { | ||
| // Prefer ESM conditions in order | ||
| for (const key of ["node", "import", "module", "default"]) { |
There was a problem hiding this comment.
"types" condition will resolve .d.ts files as barrel entries. Many modern packages list "types" before "import" in their export conditions:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"node": { "import": "./dist/node.mjs" },
"import": "./dist/index.mjs"
}
}
}Since resolveExportsValue iterates object keys in insertion order (guaranteed by the JSON parse), and the current condition list ["node", "import", "module", "default"] doesn't include "types", this specific case is actually OK — it would skip "types" and never match it.
However, a more subtle issue: some packages use only "types" + "default" at a nesting level:
{ "types": "./dist/index.d.ts", "default": "./dist/index.cjs" }Here we'd correctly skip "types" and pick "default". So this is fine — I'm retracting this concern after thinking it through. The current approach is correct because it only checks the 4 explicit conditions.
One actual nit though: the "module" condition is non-standard (it's a bundler convention, not a Node.js condition). Given that "node" is already checked first, "module" provides minimal additional value. Not blocking, just noting.
packages/vinext/src/index.ts
Outdated
| let pkgJson: PackageJson | null = null; | ||
|
|
||
| try { | ||
| const pkgJsonPath = req.resolve(path.join(packageName, "package.json")); |
There was a problem hiding this comment.
path.join(packageName, "package.json") breaks for scoped packages on Windows. path.join("@radix-ui/react-slot", "package.json") produces @radix-ui\react-slot\package.json on Windows, which require.resolve() won't find because it expects forward slashes in specifiers.
Use string concatenation instead:
| const pkgJsonPath = req.resolve(path.join(packageName, "package.json")); | |
| const pkgJsonPath = req.resolve(`${packageName}/package.json`); |
packages/vinext/src/index.ts
Outdated
| if (!packages.has(importSource)) continue; | ||
|
|
||
| // Build or retrieve the barrel export map for this package | ||
| const barrelEntry = resolvePackageEntry(importSource, root); |
There was a problem hiding this comment.
resolvePackageEntry is called on every import of every file, with no result caching. The barrel export map is cached (keyed by entry path), but the entry path resolution itself — which does require.resolve, readFileSync, existsSync, and potentially walks up 10 directories — is repeated every time.
For a project that imports from radix-ui in 50 components, this means 50 calls to resolvePackageEntry("radix-ui", root) all doing the same FS work, even though the result never changes within a build.
Consider caching the resolved entry path:
// Inside the IIFE, alongside barrelCaches:
const entryPathCache = new Map<string, string | null>();
// In the transform handler:
let barrelEntry = entryPathCache.get(importSource);
if (barrelEntry === undefined) {
barrelEntry = resolvePackageEntry(importSource, root);
entryPathCache.set(importSource, barrelEntry);
}This would reduce the per-file overhead to a single Map lookup after the first resolution.
packages/vinext/src/index.ts
Outdated
| } | ||
|
|
||
| // Replace the original import with the optimized one(s) | ||
| s.overwrite(node.start, node.end, replacements.join(";\n")); |
There was a problem hiding this comment.
Replacement omits trailing semicolons. ImportDeclaration.end in the ESTree/acorn AST includes the trailing semicolon (when present). The replacement strings don't append one:
// Original (node range includes the `;`):
import { Slot, Dialog } from "radix-ui";
// Replacement:
import * as Slot from "@radix-ui/react-slot";
import * as Dialog from "@radix-ui/react-dialog"
// ^ no semicolon on last statementThis works via ASI, but it means the rewritten code has inconsistent semicolon style (the join uses ";\n" between statements, but the final statement has none). Minor nit — you could append ";" to the joined result:
| s.overwrite(node.start, node.end, replacements.join(";\n")); | |
| s.overwrite(node.start, node.end, replacements.join(";\n") + ";"); |
packages/vinext/src/index.ts
Outdated
| exportMapCache: new Map<string, BarrelExportMap>(), | ||
| subpkgOrigin: new Map<string, string>(), | ||
| }; | ||
| let optimizedPackages: Set<string> | null = null; |
There was a problem hiding this comment.
optimizedPackages is lazily initialized but never invalidated. If nextConfig is reassigned (which happens in the config hook at line 1771), optimizedPackages will still hold the set from the first call. In practice this is likely fine because the transform hook runs after config, but the lazy init pattern creates a timing dependency.
Consider initializing it eagerly in buildStart or configResolved instead:
buildStart() {
optimizedPackages = new Set([
...DEFAULT_OPTIMIZE_PACKAGES,
...(nextConfig?.optimizePackageImports ?? []),
]);
},This also makes the behavior more explicit — no hidden first-call side effect.
tests/optimize-imports.test.ts
Outdated
| * Pre-populate the barrel export map cache by calling _buildBarrelExportMap | ||
| * with mock resolve/read functions. The cache is keyed by the resolved entry | ||
| * path. We use fixed paths here since we want the plugin's resolvePackageEntry | ||
| * to not find these packages (returning null) — instead the cache will have | ||
| * been pre-populated by the beforeEach and the plugin will find them there. | ||
| * | ||
| * Actually, the plugin calls buildBarrelExportMap with its own resolve/read | ||
| * functions. For the cache to work, the paths must match what the plugin would | ||
| * compute. Since we don't have real packages installed, the plugin's | ||
| * resolvePackageEntry will return null, and buildBarrelExportMap will also | ||
| * return null. | ||
| * | ||
| * To properly test, we directly test the transform output by pre-seeding | ||
| * the cache with paths that the plugin's resolvePackageEntry won't compute. | ||
| * We need a different approach: mock the resolve at the module level, or | ||
| * test the transform handler by calling buildBarrelExportMap first to seed | ||
| * the cache, then calling the plugin with the same package. | ||
| * | ||
| * The key insight: buildBarrelExportMap caches by ENTRY PATH. When the plugin | ||
| * calls buildBarrelExportMap("radix-ui", resolveEntry, readFile): | ||
| * 1. resolveEntry("radix-ui") → null (no real package) | ||
| * 2. Returns null because entry can't be resolved | ||
| * | ||
| * So pre-seeding won't help because the entry path won't match. We need | ||
| * to test the transform logic differently — by providing a real-ish package | ||
| * structure or by testing the helper functions independently. | ||
| * | ||
| * For plugin transform tests, we'll test that the transform handler correctly | ||
| * returns null when packages can't be resolved (already covered above), and | ||
| * test the rewriting logic through buildBarrelExportMap + MagicString directly. | ||
| */ |
There was a problem hiding this comment.
This comment block should be condensed. The design reasoning is useful but 30 lines of stream-of-consciousness in a test file is excessive. Consider 3-4 lines explaining the constraint and moving the detailed analysis to the PR description (where it already exists).
packages/vinext/src/index.ts
Outdated
| "@mui/icons-material", | ||
| "recharts", | ||
| "react-use", | ||
| "effect", |
There was a problem hiding this comment.
Confirmed: the previous review's concern about "effect" causing false positives was addressed. The quick-check now uses code.includes('"effect"') (quoted form). Good fix.
packages/vinext/src/index.ts
Outdated
| * Matches Next.js's built-in optimizePackageImports defaults plus radix-ui. | ||
| * @see https://github.com/vercel/next.js/blob/9c31bbdaa/packages/next/src/server/config.ts#L1301 | ||
| */ | ||
| const DEFAULT_OPTIMIZE_PACKAGES: string[] = [ |
There was a problem hiding this comment.
The default list now matches Next.js's config. One thing to consider: Next.js auto-detects barrel files at build time for unlisted packages (it checks if a package's entry file is a barrel with only re-exports). This PR only handles packages that are explicitly listed.
That's a reasonable scoping decision for a first pass. Worth noting as a follow-up — without auto-detection, users who import from unlisted barrel packages (e.g., a company's internal component library) would need to add them to optimizePackageImports manually.
|
Review posted successfully. Here's a summary of the feedback on PR #138: Key findings:
Also noted positively: the |
|
I'll address the bonk comments once I'm back from lunch. |
…ath fix, trailing semicolons, fixture-based transform tests
…ar barrelCaches on rebuild
- Key bySource map on `${source}::${isNamespace}` instead of just source
so that a namespace re-export and a named re-export from the same sub-module
produce two separate import statements rather than one corrupted one.
Caught by the new 'produces separate statements for namespace and named
imports from the same source' test.
- Clear barrelCaches.exportMapCache and barrelCaches.subpkgOrigin in buildStart
alongside entryPathCache so stale barrel analysis doesn't survive rebuilds.
- Add regression test for the mixed namespace+named grouping bug.
|
/bigbonk review |
There was a problem hiding this comment.
Review: Barrel Import Optimization
Solid architecture overall — the AST-based approach, pnpm strict mode handling via resolveId + subpkgOrigin, the IIFE scoping, and the decision to skip export * wildcards are all well-considered. The test suite is thorough for buildBarrelExportMap and now includes real end-to-end MagicString transform tests. Previous review feedback has been addressed.
However, there is a critical correctness bug that would break the majority of packages in DEFAULT_OPTIMIZE_PACKAGES.
Critical: Relative source paths resolve against the wrong directory
When a barrel entry file uses relative re-export paths (e.g. export { default as chunk } from './chunk.js' in lodash-es), the plugin writes that relative path verbatim into the user's file. Vite then resolves ./chunk.js relative to the importing file (app/page.tsx → /app/chunk.js), not relative to the barrel package (/node_modules/lodash-es/chunk.js).
This affects the majority of packages in the default list:
lucide-react:export { default as X } from './icons/X.js'lodash-es:export { default as chunk } from './chunk.js'antd:export { default as Button } from './button'rxjs:export { Observable } from './internal/Observable'@heroicons/react/*,@tabler/icons-react, etc.
Only radix-ui (the motivating use case) uses absolute package specifiers (@radix-ui/react-slot) and works correctly. date-fns happens to use export * (which the plugin correctly skips), so it's also unaffected.
The resolveId hook cannot save this because relative paths are intentionally excluded from subpkgOrigin (line 3896: !entry.source.startsWith(".")) — and even if they weren't, ./chunk.js would be ambiguous across packages.
The existing e2e transform tests pass because they verify string output but don't run Vite's resolver on the result (the test at line 323 asserts result.code.toContain('from "./button"') which is the exact broken output).
Fix: For relative sources, resolve them against the barrel entry's directory before emitting. For example:
const resolvedSource = source.startsWith('.')
? path.resolve(path.dirname(barrelEntry), source)
: source;Then use resolvedSource in the replacement import statement. Alternatively, register relative sources in subpkgOrigin using a compound key that includes the barrel entry path, so resolveId can resolve them from the correct context.
Other issues
See inline comments for additional feedback on resolveExportsValue, export { default as X } handling, and a minor import style issue.
packages/vinext/src/index.ts
Outdated
| if (isNamespace) { | ||
| // Each namespace import gets its own statement | ||
| for (const { local } of locals) { | ||
| replacements.push(`import * as ${local} from ${JSON.stringify(source)}`); |
There was a problem hiding this comment.
Critical: relative source paths resolve against the wrong directory.
When the barrel entry at /node_modules/lodash-es/lodash.js contains export { default as chunk } from './chunk.js', this line emits import * as chunk from "./chunk.js" into the user's file. Vite resolves ./chunk.js relative to the user's file, not the barrel.
For namespace imports from barrels with relative re-exports, source must be resolved to an absolute path (or a non-relative specifier that resolveId can intercept).
| replacements.push(`import * as ${local} from ${JSON.stringify(source)}`); | |
| const resolvedSource = source.startsWith('.') ? path.resolve(path.dirname(barrelEntry), source) : source; | |
| replacements.push(`import * as ${local} from ${JSON.stringify(resolvedSource)}`); |
And the same for the named import branch below (line 3970).
packages/vinext/src/index.ts
Outdated
| return local; | ||
| }); | ||
| replacements.push( | ||
| `import { ${importSpecs.join(", ")} } from ${JSON.stringify(source)}`, |
There was a problem hiding this comment.
Same relative-path issue as the namespace branch above. source here could be "./button" from the barrel, which would resolve against the user's file location.
| `import { ${importSpecs.join(", ")} } from ${JSON.stringify(source)}`, | |
| const resolvedSource = source.startsWith('.') ? path.resolve(path.dirname(barrelEntry), source) : source; | |
| replacements.push( | |
| `import { ${importSpecs.join(", ")} } from ${JSON.stringify(resolvedSource)}`, | |
| ); |
To avoid duplicating the resolution logic, you could resolve source once when building bySource groups (e.g., store the resolved source in the group object).
packages/vinext/src/index.ts
Outdated
| if (typeof value === "string") return value; | ||
| if (typeof value === "object" && value !== null) { | ||
| // Prefer ESM conditions in order | ||
| for (const key of ["node", "import", "module", "default"]) { |
There was a problem hiding this comment.
The "module" condition is a bundler convention, not a Node.js export condition. Since this runs in server environments (RSC/SSR), encountering a package that exposes a browser-optimized entry via "module" but not via "import" or "node" could resolve the wrong barrel file.
In practice, the risk is very low because packages with "module" almost always also have "import". But if you want to be precise, consider removing "module" or placing it last (after "default").
Not blocking — just noting.
packages/vinext/src/index.ts
Outdated
| } else { | ||
| // Group named imports from the same source | ||
| const importSpecs = locals.map(({ local, originalName }) => { | ||
| if (originalName && originalName !== local) { |
There was a problem hiding this comment.
Nit: when originalName is "default" (from export { default as Calendar } from "./calendar"), the replacement becomes import { default as Calendar } from "./calendar". This is valid ESM syntax, but more idiomatically you'd want import Calendar from "./calendar" for default imports.
Not blocking — import { default as X } works. But it's worth noting that some tooling or bundler passes might handle named vs default import syntax differently.
tests/optimize-imports.test.ts
Outdated
| const code = `import { Button, Input } from "date-fns";`; | ||
| const result = call(code, "/app/page.tsx"); | ||
| expect(result).not.toBeNull(); | ||
| expect(result!.code).toContain(`import { Button } from "./button"`); |
There was a problem hiding this comment.
This test asserts the broken output as correct. After fixing the relative path resolution bug, this assertion should change to verify that the source is resolved to an absolute path (or whatever the fix produces), not the raw relative "./button" from the barrel entry.
Consider also adding a test that verifies the rewritten imports would actually resolve correctly — e.g., by creating the target files in the tmp fixture and checking the paths exist.
packages/vinext/src/index.ts
Outdated
| // Use quoted forms to avoid false positives (e.g. "effect" in "useEffect"). | ||
| const packages = optimizedPackages; | ||
| let hasBarrelImport = false; | ||
| for (const pkg of packages) { |
There was a problem hiding this comment.
Minor performance note: this iterates the full optimizedPackages set (80+ entries) for every file. Since the check uses template literal concatenation (\"${pkg}"``), each iteration allocates a new string.
For the current set size this is negligible. But if you want to micro-optimize later, you could pre-build the quoted strings once in buildStart (e.g., quotedPackages: Array<[string, string]> with both '"pkg"' and "'pkg'" forms).
packages/vinext/src/index.ts
Outdated
| /** | ||
| * Minimal AST node shape from parseAst (Rollup/estree). | ||
| * Covers ImportDeclaration, ExportNamedDeclaration, ExportAllDeclaration, | ||
| * and other top-level statements. Includes Rollup's start/end positions. | ||
| */ | ||
| interface AstBodyNode { | ||
| type: string; | ||
| start: number; | ||
| end: number; | ||
| source?: { value: string | boolean | number | null }; | ||
| exported?: { name?: string; value?: string | boolean | number | null }; | ||
| specifiers?: Array<{ | ||
| type: string; | ||
| local: { name: string; value?: string | boolean | number | null }; | ||
| imported?: { name?: string; value?: string | boolean | number | null }; | ||
| exported?: { name?: string; value?: string | boolean | number | null }; | ||
| }>; | ||
| } | ||
|
|
||
| /** Extract the string name from an Identifier ({name}) or Literal ({value}) AST node. */ | ||
| function astName(node: { name?: string; value?: string | boolean | number | null }): string { | ||
| return node.name ?? String(node.value); | ||
| } | ||
|
|
||
| /** Nested conditional exports value (string path or nested conditions). */ | ||
| type ExportsValue = string | { [condition: string]: ExportsValue }; | ||
|
|
||
| /** Minimal package.json shape for entry point resolution. */ | ||
| interface PackageJson { | ||
| name?: string; | ||
| exports?: Record<string, ExportsValue>; | ||
| module?: string; | ||
| main?: string; | ||
| } | ||
|
|
||
| /** Plugin context with Vite's environment property (not in Rollup base types). */ | ||
| interface EnvironmentPluginContext { | ||
| environment?: Environment; | ||
| } | ||
|
|
||
| interface BarrelExportEntry { | ||
| source: string; | ||
| isNamespace: boolean; | ||
| originalName?: string; | ||
| } | ||
|
|
||
| type BarrelExportMap = Map<string, BarrelExportEntry>; | ||
|
|
||
| /** Caches used by the optimize-imports plugin, scoped to a plugin instance. */ | ||
| interface BarrelCaches { | ||
| /** Barrel export maps keyed by resolved entry file path. */ | ||
| exportMapCache: Map<string, BarrelExportMap>; | ||
| /** Maps sub-package specifiers to the barrel entry path they were derived from. */ | ||
| subpkgOrigin: Map<string, string>; | ||
| } | ||
|
|
||
| /** | ||
| * Packages whose barrel imports are automatically optimized. | ||
| * Matches Next.js's built-in optimizePackageImports defaults plus radix-ui. | ||
| * @see https://github.com/vercel/next.js/blob/9c31bbdaa/packages/next/src/server/config.ts#L1301 | ||
| */ | ||
| const DEFAULT_OPTIMIZE_PACKAGES: string[] = [ | ||
| "lucide-react", | ||
| "date-fns", | ||
| "lodash-es", | ||
| "ramda", | ||
| "antd", | ||
| "react-bootstrap", | ||
| "ahooks", | ||
| "@ant-design/icons", | ||
| "@headlessui/react", | ||
| "@headlessui-float/react", | ||
| "@heroicons/react/20/solid", | ||
| "@heroicons/react/24/solid", | ||
| "@heroicons/react/24/outline", | ||
| "@visx/visx", | ||
| "@tremor/react", | ||
| "rxjs", | ||
| "@mui/material", | ||
| "@mui/icons-material", | ||
| "recharts", | ||
| "react-use", | ||
| "effect", | ||
| "@effect/schema", | ||
| "@effect/platform", | ||
| "@effect/platform-node", | ||
| "@effect/platform-browser", | ||
| "@effect/platform-bun", | ||
| "@effect/sql", | ||
| "@effect/sql-mssql", | ||
| "@effect/sql-mysql2", | ||
| "@effect/sql-pg", | ||
| "@effect/sql-sqlite-node", | ||
| "@effect/sql-sqlite-bun", | ||
| "@effect/sql-sqlite-wasm", | ||
| "@effect/sql-sqlite-react-native", | ||
| "@effect/rpc", | ||
| "@effect/rpc-http", | ||
| "@effect/typeclass", | ||
| "@effect/experimental", | ||
| "@effect/opentelemetry", | ||
| "@material-ui/core", | ||
| "@material-ui/icons", | ||
| "@tabler/icons-react", | ||
| "mui-core", | ||
| "react-icons/ai", | ||
| "react-icons/bi", | ||
| "react-icons/bs", | ||
| "react-icons/cg", | ||
| "react-icons/ci", | ||
| "react-icons/di", | ||
| "react-icons/fa", | ||
| "react-icons/fa6", | ||
| "react-icons/fc", | ||
| "react-icons/fi", | ||
| "react-icons/gi", | ||
| "react-icons/go", | ||
| "react-icons/gr", | ||
| "react-icons/hi", | ||
| "react-icons/hi2", | ||
| "react-icons/im", | ||
| "react-icons/io", | ||
| "react-icons/io5", | ||
| "react-icons/lia", | ||
| "react-icons/lib", | ||
| "react-icons/lu", | ||
| "react-icons/md", | ||
| "react-icons/pi", | ||
| "react-icons/ri", | ||
| "react-icons/rx", | ||
| "react-icons/si", | ||
| "react-icons/sl", | ||
| "react-icons/tb", | ||
| "react-icons/tfi", | ||
| "react-icons/ti", | ||
| "react-icons/vsc", | ||
| "react-icons/wi", | ||
| "radix-ui", | ||
| ]; | ||
|
|
||
| /** | ||
| * Resolve a package.json exports value to a string entry path. | ||
| * Prefers node → import → module → default conditions, recursing into nested objects. | ||
| */ | ||
| function resolveExportsValue(value: ExportsValue): string | null { | ||
| if (typeof value === "string") return value; | ||
| if (typeof value === "object" && value !== null) { | ||
| // Prefer ESM conditions in order | ||
| for (const key of ["node", "import", "module", "default"]) { | ||
| const nested = value[key]; | ||
| if (nested !== undefined) { | ||
| const resolved = resolveExportsValue(nested); | ||
| if (resolved) return resolved; | ||
| } | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a package name to its ESM entry file path. | ||
| * Checks `exports["."]` → `module` → `main`, then falls back to require.resolve. | ||
| * | ||
| * Handles packages with strict `exports` fields that don't expose `./package.json` | ||
| * by first resolving the main entry, then walking up to find the package root. | ||
| */ | ||
| function resolvePackageEntry(packageName: string, projectRoot: string): string | null { | ||
| try { | ||
| const req = createRequire(path.join(projectRoot, "package.json")); | ||
|
|
||
| // Try resolving package.json directly (works for packages without strict exports) | ||
| let pkgDir: string | null = null; | ||
| let pkgJson: PackageJson | null = null; | ||
|
|
||
| try { | ||
| const pkgJsonPath = req.resolve(`${packageName}/package.json`); | ||
| pkgDir = path.dirname(pkgJsonPath); | ||
| pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8")) as PackageJson; | ||
| } catch { | ||
| // Package has strict exports — resolve main entry and walk up to find package.json | ||
| try { | ||
| const mainEntry = req.resolve(packageName); | ||
| let dir = path.dirname(mainEntry); | ||
| // Walk up until we find package.json with matching name | ||
| for (let i = 0; i < 10; i++) { | ||
| const candidate = path.join(dir, "package.json"); | ||
| if (fs.existsSync(candidate)) { | ||
| const parsed = JSON.parse(fs.readFileSync(candidate, "utf-8")) as PackageJson; | ||
| if (parsed.name === packageName) { | ||
| pkgDir = dir; | ||
| pkgJson = parsed; | ||
| break; | ||
| } | ||
| } | ||
| const parent = path.dirname(dir); | ||
| if (parent === dir) break; | ||
| dir = parent; | ||
| } | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| if (!pkgDir || !pkgJson) return null; | ||
|
|
||
| if (pkgJson.exports) { | ||
| // TODO: Some packages export their barrel from a non-root subpath (e.g. | ||
| // exports["./index"] or exports["./*"]). Only exports["."] is checked here, | ||
| // which covers the vast majority of packages in the default list. User-provided | ||
| // packages with non-standard export maps may need manual sub-module imports. | ||
| const dotExport = pkgJson.exports["."]; | ||
| if (dotExport) { | ||
| const entryPath = resolveExportsValue(dotExport); | ||
| if (entryPath) { | ||
| return path.resolve(pkgDir, entryPath); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const entryField = pkgJson.module ?? pkgJson.main; | ||
| if (typeof entryField === "string") { | ||
| return path.resolve(pkgDir, entryField); | ||
| } | ||
|
|
||
| return req.resolve(packageName); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Build a map of exported names → source sub-module for a barrel package. | ||
| * | ||
| * Parses the barrel entry file AST and extracts the export map. | ||
| * Handles: `export * as X from`, `export { A } from`, `import * as X; export { X }`. | ||
| * Does NOT recursively resolve `export * from` (wildcard) — those imports are left unchanged. | ||
| */ | ||
| function buildBarrelExportMap( | ||
| packageName: string, | ||
| resolveEntry: (pkg: string) => string | null, | ||
| readFile: (filepath: string) => string | null, | ||
| cache?: Map<string, BarrelExportMap>, | ||
| ): BarrelExportMap | null { | ||
| const entryPath = resolveEntry(packageName); | ||
| if (!entryPath) return null; | ||
|
|
||
| const cached = cache?.get(entryPath); | ||
| if (cached) return cached; | ||
|
|
||
| const content = readFile(entryPath); | ||
| if (!content) return null; | ||
|
|
||
| let ast: ReturnType<typeof parseAst>; | ||
| try { | ||
| ast = parseAst(content); | ||
| } catch { | ||
| return null; | ||
| } | ||
|
|
||
| const exportMap: BarrelExportMap = new Map(); | ||
|
|
||
| // Track import bindings: local name → { source, isNamespace, originalName } | ||
| const importBindings = new Map< | ||
| string, | ||
| { source: string; isNamespace: boolean; originalName?: string } | ||
| >(); | ||
|
|
||
| for (const node of ast.body as AstBodyNode[]) { | ||
| if (node.type === "ImportDeclaration") { | ||
| const source = node.source!.value as string; | ||
| for (const spec of node.specifiers!) { | ||
| if (spec.type === "ImportNamespaceSpecifier") { | ||
| importBindings.set(spec.local.name, { source, isNamespace: true }); | ||
| } else if (spec.type === "ImportSpecifier") { | ||
| const imported = astName(spec.imported!); | ||
| importBindings.set(spec.local.name, { | ||
| source, | ||
| isNamespace: false, | ||
| originalName: imported, | ||
| }); | ||
| } else if (spec.type === "ImportDefaultSpecifier") { | ||
| importBindings.set(spec.local.name, { | ||
| source, | ||
| isNamespace: false, | ||
| originalName: "default", | ||
| }); | ||
| } | ||
| } | ||
| } else if (node.type === "ExportAllDeclaration" && node.exported) { | ||
| // export * as Name from "sub-pkg" | ||
| const name = astName(node.exported); | ||
| exportMap.set(name, { source: node.source!.value as string, isNamespace: true }); | ||
| } else if (node.type === "ExportNamedDeclaration" && node.source) { | ||
| // export { A, B } from "sub-pkg" | ||
| for (const spec of node.specifiers!) { | ||
| const exported = astName(spec.exported!); | ||
| const local = astName(spec.local); | ||
| exportMap.set(exported, { | ||
| source: node.source.value as string, | ||
| isNamespace: false, | ||
| originalName: local, | ||
| }); | ||
| } | ||
| } else if (node.type === "ExportNamedDeclaration" && !node.source && node.specifiers) { | ||
| // export { X } — look up X in importBindings | ||
| for (const spec of node.specifiers) { | ||
| const exported = astName(spec.exported!); | ||
| const local = astName(spec.local); | ||
| const binding = importBindings.get(local); | ||
| if (binding) { | ||
| exportMap.set(exported, { | ||
| source: binding.source, | ||
| isNamespace: binding.isNamespace, | ||
| originalName: binding.isNamespace ? undefined : binding.originalName, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| // export * from "sub-pkg" — not resolved eagerly (left unchanged at transform time) | ||
| } | ||
|
|
||
| cache?.set(entryPath, exportMap); | ||
| return exportMap; | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
This is a lot of code added to what is already a very large file (~5000 lines). The types, interfaces, helpers (resolveExportsValue, resolvePackageEntry, buildBarrelExportMap), and the default package list total ~330 lines — all defined at module scope, far from where they're used (the plugin IIFE at line 3786).
Consider extracting the barrel optimization logic into a separate file (e.g., packages/vinext/src/optimize-imports.ts) and importing it. This would:
- Reduce
index.tscognitive load - Make the barrel optimization independently testable without importing all of vinext
- Keep the exported test helper (
_buildBarrelExportMap) cleaner
Not blocking for this PR, but worth considering as a follow-up.
|
Review posted successfully. Here's what I flagged on PR #138: Critical findingRelative source paths resolve against the wrong directory. When barrel entry files use relative re-exports (e.g., This affects the majority of packages in The existing tests don't catch this because they verify string output but never run Vite's resolver on the result. Other feedback
|
…t imports for default re-exports
Barrels like lodash-es and lucide-react use relative re-export paths
(e.g. `export { chunk } from './chunk.js'`). Writing those verbatim caused
Vite to resolve './chunk.js' against the importing user file rather than
the barrel package directory.
Fix: resolve any relative source path against the barrel entry's directory
before emitting the replacement import statement, producing an absolute
filesystem path that Vite resolves correctly regardless of where the
importing file lives.
Also fix: `export { default as X }` re-exports now produce `import X from`
(a default import) instead of `import { default as X }` (invalid syntax
that only works via ASI / bundler leniency).
Tests updated to assert absolute paths and added a dedicated test for
default re-export handling.
|
Review submitted successfully. Here's a summary of what I flagged on PR #138: Key findings
What's solidAll critical issues from the previous 5+ review rounds have been addressed: relative path resolution, mixed namespace/named grouping, cache invalidation, |
- Fix test imports: vitest → vite-plus/test, Plugin from vite → vite-plus, following the convention used by 82 of 85 test files in this repo - Add TODO comment for extensionless absolute paths produced when barrel sources lack file extensions (Vite resolves these correctly in practice) - Expand subpkgOrigin first-wins comment to explicitly note the nested node_modules edge case and why it is out of scope for the default list
|
/bigbonk review |
There was a problem hiding this comment.
Review: Barrel Import Optimization
This is a well-designed feature that solves a real, painful problem — RSC crashes from barrel re-exports that eagerly evaluate React.createContext() at module top-level. The implementation is clean, well-architected, and has been thoroughly refined through multiple review rounds. All previously-identified critical issues (relative path resolution, mixed namespace/named grouping, cache invalidation, "effect" false positives, "node" export condition, Windows path.join for scoped packages) have been addressed.
The code is cleanly extracted into its own file, caches are properly scoped and cleared in buildStart, and the test suite covers both unit-level buildBarrelExportMap tests and end-to-end transform tests with real tmp fixtures. The resolveId hook for pnpm strict hoisting is a thoughtful touch that many implementations miss.
I have a few minor observations below — none are blocking.
Non-blocking notes
-
ExportNamedDeclarationwithdeclarationbut nosource— barrels that define and export inline (e.g.export const VERSION = "1.0") are correctly handled: they fall through without being added to the export map (nospecifiersand nosource). If user code tries to importVERSION, theallResolvedguard will correctly skip the rewrite. Good. -
Extensionless absolute paths from relative barrel sources — the TODO comment at line 530 acknowledges this. Vite's resolver handles it, so this works. Just confirming there's no gap.
-
export * from(wildcard) silently bails — whenallResolvedis false because a name comes through a wildcard, there's no diagnostic signal. For a future iteration, a debug-level log would help users understand why optimization isn't kicking in for a particular import. -
resolveExportsValueand"react-server"condition — the TODO at line 160 is well-placed. When auto-detection of barrel packages is added,"react-server"should go before"node"in the priority list since RSC environments may expose a narrower barrel with fewer client-only re-exports.
| } | ||
| } | ||
| // export * from "sub-pkg" — not resolved eagerly (left unchanged at transform time) | ||
| } |
There was a problem hiding this comment.
Nit: This comment sits after the else if chain but inside the for loop body. At first glance it reads like dead code. Consider moving it into an explicit else if branch with a comment, making it clearer this is an intentional no-op:
} else if (node.type === "ExportAllDeclaration" && !node.exported) {
// export * from "sub-pkg" — wildcard re-exports are intentionally
// not resolved. If user code imports a name that only comes through
// a wildcard, the allResolved guard in the transform will leave the
// import unchanged.
}This makes the design decision explicit in the code rather than just a trailing comment. Very minor — not blocking.
| if (spec.type === "ImportSpecifier") { | ||
| const imported = astName(spec.imported!); | ||
| specifiers.push({ local: spec.local.name, imported }); | ||
| if (!exportMap.has(imported)) { |
There was a problem hiding this comment.
When allResolved bails out due to a missing name (likely from a wildcard export *), there's no signal to the developer. For large barrels where most names are in the map but one isn't, this silently disables optimization for the entire import statement.
Consider adding a debug-level log in a follow-up:
if (!allResolved) {
// TODO: add debug logging so users can diagnose why optimization was skipped
// e.g. "Skipping barrel optimization for 'lodash-es': 'debounce' not found in export map (may come from export *)"
}Non-blocking — the current behavior (leaving the import unchanged) is correct and safe.
| for (const { local, originalName } of locals) { | ||
| if (originalName === "default") { | ||
| defaultLocals.push(local); | ||
| } else if (originalName !== undefined && originalName !== local) { |
There was a problem hiding this comment.
Nit: The originalName !== undefined guard would be slightly more precise than originalName &&, since an empty string is falsy but technically a valid (though unrealistic) AST name. The current code is fine in practice — just noting for consistency with the strict typing elsewhere in this file.
tests/optimize-imports.test.ts
Outdated
| if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it("rewrites namespace re-export: import { Slot } from 'pkg' → import * as Slot from 'sub-pkg'", async () => { |
There was a problem hiding this comment.
Minor readability note: the test comment at lines 301-303 explains this well. The choice of lucide-react as the package name with @radix-ui sub-packages is initially confusing but the comment clarifies it. Good.
|
Review submitted successfully. Here's a summary of my review on PR #138: Approved — the barrel import optimization is well-implemented and ready to merge. What I reviewed
Key strengths
Non-blocking inline comments
|
|
Skimmed through the diff a bit manually and have some extra thoughts. I'll address them tomorrow. |
- Support wildcard re-exports (export * from './sub') by recursively parsing sub-modules and merging their exports into the barrel map - Add circular-reference guard to prevent infinite loops in recursive export map building - Refactor buildBarrelExportMap into buildExportMapFromFile helper with shared recursive logic - Use switch statements for spec.type handling instead of if-else chains - Remove non-null assertions and type assertions; use optional chaining and typeof guards throughout - Extract resolvePackageInfo helper to separate concerns in resolvePackageEntry - Tests now import directly from optimize-imports.ts instead of via the full vinext() plugin array - Remove _buildBarrelExportMap re-export from index.ts (no longer needed since the plugin has its own file) - Fix type assertion for optimizePackageImports in next-config.ts using a proper type-guard filter - Apply README nit: 'is always optimized' → 'are always optimized' - Add tests for wildcard export resolution, no-overwrite semantics, and circular re-export safety
|
/bigbonk review |
There was a problem hiding this comment.
Review: Barrel Import Optimization
Solid feature that addresses a real pain point — RSC crashes from barrel re-exports that eagerly call React.createContext(). The architecture is well-considered: AST-based export map building, recursive wildcard resolution, pnpm strict mode handling via resolveId + subpkgOrigin, and proper cache scoping with buildStart clearing. The test coverage is thorough, especially the end-to-end transform tests with real tmp fixtures.
The previous review rounds have done a great job catching and fixing the critical issues. I'm focusing on what remains unresolved and a few new things I noticed.
Issues to address
-
Wildcard re-export source paths are relative to the wrong file — When
export * from "./sub"is recursively resolved, entries from the sub-module retain their original relativesourcevalues (e.g.,"./button"incomponents.js). These are relative to the sub-module's directory, but the transform resolves them against the barrel entry's directory (barrelDir). This works when all files are in the same directory (as in the current tests), but breaks for nested structures wherecomponents/index.jsexports{ Button } from "./Button"— the source"./Button"would need to resolve againstcomponents/, not the barrel root. TheresolvePackageEntryreturns the barrel entry, not the sub-module. Severity: medium — most real barrels keep re-exports flat, butantdand others have nested structures. -
"react-server"condition should be addressed before shipping (per james-elicx's comment) — The TODO at line 160-163 acknowledges this, but given this plugin specifically targets RSC environments, resolving the wrong barrel entry could silently break packages that expose a narrower RSC-safe barrel via"react-server". This should go before"node"in the priority list. -
as Plugintype assertion at line 725 — james-elicx asked about removing this. Thetransform: { filter, handler }object form may not match thePlugintype directly, but rather than asserting, consider usingsatisfies Pluginor restructuring to avoid the cast. -
Type assertion in
next-config.ts— james-elicx asked if the type assertion in the config resolution is necessary. TherawOptimizeextraction withArray.isArray+.filtershould be sufficient without needing the: string[]annotation since TypeScript can infer it from the filter predicate.
Minor / non-blocking
See inline comments for additional details.
| * TODO: The "react-server" condition is increasingly common — packages like `react`, | ||
| * `react-dom`, and `next-intl` use it to expose RSC-compatible entry points. Since this | ||
| * plugin targets RSC/SSR environments, "react-server" should be added to the preferred | ||
| * condition list (before "node") in a future pass. |
There was a problem hiding this comment.
Agreeing with james-elicx here — "react-server" should be supported before this ships, not deferred as a TODO. Since this plugin explicitly targets RSC/SSR environments, packages that expose a different (narrower, RSC-safe) barrel under "react-server" would resolve the wrong entry file without it.
The fix is straightforward — add "react-server" before "node" in the condition list:
| * condition list (before "node") in a future pass. | |
| * condition list (before "node") in a future pass. |
Actually, replacing the TODO with the implementation:
for (const key of ["react-server", "node", "import", "module", "default"]) {Since "react-server" is only set by packages that know about RSC, any package without it will simply skip the condition and fall through to "node" / "import" as before.
| }; | ||
| }, | ||
| }, | ||
| } as Plugin; |
There was a problem hiding this comment.
Per james-elicx's comment — this as Plugin cast exists because the transform: { filter, handler } object form doesn't match the standard Plugin type definition.
Could this be addressed by typing the return as Plugin and letting TypeScript complain about the specific incompatibility, then fixing it properly? Or using satisfies Plugin? The as Plugin hides any future type drift.
If the Vite types don't support the object-form transform natively yet, at minimum add a comment explaining why the assertion is needed:
// `as Plugin` needed because Vite's Plugin type doesn't yet model
// the object-form `transform: { filter, handler }` pattern.
} as Plugin;| * React.createContext() in RSC environments where createContext doesn't exist. | ||
| */ | ||
|
|
||
| import type { Plugin } from "vite"; |
There was a problem hiding this comment.
This project uses Vite+ — source files in the codebase should ideally import from "vite-plus" rather than "vite" directly. Looking at other plugins in packages/vinext/src/plugins/, I see they import from "vite" as well (e.g., client-reference-dedup.ts), so this is consistent with the existing pattern.
Just noting for awareness — the "vite-plus" convention seems stricter for test files (which the test file already follows correctly).
| // extensionless absolute paths (e.g. `/node_modules/lodash-es/chunk`). | ||
| // 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). |
There was a problem hiding this comment.
james-elicx flagged the TODO here about extensionless absolute paths. This is worth addressing: when the barrel has export { X } from "./chunk" (no extension), the emitted import becomes something like /node_modules/lodash-es/chunk — an extensionless absolute path.
Vite's resolver should handle this, but it's an unusual path through the resolver (most imports are bare specifiers or relative). A defensive approach would be to check if the source has an extension and, if not, probe for common extensions using the readFile callback that's already available. Not blocking, but reduces reliance on Vite's resolver behavior for an edge case.
| async resolveId(source) { | ||
| // Only apply on server environments (RSC/SSR). The client uses Vite's | ||
| // dep optimizer which handles barrel CJS→ESM conversion correctly. | ||
| if ((this as { environment?: { name?: string } }).environment?.name === "client") return; |
There was a problem hiding this comment.
james-elicx suggested using switch statements on spec.type here instead of the nested if chain. While the current code is readable, switch would be more idiomatic for type-discriminated unions and matches the pattern already used in buildExportMapFromFile (line 315). Consistency within the same file would help.
Also applies to the specifier type check around line 602.
|
Review posted successfully. Here's a summary of what I flagged on PR #138: Key findings
|
- Resolve relative sources to absolute paths inside buildExportMapFromFile so nested wildcard re-exports (e.g. antd-style components/) resolve against the correct sub-module directory instead of the barrel root - Add react-server condition to resolveExportsValue (before node/import) for RSC-compatible entry points in packages like react and react-dom - Add NOTE comment in resolvePackageEntry documenting that only exports["."] is checked, not subpath exports - Eliminate double readFile call in buildBarrelExportMap by passing the already-read content to buildExportMapFromFile via new initialContent param - Update subpkgOrigin guard to check for absolute paths (not startsWith ".") since relative sources are now stored as absolute paths in the export map - Add test for nested subdirectory wildcard path resolution correctness - Update existing unit tests to expect absolute paths in export map entries
…ronment SSR renders with the full React runtime and must not resolve react-server export condition entries. Thread preferReactServer (env.name === 'rsc') through resolveExportsValue and resolvePackageEntry, and key entryPathCache by environment prefix to keep RSC and SSR barrel entries separate.
|
/bigbonk review |
There was a problem hiding this comment.
Review: Barrel Import Optimization
This is a well-designed feature that addresses a real pain point — RSC crashes from barrel re-exports that eagerly evaluate React.createContext(). The architecture is sound: cleanly extracted into its own plugin file, AST-based export map with recursive wildcard resolution, pnpm strict hoisting handled via resolveId + subpkgOrigin, proper cache scoping with buildStart clearing, ::${isNamespace} composite keys, and "react-server" condition awareness. The test coverage is thorough — both unit tests for buildBarrelExportMap and end-to-end transform tests with real tmp filesystem fixtures.
All critical issues from the extensive prior review rounds have been addressed. I have a few remaining observations — one functional concern and several minor items.
Issues
-
Double AST parse in
buildBarrelExportMap— The barrel entry is parsed once for validation (line 472), then the content is passed tobuildExportMapFromFilewhich parses it again (line 308-309). TheinitialContentparameter avoids a redundantreadFile, butparseAstis still called twice on the same content. This doubles the CPU work for every barrel entry. See inline comment. -
ExportNamedDeclarationwithdeclarationbut nosourceand nospecifiers— Barrels with inline declarations likeexport const VERSION = "1.0"fall through without being added to the map (correct), but if user code doesimport { VERSION } from "barrel", theallResolvedguard will bail, silently disabling optimization for the entire import statement. This is correct behavior but there's no diagnostic signal. A debug log would help users understand why optimization isn't kicking in for a particular import. -
resolvePackageEntryreturns OS-native paths on Windows from therequire.resolvefallback (line 271) — Theexportsandmodule/mainbranches normalize with.split(path.sep).join("/"), but thereq.resolve(packageName)fallback at line 271 only does the split after the fact. This is fine, but the path flows into cache keys andsubpkgOriginvalues. On Windows, cache key format could mismatch if the same path arrives via different code branches. Low risk since CI is Linux and deploy target is Workers.
Non-blocking nits
See inline comments for specifics on the double parse, the as Plugin assertion, and the test fixture naming.
| // Pre-built quoted forms used for the per-file quick-check. Computed once in | ||
| // buildStart so the transform loop doesn't allocate template literals per file. | ||
| let quotedPackages: string[] = []; | ||
|
|
There was a problem hiding this comment.
Nit: optimizedPackages is typed as Set<string> and initialized to an empty new Set(), but quotedPackages is typed as string[] initialized to []. They're always populated together in buildStart, so the empty initializers are just for the type checker. This is fine — just noting that if buildStart somehow doesn't run before transform (shouldn't happen in Vite's lifecycle), the transform would silently no-op rather than crash. Good defensive default.
| barrelCaches.subpkgOrigin.clear(); | ||
| }, | ||
|
|
||
| async resolveId(source) { |
There was a problem hiding this comment.
The resolveId hook accesses the environment via (this as { environment?: { name?: string } }).environment?.name, while the transform handler at line 557 uses the same pattern. This is now consistent between the two hooks (good — earlier versions had a discrepancy with EnvironmentPluginContext). Just confirming this is intentional and works at runtime.
|
Review posted successfully. Here's a summary of what I flagged on PR #138: Key findings
What's solidAll critical issues from 8+ prior review rounds are resolved: relative path resolution, mixed namespace/named grouping, cache invalidation, |
…ation, test names, wildcard dir test - Add TODO comment on allResolved bail-out suggesting debug logging - Remove redundant ': string[]' annotation on optimizePackageImports (inferred) - Rename namespace re-export test to use generic X/barrel names instead of Slot/lucide-react - Update syntax-error test to match new empty-map-on-parse-error behavior (not null) - Add test for directory-style 'export * from "./components"' where components/index.js exists
|
/bigbonk review |
There was a problem hiding this comment.
Review: Barrel Import Optimization
This is a well-structured feature solving a real pain point — RSC crashes from barrel re-exports that eagerly evaluate React.createContext() at module scope. The architecture is sound: cleanly extracted plugin file, AST-based export map with recursive wildcard resolution, pnpm strict hoisting handled via resolveId + subpkgOrigin, proper cache scoping with buildStart clearing, ::${isNamespace} composite keys, "react-server" condition awareness, and pre-built quoted strings for the quick-check. The test suite is thorough with both unit and end-to-end transform tests using real tmp fixtures.
The extensive prior review rounds have addressed the major issues (relative path resolution, namespace/named grouping, cache invalidation, "effect" false-positive, "node" export condition, Windows path.join, trailing semicolons). I have a few remaining observations — one functional gap and several minor items.
Key findings
-
Wildcard extension candidates missing
.mjsfor index files and.tsx— The candidate list at line 384 tries[subPath, .js, .mjs, .ts, /index.js, /index.ts]but is missing/index.mjsand.tsxvariants. Packages usingexport * from "./components"where the index file iscomponents/index.mjs(ESM-first packages) orcomponents/index.tsx(TypeScript-first packages like internal libraries) would silently fail to resolve. Given the filter at line 565 already includes.mjsfiles, this is an internal inconsistency. -
readFilecalled twice on successful wildcard candidate just to check existence — At line 395,readFile(candidate) !== nullreads the file to check if it exists, thenbuildExportMapFromFilereads it again (noinitialContentpassed for recursive calls). TheinitialContentoptimization is only applied for the entry file inbuildBarrelExportMap. For barrels with multiple wildcard levels, each sub-module is read twice. -
resolveIdhook runs on all environments including"ssr"but never on"client"— The guard at line 551 only skips"client", meaning this hook fires for every module resolution in both RSC and SSR. SincesubpkgOrigintypically has very few entries, the Map lookup is cheap, but the hook registration still participates in Vite's resolve chain for every specifier. This is fine — just documenting the design. -
Test file has a lingering
afterEachimport but only one describe block uses it —afterEachis imported at line 7 but only used in the transform describe block. Minor.
Overall this is solid work. The issues above are non-blocking. The implementation handles the motivating use case (radix-ui, lucide-react, etc.) correctly, and the safe fallback behavior (leaving imports unchanged when resolution fails) means the worst case is no optimization, not a crash.
| it("rewrites namespace re-export: import { X } from 'barrel' → import * as X from 'sub-pkg'", async () => { | ||
| // lucide-react is in DEFAULT_OPTIMIZE_PACKAGES. The barrel contents below use | ||
| // @radix-ui sub-packages intentionally — this tests the namespace rewrite path | ||
| // with an arbitrary barrel; the package name just needs to be in the optimized list. | ||
| const call = await setupTransform( | ||
| "lucide-react", | ||
| `export * as Slot from "@radix-ui/react-slot";\nexport * as Dialog from "@radix-ui/react-dialog";`, | ||
| ); | ||
| const code = `import { Slot, Dialog } from "lucide-react";\nconst x = Slot;`; | ||
| const result = call(code, "/app/component.tsx"); | ||
| expect(result).not.toBeNull(); | ||
| expect(result!.code).toContain(`import * as Slot from "@radix-ui/react-slot"`); | ||
| expect(result!.code).toContain(`import * as Dialog from "@radix-ui/react-dialog"`); | ||
| expect(result!.code).not.toContain(`from "lucide-react"`); | ||
| }); |
There was a problem hiding this comment.
Previous reviews flagged the confusing test fixture naming (lucide-react barrel with radix sub-packages). The comment at line 441 helps, but consider using a more self-documenting fixture. For example, you could add a custom package name to optimizePackageImports via the getNextConfig callback instead of repurposing lucide-react:
const plugin = createOptimizeImportsPlugin(
() => ({ optimizePackageImports: ["test-barrel"] } as any),
() => tmpDir,
);Then use test-barrel as the package name. This decouples the test from the default list and makes the fixture intent clear.
Non-blocking — the test is correct as-is.
| * React.createContext() in RSC environments where createContext doesn't exist. | ||
| */ | ||
|
|
||
| import type { Plugin } from "vite"; |
There was a problem hiding this comment.
Confirmed: other plugins in packages/vinext/src/plugins/ (async-hooks-stub.ts, client-reference-dedup.ts) also import from "vite" directly. This is consistent for source files — the "vite-plus" convention applies to test files. No action needed.
| if (!allResolved) break; | ||
| } | ||
|
|
||
| // TODO: consider debug logging which specifier was unresolved |
There was a problem hiding this comment.
Good that this TODO exists. When implementing the debug logging, consider using Vite's built-in createLogger or the this.warn / this.info methods available on the plugin context, so users can control verbosity through Vite's logLevel config. Something like:
if (!allResolved) {
const missing = specifiers.find(s => !exportMap.has(s.imported));
// Use Vite's logger when available — debug level so it's hidden by default
// logger.info(`[optimize-imports] Skipping barrel optimization for "${importSource}": "${missing?.imported}" not found in export map`);
continue;
}|
Review posted successfully. Here's a summary of what I flagged on PR #138: Key findings
All issues are non-blocking. The implementation is well-architected, all critical bugs from prior review rounds have been fixed, and the safe fallback behavior (leaving imports unchanged on failure) is the right design choice. |
…ble-read, readFile hoisting, subpkg registration guard - Add /index.mjs, /index.tsx, and .tsx to wildcard candidate list for ESM-first and TypeScript-first packages - Pass candidateContent to buildExportMapFromFile in wildcard loop to avoid reading each sub-module file twice - Add comment clarifying the no-op resolveEntry callback pattern - Hoist readFileSafe closure outside the import-declaration for-loop (one allocation per transform, not per barrel import) - Gate subpkgOrigin registration loop with registeredBarrels Set so 50 files importing the same barrel don't each iterate the full export map
|
/bigbonk review |
There was a problem hiding this comment.
Review: Barrel Import Optimization
This is a well-designed feature that solves a real pain point — RSC crashes from barrel re-exports that eagerly evaluate React.createContext() at module scope. The implementation has been through extensive review and the critical issues (relative path resolution, namespace/named grouping, cache invalidation, "effect" false-positive, "node" export condition, Windows path handling) have all been addressed. The architecture is clean: extracted into its own file, AST-based export map building with recursive wildcard resolution, pnpm strict hoisting via resolveId + subpkgOrigin, and properly scoped caches.
I'm focusing on issues that haven't been fully addressed in prior rounds, plus one structural concern.
Summary of findings
-
resolveIddoesn't guard against absolute paths from transformed barrel entries — thesubpkgOriginregistration correctly filters out relative and absolute paths, but theresolveIdhook callsthis.resolve(source, barrelEntry)without any protection. If a future code path or edge case causes an absolute path to land insubpkgOrigin, Vite's resolver would receive it as an importer, not a specifier, leading to confusing failures. Low risk given the current guards, but a defensive check would be cheap. -
buildBarrelExportMapdouble-caches —buildExportMapFromFilestores the result incacheat line 462, thenbuildBarrelExportMapstores the exact same map again at line 506. This is harmless (same key, same value) but misleading — a reader might think the two cache entries differ. -
resolvePackageEntryfallback path lacks Windows normalization — Thereq.resolve(packageName)fallback at line 271 normalizes with.split(path.sep).join("/"), which is good. ButresolvePackageInfoat line 210 usesreq.resolve(packageName)to getmainEntryand then walks up withpath.dirname/path.join— these produce OS-native paths forpkgDir. WhenpkgDirflows intopath.resolve(pkgDir, entryPath)at lines 260/267, the result goes through the.split(path.sep).join("/")normalization, so the final output is safe. Just confirming this chain is correct. -
Test gap: no test for
import * as Pkg from "barrel"being correctly skipped — theImportNamespaceSpecifiercase at line 706 setsallResolved = false, which is correct (can't optimize a full namespace import). But there's no test that verifiesimport * as R from "radix-ui"is left unchanged. This would be a simple addition. -
Minor:
export { X as default }in a barrel is not tested — The barrel patternexport { X as default }(indirect default re-export via import binding) maps"default"in the export map. The transform handlesImportDefaultSpecifierby looking up"default"in the map, which works. TheImportDefaultSpecifiertest (line 613) covers the user-side import, but the barrel side usesimport Foo from "./foo"; export { Foo as default }. There's no test for the directexport { default } from "./sub"pattern (where the barrel itself re-exports a default). The code handles it correctly (line 437 would storeoriginalName: "default"viaastName(spec.local)wherelocal.name === "default"), but explicit test coverage would be good.
Overall this is solid work — the safe fallback design (leave imports unchanged on any failure) is the right approach, and the test coverage is thorough. The issues above are non-blocking.
| if (!entryPath) return null; | ||
|
|
||
| const exportMapCache = cache ?? new Map<string, BarrelExportMap>(); | ||
|
|
There was a problem hiding this comment.
Minor: buildBarrelExportMap checks exportMapCache.get(entryPath) at line 488, but buildExportMapFromFile also checks cache.get(filePath) at line 305 with the same key. The outer check short-circuits before reading the file content, which is good for performance on cache hits. But on the first call, the content is read at line 497 and then passed as initialContent to avoid a second read — so the double-existence-check is only meaningful for subsequent calls to buildBarrelExportMap with the same entry (which would be deduplicated by entryPathCache in the transform handler anyway).
This is fine — just noting the redundancy for awareness. The layered caching works correctly.
| * Uses a pre-populated barrel export map cache so no real packages need to be | ||
| * installed. Each test uses a unique fake entry path to avoid cache collisions. | ||
| */ | ||
| import { describe, it, expect, afterEach } from "vite-plus/test"; |
There was a problem hiding this comment.
Good — imports from "vite-plus/test" and "vite-plus" (line 16) match the project convention. This was fixed from a prior review round.
| if (!allResolved) break; | ||
| } | ||
|
|
||
| // TODO: consider debug logging which specifier was unresolved |
There was a problem hiding this comment.
This TODO is worth addressing before the feature ships widely — silent optimization failures are hard to debug. Even a single console.debug (or Vite logger when available) that names the unresolved specifier would save users significant debugging time.
For example: when a barrel uses export * from "./utils" and user code imports a name that only comes through that wildcard, the import silently stays unchanged. The user sees no error, no warning — just "my barrel optimization isn't working". A debug log here closes that gap.
Non-blocking for merge, but recommend addressing as a fast follow-up.
| // Try with the path as-is first, then with common extensions. | ||
| // Include .tsx for TypeScript-first internal libraries, and /index.mjs | ||
| // for ESM-first packages where the directory index is a .mjs file. | ||
| const candidates = [ |
There was a problem hiding this comment.
The extension candidate list is comprehensive — .js, .mjs, .ts, .tsx for both direct files and directory indices. This covers the vast majority of real-world packages.
One edge case to note for the future: some packages use .cjs or .cts extensions for their barrel entries (especially CJS packages with conditional exports). These aren't in the candidate list, but they're also unlikely to appear in the DEFAULT_OPTIMIZE_PACKAGES list since the plugin targets ESM barrels. Worth a TODO comment if you want to be thorough.
| // barrel don't each re-iterate the full export map. | ||
| if (!registeredBarrels.has(barrelEntry)) { | ||
| registeredBarrels.add(barrelEntry); | ||
| for (const entry of exportMap.values()) { |
There was a problem hiding this comment.
The registeredBarrels gate is a nice optimization — avoids redundantly iterating the export map on every file that imports from the same barrel. Clean pattern.
| // Key on both resolved source and isNamespace: a named import and a | ||
| // namespace import from the same sub-module must produce separate | ||
| // import statements. | ||
| const key = `${resolvedSource}::${entry.isNamespace}`; |
There was a problem hiding this comment.
The ::${entry.isNamespace} composite key is the correct fix for the mixed namespace/named grouping bug identified in earlier reviews. This ensures import * as X and import { Y } from the same sub-module produce separate import statements. Good.
| expect(result!.code).not.toContain(`from "./b"`); | ||
| }); | ||
|
|
||
| it("leaves import unchanged when a specifier is not in the barrel map", async () => { |
There was a problem hiding this comment.
Consider adding a test for import * as R from "rxjs" (namespace import of the barrel itself) to verify it's left unchanged. The ImportNamespaceSpecifier case at optimize-imports.ts:706 sets allResolved = false, but this path has no explicit test coverage.
| it("leaves import unchanged when a specifier is not in the barrel map", async () => { | |
| it("leaves import unchanged when a specifier is not in the barrel map", async () => { | |
| // rxjs is in DEFAULT_OPTIMIZE_PACKAGES | |
| const call = await setupTransform("rxjs", `export * as Slot from "@radix-ui/react-slot";`); | |
| // "Unknown" is not exported from the barrel | |
| const code = `import { Slot, Unknown } from "rxjs";`; | |
| const result = call(code, "/app/page.tsx"); | |
| expect(result).toBeNull(); | |
| }); | |
| it("leaves namespace import of barrel unchanged (cannot be optimized)", async () => { | |
| const call = await setupTransform("rxjs", `export * as Slot from "@radix-ui/react-slot";`); | |
| const code = `import * as R from "rxjs";`; | |
| const result = call(code, "/app/page.tsx"); | |
| expect(result).toBeNull(); | |
| }); |
|
|
||
| // Resolve optimizePackageImports from experimental config | ||
| const rawOptimize = experimental?.optimizePackageImports; | ||
| const optimizePackageImports = Array.isArray(rawOptimize) |
There was a problem hiding this comment.
The type annotation on optimizePackageImports is redundant — TypeScript infers string[] from the ternary with Array.isArray + .filter((x): x is string => ...). The rawOptimize.filter(...) returns string[] thanks to the type predicate, and the [] fallback is also string[] by inference.
Not a problem, just unnecessary verbosity. james-elicx flagged this too.
| const optimizePackageImports = Array.isArray(rawOptimize) | |
| const optimizePackageImports = Array.isArray(rawOptimize) | |
| ? rawOptimize.filter((x): x is string => typeof x === "string") | |
| : []; |
|
Review posted on PR #138. Here's a summary of what I flagged: Key findings
All issues are non-blocking. The implementation is well-structured and the safe fallback design (leave imports unchanged on failure) is the right approach. |
…tx type alias - Remove the redundant exportMapCache.set() in buildBarrelExportMap — buildExportMapFromFile already stores the result under the same key; the extra set was a no-op but misleading - Extract PluginCtx type alias for the environment cast so all three hook sites share one definition and the verbose inline cast doesn't repeat across resolveId and transform
|
/bigbonk review |
There was a problem hiding this comment.
Review: Barrel Import Optimization
This is a well-designed feature that solves a real and painful problem — RSC crashes from barrel re-exports that eagerly evaluate React.createContext() at module scope. The implementation has clearly been refined through many review rounds and all the critical issues (relative path resolution, namespace/named grouping, cache invalidation, "effect" false-positive, "react-server" condition, Windows paths) have been addressed.
The architecture is solid: cleanly extracted into its own plugin file, AST-based export map with recursive wildcard resolution, pnpm strict hoisting via resolveId + subpkgOrigin, properly scoped caches cleared in buildStart, ::${isNamespace} composite keys, and pre-built quoted strings for the quick-check.
I have one functional concern (synchronous FS in the transform hook) and a few smaller items. The rest is ready.
Findings
-
Synchronous
readFileSyncin the transform handler blocks the event loop —readFileSafe(line 627) doesfs.readFileSyncinside thetransformhandler. On the first import of each barrel package, this triggers reading and parsing the barrel entry plus all recursively-resolved sub-modules synchronously. For packages likeantdwith deep re-export trees, this could cause noticeable dev-server stalls. The barrel export map is cached after the first resolution so subsequent files are fast, but the initial hit for each barrel is fully synchronous. Consider makingbuildBarrelExportMapasync (reading files withfs.promises.readFile) and making the transform handlerasync. This isn't blocking — the current approach is correct and the initial hit only happens once per barrel per build — but it's worth noting as a performance improvement for a follow-up. -
import * as Pkg from "barrel"has no test coverage — TheImportNamespaceSpecifiercase at line 711-713 correctly setsallResolved = false, but there's no test that verifiesimport * as R from "radix-ui"is left unchanged. This is a one-line test addition. -
resolvePackageEntrycreates a newcreateRequireon every call — Lines 204 and 275 each create a newcreateRequireinstance. SinceresolvePackageEntryis called per barrel package (cached viaentryPathCache), this is at most ~80 calls for the default list. Not a real issue, just noting. -
readFileSafeclosure could be hoisted further — The closure at line 627 is currently scoped to thehandlerfunction (good — shared across all imports in one file). But since it captures nothing from the per-file scope, it could be a module-level function to avoid even the per-file allocation. Very minor. -
james-elicx's unresolved comments — Several comments from james-elicx (the maintainer) appear to still be open:
- Subpath exports support (line 257 comment about iterating all subpath exports)
as Plugintype assertion removal question- Switch statements suggestion for spec.type
- Non-null assertion cleanup
_buildBarrelExportMapno longer needed in index.ts export- next-config.ts type assertion
These should be addressed or explicitly deferred before merge.
Overall this is solid work. The safe fallback design (leave imports unchanged on any failure) is the right approach, the test coverage is thorough, and the feature solves a real compatibility gap.
| return fs.readFileSync(filepath, "utf-8"); | ||
| } catch { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
This synchronous readFileSync runs inside the transform handler on the main thread. For the first import of a barrel package with a deep re-export tree (e.g., antd with export * from chains), this blocks the event loop while reading and parsing every sub-module file synchronously.
The caching means this only happens once per barrel per build, so it's not a correctness issue. But for a dev server processing its first request, multiple barrels being resolved synchronously in sequence could cause a noticeable stall.
Consider making this async in a follow-up — buildBarrelExportMap and buildExportMapFromFile could accept an async readFile and the transform handler could be async. Not blocking.
| exportMap.set(name, { source: normalizeSource(rawSource), isNamespace: true }); | ||
| } else { | ||
| // export * from "./sub" — wildcard: recursively merge sub-module exports | ||
| if (rawSource.startsWith(".")) { |
There was a problem hiding this comment.
The wildcard resolution only handles relative paths (rawSource.startsWith(".")) and skips bare package specifiers. This is documented at line 423 as intentional, but it means barrels that re-export from peer dependencies via export * from "other-pkg" will have those names silently missing from the export map.
If a user imports a name that only comes through such a wildcard, allResolved will correctly bail out and leave the import unchanged. But there's no diagnostic signal — the user just sees their import wasn't optimized with no explanation.
The TODO at line 719 covers this for the transform side. Consider also adding a brief inline comment here noting the user-visible consequence: names from external wildcard re-exports will cause the entire import statement to fall back to unoptimized.
| * React.createContext() in RSC environments where createContext doesn't exist. | ||
| */ | ||
|
|
||
| import type { Plugin } from "vite"; |
There was a problem hiding this comment.
Confirmed consistent with other plugins in packages/vinext/src/plugins/ (async-hooks-stub.ts, client-reference-dedup.ts) which also import from "vite" directly. The "vite-plus" convention applies to test files. No change needed.
| }; | ||
| }, | ||
| }, | ||
| } satisfies Plugin; |
There was a problem hiding this comment.
Good — satisfies Plugin is the right choice here. It gives structural type-checking at the object literal while still allowing the : Plugin return type to flow through. This addresses james-elicx's earlier question about the as Plugin assertion. The comment at line 542-544 explaining why satisfies is used is helpful.
| // 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}`; |
There was a problem hiding this comment.
Smart approach — keying entryPathCache on rsc:${pkg} vs ssr:${pkg} handles the case where a package has different barrel entries under "react-server" vs "import" conditions. This is a subtle detail that could easily be missed.
| // condition must NOT be preferred — SSR renders with the full React runtime. | ||
| const conditions = preferReactServer | ||
| ? ["react-server", "node", "import", "module", "default"] | ||
| : ["node", "import", "module", "default"]; |
There was a problem hiding this comment.
Minor: the "module" condition is a bundler convention (not a Node.js export condition). Since this runs in server environments, it provides minimal value when "node" and "import" are already checked. Low risk since packages with "module" almost always also have "import", but consider removing it or moving it after "default" to avoid resolving a browser-optimized entry in edge cases. Not blocking.
| // "Unknown" is not exported from the barrel | ||
| const code = `import { Slot, Unknown } from "rxjs";`; | ||
| const result = call(code, "/app/page.tsx"); | ||
| expect(result).toBeNull(); |
There was a problem hiding this comment.
Missing test for import * as R from "rxjs" (namespace import of the barrel itself). The ImportNamespaceSpecifier case at optimize-imports.ts:711-713 correctly sets allResolved = false, but this path has no explicit test coverage. Consider adding:
| expect(result).toBeNull(); | |
| expect(result).toBeNull(); | |
| }); | |
| it("leaves namespace import of barrel unchanged (cannot be optimized)", async () => { | |
| const call = await setupTransform("rxjs", `export * as Slot from "@radix-ui/react-slot";`); | |
| const code = `import * as R from "rxjs";`; | |
| const result = call(code, "/app/page.tsx"); | |
| expect(result).toBeNull(); | |
| }); |
|
|
||
| // Resolve optimizePackageImports from experimental config | ||
| const rawOptimize = experimental?.optimizePackageImports; | ||
| const optimizePackageImports = Array.isArray(rawOptimize) |
There was a problem hiding this comment.
The explicit : string[] type annotation is redundant — TypeScript infers string[] from the Array.isArray guard + .filter((x): x is string => ...) type predicate. Harmless but unnecessary.
| const optimizePackageImports = Array.isArray(rawOptimize) | |
| const optimizePackageImports = Array.isArray(rawOptimize) |
| const mainEntry = req.resolve(packageName); | ||
| let dir = path.dirname(mainEntry); | ||
| // Walk up until we find package.json with matching name | ||
| for (let i = 0; i < 10; i++) { |
There was a problem hiding this comment.
The walk-up loop for packages with strict exports is bounded to 10 iterations. For scoped packages in monorepos with hoisted dependencies, the distance from the resolved entry to the package root could theoretically exceed 10 levels (though this is extremely unlikely in practice).
The parsed.name === packageName check correctly uses the full scoped name (e.g., @radix-ui/react-slot), so it handles scoped packages properly. Just noting that the bound of 10 is a hardcoded assumption — a const MAX_WALK_DEPTH = 10 would make the intent clearer.
| if (!allResolved) break; | ||
| } | ||
|
|
||
| // TODO: consider debug logging which specifier was unresolved |
There was a problem hiding this comment.
This TODO is worth addressing as a fast follow-up. When allResolved bails because a name comes through export * or an inline export const, there's zero signal to the developer. For a large barrel where 9 of 10 imports resolve but one doesn't, the entire import statement silently stays unchanged.
A debug-level log naming the unresolved specifier would save significant debugging time:
if (!allResolved) {
const missing = specifiers.find(s => !exportMap.has(s.imported));
// logger.debug(`[optimize-imports] Skipping optimization for "${importSource}": "${missing?.imported}" not in export map`);
continue;
}|
Review posted successfully on PR #138. Here's a summary of what I flagged: Key findings
What's solidAll critical issues from the extensive prior review rounds have been resolved: relative path resolution against barrel dir, mixed namespace/named grouping via |
…ace import test - Move readFileSafe from handler scope to module level since it captures nothing from the per-file closure; avoids a per-call function allocation - Add test verifying import * as Pkg from 'barrel' is left unchanged (ImportNamespaceSpecifier sets allResolved = false — can't optimize a full namespace import to a sub-module)
Summary
vinext:optimize-importsVite plugin that rewrites barrel imports into direct sub-module imports on the serverReact.createContext()at module top-levelexport * as X,export { A, B },export { default as X },import * as X; export { X })resolveIdhook for pnpm strict hoisting compatibilityexperimental.optimizePackageImportsfromnext.configwith sensible defaults matching Next.jsSet<string>for O(1) package lookupsCloses #100
Closes #137
Test plan
React.createContextcrash