feat: optimize barrel imports for RSC-incompatible packages#138
feat: optimize barrel imports for RSC-incompatible packages#138james-elicx merged 24 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.
|
/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)
|
/bigbonk review |
|
APIError: This model does not support assistant message prefill. The conversation must end with a user message. |
|
@james-elicx Bonk workflow failed. Check the logs for details. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk re-review the pr |
There was a problem hiding this comment.
Review: Barrel Import Optimization
This is a well-designed feature that solves a real problem — RSC crashes from barrel re-exports that eagerly evaluate React.createContext() at module top-level. The implementation has been through extensive review and the critical bugs (relative path resolution, namespace/named grouping, cache invalidation, "effect" false positives, "react-server" condition, Windows paths) have all been addressed. The code is cleanly extracted into its own file, the architecture is sound, and the test suite is solid.
I'm focusing this review on items from james-elicx's maintainer review that appear still open, plus a small number of new observations. Most of the automated review feedback from prior rounds has been addressed.
Still-open maintainer feedback
-
import type { Plugin } from "vite"in source file — This is consistent with other plugins insrc/plugins/(async-hooks-stub.ts,client-reference-dedup.ts), so no change needed in the source file. The test file correctly uses"vite-plus"/"vite-plus/test"already. ✅ -
Subpath exports support (james-elicx line 269 comment) — Only
exports["."]is checked. The code has a good comment documenting this limitation. This is fine for v1 — the default package list all use root barrels. Agree this is a follow-up. -
satisfies Plugin— Already addressed. ✅ -
Type assertion in next-config.ts — Still present but harmless. See inline comment.
New observations
See inline comments for details. Nothing blocking — the implementation is ready for merge after addressing the maintainer's open items.
| // Check if ALL specifiers can be resolved. If any can't, leave the import unchanged. | ||
| const specifiers: Array<{ local: string; imported: string }> = []; | ||
| let allResolved = true; | ||
| for (const spec of node.specifiers ?? []) { |
There was a problem hiding this comment.
james-elicx suggested using switch on spec.type here. The current switch is already there (line 694), so this was addressed. ✅
However, there's an edge case worth noting: the case "ImportSpecifier" block checks if (!spec.imported) and sets allResolved = false, but ImportSpecifier nodes in the ESTree spec always have an imported property. This guard is defensive but will never trigger in practice. Not blocking — just noting it's dead code.
| include: /\.(tsx?|jsx?|mjs)$/, | ||
| }, | ||
| }, | ||
| handler(code, id) { |
There was a problem hiding this comment.
The transform handler is synchronous (returns TransformResult | null, not a Promise). The readFileSafe calls inside buildBarrelExportMap use readFileSync, which blocks the event loop during the initial barrel resolution for each package.
For the default package list this is a one-time cost per barrel per build (cached afterward), so it's acceptable for v1. But for packages with deep export * from chains (e.g., antd), this could cause a noticeable dev-server stall on first request.
Worth noting as a follow-up: making buildBarrelExportMap async and the handler async would let Vite interleave other work during barrel resolution. Not blocking.
| if (!allResolved) break; | ||
| } | ||
|
|
||
| // TODO: consider debug logging which specifier was unresolved |
There was a problem hiding this comment.
This TODO is important for developer experience. When allResolved bails because a name comes through a wildcard export * or an inline export const, the import silently stays unchanged with no diagnostic. For a barrel where 9 of 10 names resolve but one doesn't, the entire import falls back to unoptimized with no signal.
Recommend addressing as a fast follow-up — even a console.debug naming the unresolved specifier would save significant debugging time.
|
|
||
| // Resolve optimizePackageImports from experimental config | ||
| const rawOptimize = experimental?.optimizePackageImports; | ||
| const optimizePackageImports = Array.isArray(rawOptimize) |
There was a problem hiding this comment.
james-elicx asked if this type annotation is needed — it's redundant since TypeScript infers string[] from Array.isArray + the .filter((x): x is string => ...) type predicate.
| const optimizePackageImports = Array.isArray(rawOptimize) | |
| const optimizePackageImports = Array.isArray(rawOptimize) |
| 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: originalName !== undefined would be more precise than relying on the truthiness check via the && short-circuit in the else if above (line 780). An empty string is falsy but technically a valid (though unrealistic) AST name. The current code works correctly for all realistic inputs — this is purely about defensive precision.
| } else if (originalName !== undefined && originalName !== local) { | |
| } else if (originalName !== undefined && originalName !== local) { |
| // "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.
Good that there's now an explicit test for the import * as Pkg from "barrel" case (line 512). The allResolved = false guard is exercised.
| } | ||
|
|
||
| /** Extract the string name from an Identifier ({name}) or Literal ({value}) AST node. */ | ||
| function astName(node: { name?: string; value?: string | boolean | number | null }): string { |
There was a problem hiding this comment.
Minor: if both node.name and node.value are absent/undefined, this throws a generic Error. In practice this can't happen for ImportSpecifier.imported or ExportSpecifier.exported AST nodes, but the error message could include some node context (e.g., JSON.stringify(node)) to aid debugging if it ever fires on an unexpected AST shape. Not blocking.
| 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 bound of 10 is a reasonable heuristic. Consider extracting it to a named constant (e.g., const MAX_PKG_ROOT_WALK_DEPTH = 10) to make the intent clearer to future readers. Very minor.
| // 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 wildcard candidate list is thorough — .js, .mjs, .ts, .tsx for both direct files and directory indices. One gap: .cjs and .cts extensions are not tried. These are uncommon in ESM barrels (which is the target), so this is fine for now. Just noting for completeness.
Also, the candidate order tries the bare path first (subPath), which would match if the path already includes the extension (e.g., export * from "./utils.js"). Good.
| if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it("rewrites namespace re-export: import { X } from 'barrel' → import * as X from 'sub-pkg'", async () => { |
There was a problem hiding this comment.
The comment at lines 440-442 explains the confusing fixture naming (lucide-react barrel with radix sub-packages). This is adequate — the reader understands the package name just needs to be in DEFAULT_OPTIMIZE_PACKAGES and the barrel contents are synthetic.
A slightly cleaner approach for future tests would be to add a custom package name via the getNextConfig callback's optimizePackageImports array, decoupling tests from the default list entirely. Not blocking.
|
Review posted successfully. Here's a summary of what I flagged on PR #138: Key findings
All critical issues from prior review rounds have been resolved. The implementation is well-structured and the safe fallback design (leave imports unchanged on failure) is correct. The maintainer's open items (subpath exports, type assertions) are tracked but non-blocking. |
…d subpkgOrigin, debug gate, graceful astName
|
/bigbonk review the DIFF / changes in this pr. ignore other peoples review comments when you review the pr. |
There was a problem hiding this comment.
Review: Barrel Import Optimization
This is a solid, well-structured feature that addresses a real pain point — RSC crashes from barrel re-exports that eagerly evaluate React.createContext() at module scope. The implementation has been through many review rounds and the critical issues (relative path resolution, namespace/named grouping, cache invalidation, "effect" false-positive, "react-server" condition, Windows paths) have all been addressed.
I have one correctness concern, a few items from james-elicx's review that appear still open, and some minor notes.
1. registeredBarrels dedup is not environment-scoped (potential correctness gap)
registeredBarrels is a Set<string> keyed by the resolved barrel entry path. The resolveId fallback (line 648-649) checks both the current environment's subpkgOrigin map and the other environment's map, which paper over this in the common case. However, if a package has different barrel entries for RSC vs SSR (via "react-server" condition), and those different entries re-export different sub-packages, the registeredBarrels key is different for each, so both get registered correctly.
But there's a subtle gap: the registeredBarrels set is never scoped to envKey. If the same package has the same barrel entry for both RSC and SSR, RSC registers it first under envKey="rsc". SSR skips because registeredBarrels.has(barrelEntry) is true, but the sub-packages are only in the "rsc" origin map, not "ssr". The resolveId fallback mitigates this — but it means SSR always resolves these sub-packages using the RSC barrel's context, which is fine when they share the same entry, but is a coupling that could break if the fallback logic changes.
Consider keying registeredBarrels on ${envKey}:${barrelEntry} for isolation, matching the entryPathCache pattern.
2. Still-open maintainer feedback
james-elicx left several comments that should be addressed or explicitly deferred:
- next-config.ts type annotation (line 509) — redundant
: string[]annotation. TypeScript infersstring[]from the filter predicate. Trivial cleanup. - Subpath exports (line 300 comment) — only
exports["."]is checked. The code has a goodNOTEcomment documenting this limitation. Fine for v1 as a documented follow-up.
3. Minor notes (non-blocking)
See inline comments.
| // subpkgOrigin is keyed by environment ("rsc"/"ssr") so that divergent | ||
| // barrel entries (e.g. react-server vs import condition) stay isolated. | ||
| const envKey = preferReactServer ? "rsc" : "ssr"; | ||
| if (!registeredBarrels.has(barrelEntry)) { |
There was a problem hiding this comment.
registeredBarrels is not scoped by environment. If RSC processes a barrel first and registers its sub-packages under the "rsc" origin map, SSR skips because registeredBarrels.has(barrelEntry) is true, leaving the "ssr" origin map empty for that barrel's sub-packages.
The resolveId fallback at line 648-649 compensates by checking the other environment's map, so this works in practice. But it creates a coupling — if the fallback is ever removed or changed, SSR resolveId would silently fail to find sub-packages.
Consider scoping:
| if (!registeredBarrels.has(barrelEntry)) { | |
| if (!registeredBarrels.has(`${envKey}:${barrelEntry}`)) { | |
| registeredBarrels.add(`${envKey}:${barrelEntry}`); |
This way each environment independently populates its own subpkgOrigin map, making the resolveId fallback a true fallback rather than the primary path for the second environment.
|
|
||
| // Resolve optimizePackageImports from experimental config | ||
| const rawOptimize = experimental?.optimizePackageImports; | ||
| const optimizePackageImports = Array.isArray(rawOptimize) |
There was a problem hiding this comment.
james-elicx flagged this — the explicit type annotation is redundant since TypeScript infers string[] from Array.isArray + the .filter((x): x is string => ...) type predicate. Matches the pattern used for serverExternalPackages a few lines below which also doesn't annotate.
| 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.
Nit: the magic number 10 would be clearer as a named constant.
| for (let i = 0; i < 10; i++) { | |
| for (let i = 0; i < MAX_PKG_ROOT_WALK_DEPTH; i++) { |
With const MAX_PKG_ROOT_WALK_DEPTH = 10; defined near the top of the file.
| 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: originalName && conflates undefined with empty string (falsy). originalName !== undefined would be more precise, even though an empty string name can't come from the AST parser in practice.
| } else if (originalName !== undefined && originalName !== local) { | |
| } else if (originalName !== undefined && originalName !== local) { |
|
|
||
| /** Extract the string name from an Identifier ({name}) or Literal ({value}) AST node. | ||
| * Returns null for unexpected node shapes so callers can degrade gracefully rather than crash. */ | ||
| function astName(node: { name?: string; value?: string | boolean | number | null }): string | null { |
There was a problem hiding this comment.
Good defensive approach returning null instead of throwing. Minor: if this ever fires on an unexpected AST shape, the null return propagates silently. Consider including some node context in a debug log (e.g. JSON.stringify(node)) so failures are diagnosable. Not blocking.
| expect(result).toBeNull(); | ||
| }); | ||
|
|
||
| it("leaves namespace import unchanged: import * as Pkg from 'barrel' cannot be optimized", async () => { |
There was a problem hiding this comment.
Good — this was flagged as a test gap in prior reviews and is now covered. The ImportNamespaceSpecifier guard at optimize-imports.ts:781-783 is exercised.
| registeredBarrels.clear(); | ||
| }, | ||
|
|
||
| async resolveId(source) { |
There was a problem hiding this comment.
The resolveId hook fires on every module resolution in server environments because it checks subpkgOrigin for each specifier. Since the map is typically small (bounded by barrel export count) and Map lookups are O(1), this is fine. Just confirming this is intentional — the hook correctly returns undefined early for non-matching specifiers.
| `${subPath}/index.mts`, | ||
| `${subPath}/index.cts`, | ||
| ]; | ||
| for (const candidate of candidates) { |
There was a problem hiding this comment.
The candidate list is thorough, covering .js, .mjs, .cjs, .ts, .tsx, .jsx, .mts, .cts for both direct files and directory indices. This handles TypeScript-first, ESM-first, and CJS packages.
One note: this tries up to 18 candidates per wildcard re-export. For barrels with many export * from chains (e.g. antd), this multiplies the file reads. Each candidate calls readFile which does a filesystem stat+read. The caching of buildExportMapFromFile results prevents re-parsing resolved files, but the failed readFile calls for non-existent candidates aren't cached. This is a one-time cost per barrel per build, so it's acceptable — just worth noting for future optimization if profiling shows it as a bottleneck.
|
Review posted successfully. Here's a summary of what I flagged on PR #138: Key findings
What's solidAll critical issues from the extensive prior review rounds are resolved: relative path resolution via |
…locking SSR
When RSC and SSR resolve the same barrel entry path (common — most packages
have no react-server export condition), the bare barrelEntry key in
registeredBarrels caused RSC's registration to suppress SSR's inner loop
entirely. SSR's subpkgOrigin map was left empty, making resolveId depend
solely on the cross-env fallback — fragile coupling that could silently break.
Change the registeredBarrels key from barrelEntry to `${envKey}:barrelEntry`
so each environment runs the inner loop and populates its own subpkgOrigin
map independently. Matches the existing entryPathCache key pattern.
Add a regression test covering the shared-barrel-entry scenario.
|
/bigbonk review the DIFF of this pr |
There was a problem hiding this comment.
Fresh review of PR #138: Barrel import optimization
This is a well-designed feature that solves a real, painful problem — RSC crashes from barrel re-exports eagerly evaluating React.createContext() at module scope. The implementation has been through an extensive review cycle and the major correctness issues (relative path resolution, namespace/named grouping, cache invalidation, "effect" false-positive, "react-server" condition, Windows paths) have all been addressed.
The architecture is sound: clean extraction into plugins/optimize-imports.ts, AST-based export map with recursive wildcard resolution, pnpm strict hoisting via resolveId + subpkgOrigin, properly scoped caches cleared in buildStart, ::${isNamespace} composite keys, and DEBUG-gated diagnostic logging.
What I'd want addressed before merge
-
resolvePackageInfocreates acreateRequireper call, andresolvePackageEntrycreates another one — Both are async and called per barrel package.resolvePackageInfocreates one at line 245, then if that resolves,resolvePackageEntrycreates a second one at line 318. These share the sameprojectRootand could share a singlecreateRequireinstance. More importantly, all these functions useasync fs.readFilebut call synchronouscreateRequire+req.resolvewhich does synchronous filesystem probing. The async/sync mix is fine for correctness but worth noting — theasyncin these functions only matters for thefs.readFilecalls. -
The
"module"export condition inresolveExportsValueresolves bundler-targeted entries in a server context. This is low risk (packages with"module"almost always also have"import"), but placing it after"default"or removing it would be more correct for server environments. Non-blocking, but worth a follow-up. -
Two open items from james-elicx's review still appear unresolved: the subpath exports suggestion (iterating all subpath exports, not just
exports["."]) and theas Plugin→satisfies Pluginchange. The latter was actually addressed (the code usessatisfies Pluginnow — good), but the subpath exports item deserves at least a tracking comment. It's documented as aNOTEcomment at line 301, which is sufficient for now.
Non-blocking observations
See inline comments for specifics. Overall this is solid work and ready to merge after the inline items are considered.
| return path.resolve(pkgDir, entryField).split(path.sep).join("/"); | ||
| } | ||
|
|
||
| const req = createRequire(path.join(projectRoot, "package.json")); |
There was a problem hiding this comment.
This creates a second createRequire instance for the same projectRoot — resolvePackageInfo already created one at line 245. Since resolvePackageEntry calls resolvePackageInfo first (which does its own createRequire), this second one is only hit when the exports and module/main paths don't yield a result.
Consider extracting the createRequire call to a shared spot, or passing the req from resolvePackageInfo through the return value so resolvePackageEntry can reuse it. Minor — createRequire is cheap, and entryPathCache means this path is only hit once per barrel per build.
| 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 magic number 10 would be clearer as a named constant:
| for (let i = 0; i < 10; i++) { | |
| const MAX_PKG_ROOT_WALK_DEPTH = 10; | |
| for (let i = 0; i < MAX_PKG_ROOT_WALK_DEPTH; i++) { |
Very minor — the behavior is correct as-is.
| !entry.source.startsWith(".") && | ||
| !envOriginMap.has(entry.source) | ||
| ) { | ||
| // First barrel to register this specifier (within this environment) wins. |
There was a problem hiding this comment.
Worth a brief comment explaining why first-wins is safe here — a future reader might wonder about nested node_modules scenarios where two barrels could theoretically resolve the same specifier to different versions.
| // First barrel to register this specifier (within this environment) wins. | |
| !envOriginMap.has(entry.source) | |
| ) { | |
| // First barrel to register this specifier wins. This is safe because | |
| // sub-package specifiers (e.g. "@radix-ui/react-slot") resolve to the | |
| // same path regardless of which barrel's context is used as the importer. |
| 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: originalName && conflates undefined with empty string. While an empty-string name can't come from the AST parser in practice, originalName !== undefined would be more precise:
| } else if (originalName !== undefined && originalName !== local) { | |
| } else if (originalName !== undefined && originalName !== local) { |
| } | ||
| } | ||
| } | ||
| // Non-relative wildcard re-exports (e.g. `export * from "other-pkg"`) are |
There was a problem hiding this comment.
Good design decision to skip non-relative wildcard re-exports. Worth noting that when this happens, any names that only come through export * from "other-pkg" will cause allResolved to bail in the transform, silently leaving the import unchanged. The debug logging at line 804 covers this for developers who opt in via DEBUG=vinext:*, which is the right level of visibility.
| expect(DEFAULT_OPTIMIZE_PACKAGES).toContain("antd"); | ||
| }); | ||
|
|
||
| it("resolves nested subdirectory wildcard re-exports to the correct absolute path", async () => { |
There was a problem hiding this comment.
This is an excellent test — it directly catches the relative-path resolution bug that was the critical finding in earlier reviews. The assertion at line 357 (expect(buttonEntry!.source).not.toBe("/fake/nested/Button")) is the key regression guard.
| expect(result!.code).not.toContain(`from "./`); | ||
| }); | ||
|
|
||
| it("populates subpkgOrigin independently for RSC and SSR when they share the same barrel entry", async () => { |
There was a problem hiding this comment.
Good regression test for the registeredBarrels environment-scoping fix. This test is well-structured — it exercises both RSC and SSR paths sequentially and verifies that the second environment isn't silently broken by the first environment's registration.
|
|
||
| /** Extract the string name from an Identifier ({name}) or Literal ({value}) AST node. | ||
| * Returns null for unexpected node shapes so callers can degrade gracefully rather than crash. */ | ||
| function astName(node: { name?: string; value?: string | boolean | number | null }): string | null { |
There was a problem hiding this comment.
If both node.name and node.value are absent, this returns null silently. The callers handle null gracefully (they skip the export/import), which is the right behavior. But if this ever fires on an unexpected AST shape, there's no diagnostic. Consider adding context to help debug:
| function astName(node: { name?: string; value?: string | boolean | number | null }): string | null { | |
| function astName(node: { name?: string; value?: string | boolean | number | null }): string | null { | |
| if (node.name !== undefined) return node.name; | |
| if (typeof node.value === "string") return node.value; | |
| if (isDebugEnabled) { | |
| console.debug(`[vinext:optimize-imports] astName: unexpected node shape`, node); | |
| } | |
| return null; | |
| } |
Very minor — this can't happen for standard ImportSpecifier.imported / ExportSpecifier.exported nodes.
| // 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.
The "module" condition is a bundler convention, not a Node.js export condition. Since this plugin runs in server environments (RSC/SSR), resolving a bundler-targeted entry could theoretically return a browser-optimized barrel. In practice this is very low risk (packages with "module" almost always also have "import" which is checked first), but moving "module" after "default" would be slightly more correct.
Non-blocking — the current order works for all packages in DEFAULT_OPTIMIZE_PACKAGES.
| if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it("rewrites namespace re-export: import { X } from 'barrel' → import * as X from 'sub-pkg'", async () => { |
There was a problem hiding this comment.
The comment at lines 501-503 explaining the confusing fixture setup (lucide-react barrel with radix sub-packages) is helpful. An alternative approach for future tests would be to add a custom package name via getNextConfig → optimizePackageImports, decoupling from the default list entirely. Not worth changing now — the comment is sufficient.
|
Review posted successfully. Here's a summary of what I flagged on PR #138: Key findings
Non-blocking inline comments
What's solidAll critical issues from the extensive prior review rounds are resolved. The implementation is clean, well-extracted into its own file, and the test suite is thorough with both unit and end-to-end transform tests using real tmp fixtures. The |
date-fns barrels use `export * from "./formatDistanceToNow.js"` where the
sub-module directly declares `export function formatDistanceToNow(...) {}`.
buildExportMapFromFile only handled ExportNamedDeclaration with a source
(re-exports) or specifiers (local re-bindings) — it silently skipped inline
declarations (export function/class/const/let/var), leaving the name absent
from the export map and causing the plugin to log "skipping: could not
resolve specifier" and leave the barrel import unchanged.
Handle the declaration branch: FunctionDeclaration and ClassDeclaration
contribute their id.name; VariableDeclaration iterates all declarators.
The source for inline declaration entries is the sub-module file itself.
Also extend AstBodyNode.declaration typing from `unknown` to the minimal
shape covering all three declaration kinds.
Add three unit tests covering export function, export const (multiple
declarators), and export class; all 38 tests pass.
|
/bigbonk review the DIFF in this pr |
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 architecture is clean: extracted into its own file, AST-based export map with recursive wildcard resolution, pnpm strict hoisting handled via resolveId + subpkgOrigin, environment-scoped caches cleared in buildStart, and ::${isNamespace} composite keys for correct namespace/named grouping.
The extensive prior review rounds have addressed the critical correctness bugs (relative path resolution, mixed namespace/named grouping, cache invalidation, "effect" false-positive, "react-server" condition, Windows paths). The test coverage is strong with both unit tests for the export map builder and end-to-end transform tests using real tmp fixtures.
I have a few remaining findings below — one potential correctness issue and several minor items.
Summary
resolveIdcross-environment fallback has a subtle race with environment-scopedregisteredBarrels— The scoping fix is correct, but theresolveIdfallback that checks the other environment's map creates a tight coupling that deserves documentation.resolvePackageEntrycreates redundantcreateRequireinstances — Minor perf, but easy to fix.readFileSafeis async but the transform handler callsawaitin a tight loop — Correct but worth noting as a future perf improvement target.- Several minor items —
originalName &&precision, magic number for walk-up depth, inline suggestions.
| const envName = (this as PluginCtx).environment?.name ?? "ssr"; | ||
| const barrelEntry = | ||
| barrelCaches.subpkgOrigin.get(envName)?.get(source) ?? | ||
| barrelCaches.subpkgOrigin.get(envName === "rsc" ? "ssr" : "rsc")?.get(source); |
There was a problem hiding this comment.
The cross-environment fallback is a pragmatic solution, but it creates an implicit coupling: the resolveId hook for SSR silently depends on RSC having already processed the same barrel (or vice versa). If a barrel is only ever imported from one environment's module graph, the other environment's resolveId will always miss both maps and return undefined.
This is fine in practice — if an environment never transforms an import from a barrel, it won't produce specifiers that need resolution. But the bidirectional fallback makes the code harder to reason about. Consider adding a brief comment explaining when the fallback is expected to fire and why it's safe:
| barrelCaches.subpkgOrigin.get(envName === "rsc" ? "ssr" : "rsc")?.get(source); | |
| barrelCaches.subpkgOrigin.get(envName)?.get(source) ?? | |
| // Cross-environment fallback: if this environment hasn't processed the barrel yet | |
| // (e.g. SSR resolving a specifier introduced by RSC's transform), check the other | |
| // environment's map. Safe because the sub-package specifier resolves to the same | |
| // path regardless of which barrel context is used as the importer. | |
| barrelCaches.subpkgOrigin.get(envName === "rsc" ? "ssr" : "rsc")?.get(source); |
| return path.resolve(pkgDir, entryField).split(path.sep).join("/"); | ||
| } | ||
|
|
||
| const req = createRequire(path.join(projectRoot, "package.json")); |
There was a problem hiding this comment.
This creates a second createRequire instance for the same projectRoot — resolvePackageInfo already creates one at line 255. Since resolvePackageEntry calls resolvePackageInfo first and only reaches this line when the exports/module/main paths don't yield a result, the duplication is low-frequency. But it's easy to eliminate by having resolvePackageInfo return the req instance alongside pkgDir/pkgJson, or by restructuring resolvePackageEntry to reuse the one from resolvePackageInfo.
Non-blocking — entryPathCache means this is hit at most once per barrel per build.
| 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 magic number 10 is a reasonable heuristic but would be clearer as a named constant. This also applies context for anyone investigating future issues with deeply-nested monorepo node_modules layouts.
| for (let i = 0; i < 10; i++) { | |
| const MAX_PKG_ROOT_WALK_DEPTH = 10; | |
| for (let i = 0; i < MAX_PKG_ROOT_WALK_DEPTH; i++) { |
| 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: originalName && conflates undefined with empty string (both falsy). While an empty-string name can't come from the AST parser in practice, originalName !== undefined is more precise and consistent with the strict typing elsewhere in this file.
| } else if (originalName !== undefined && originalName !== local) { | |
| } else if (originalName !== undefined && originalName !== local) { |
| !entry.source.startsWith("/") && | ||
| !entry.source.startsWith(".") && | ||
| !envOriginMap.has(entry.source) | ||
| ) { |
There was a problem hiding this comment.
The first-wins dedup assumption is correct but subtle. A future reader might wonder about nested node_modules scenarios where two barrels re-export from the same sub-package but resolve to different versions. Worth a brief inline note:
| ) { | |
| !envOriginMap.has(entry.source) | |
| ) { | |
| // First barrel to register this specifier wins. This is safe because | |
| // sub-package specifiers (e.g. "@radix-ui/react-slot") resolve to the | |
| // same path regardless of which barrel's context is used as the importer — | |
| // only the importer directory differs, not the resolved target. |
|
|
||
| /** Extract the string name from an Identifier ({name}) or Literal ({value}) AST node. | ||
| * Returns null for unexpected node shapes so callers can degrade gracefully rather than crash. */ | ||
| function astName(node: { name?: string; value?: string | boolean | number | null }): string | null { |
There was a problem hiding this comment.
If this ever fires on an unexpected AST shape, the null return propagates silently through the callers — the export/import is simply skipped. This is the right graceful degradation, but a debug log would help diagnose issues:
| function astName(node: { name?: string; value?: string | boolean | number | null }): string | null { | |
| function astName(node: { name?: string; value?: string | boolean | number | null }): string | null { | |
| if (node.name !== undefined) return node.name; | |
| if (typeof node.value === "string") return node.value; | |
| if (isDebugEnabled) { | |
| console.debug(`[vinext:optimize-imports] astName: unexpected node shape`, JSON.stringify(node)); | |
| } | |
| return null; | |
| } |
Non-blocking — this can't happen for standard ImportSpecifier.imported / ExportSpecifier.exported nodes.
| // 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.
The "module" condition is a bundler convention (not a Node.js export condition). Since this plugin targets server environments, a package that only exposes a browser-optimized entry via "module" (without "import" or "node") could resolve the wrong barrel file. In practice the risk is very low — packages with "module" almost always also have "import" — but consider moving "module" after "default" or removing it. Non-blocking.
| if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it("rewrites namespace re-export: import { X } from 'barrel' → import * as X from 'sub-pkg'", async () => { |
There was a problem hiding this comment.
The test name and fixture are initially confusing — lucide-react as the package name but @radix-ui sub-packages as the barrel contents. The comment at lines 565-567 helps, but a more self-documenting approach for future tests would be to add a custom package name via the getNextConfig callback's optimizePackageImports array, decoupling from the default list entirely. This would also make the test resilient to changes in DEFAULT_OPTIMIZE_PACKAGES.
Non-blocking — the test is correct and the comment is adequate.
| } | ||
| } | ||
| } | ||
| // Non-relative wildcard re-exports (e.g. `export * from "other-pkg"`) are |
There was a problem hiding this comment.
When non-relative wildcard re-exports are skipped here (e.g. export * from "other-pkg"), any names that only come through that wildcard will cause allResolved to bail in the transform, silently disabling optimization for the entire import statement. The debug logging at line 831 covers the transform-side signal, but a brief note here about the user-visible consequence would help:
| // Non-relative wildcard re-exports (e.g. `export * from "other-pkg"`) are | |
| // Non-relative wildcard re-exports (e.g. `export * from "other-pkg"`) are | |
| // intentionally skipped — they'd require resolving an external package which | |
| // is out of scope for the barrel optimization pass. Names that only come | |
| // through such wildcards will cause allResolved to bail in the transform, | |
| // leaving the entire import statement unchanged (logged at DEBUG level). |
|
Review posted successfully. Here's a summary of what I flagged on PR #138: Key findings
All issues are non-blocking. The implementation is solid, well-tested, and addresses all critical bugs from prior review rounds. |
|
It's going around in circles a lot now and not really throwing up any substantiative concerns. I'm going to merge - it would be good to see the impact on real-world projects. |
|
Is bonk ever satisfied 😅 |
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