Skip to content

feat: optimize barrel imports for RSC-incompatible packages#138

Merged
james-elicx merged 24 commits intocloudflare:mainfrom
gentritbiba:feat/optimize-package-imports
Mar 21, 2026
Merged

feat: optimize barrel imports for RSC-incompatible packages#138
james-elicx merged 24 commits intocloudflare:mainfrom
gentritbiba:feat/optimize-package-imports

Conversation

@gentritbiba
Copy link
Contributor

Summary

  • Adds vinext:optimize-imports Vite plugin that rewrites barrel imports into direct sub-module imports on the server
  • Prevents RSC crashes caused by eager evaluation of barrel re-exports that call React.createContext() at module top-level
  • Parses barrel entry files to build export maps, handling all re-export patterns (export * as X, export { A, B }, export { default as X }, import * as X; export { X })
  • Uses Vite's own resolver via resolveId hook for pnpm strict hoisting compatibility
  • Only applies on server environments (RSC/SSR) — client uses Vite's dep optimizer
  • Respects experimental.optimizePackageImports from next.config with sensible defaults matching Next.js
  • Lazy-initialized Set<string> for O(1) package lookups

Closes #100
Closes #137

Test plan

  • 19 new unit tests covering plugin existence, guard clauses, all barrel export patterns, caching, aliased exports, mixed exports, and edge cases (syntax errors, unresolvable packages)
  • All 2030 existing tests pass
  • Manual verification with shadcn reproduction repo — page renders without React.createContext crash
  • Lint clean (oxlint) and type check clean (tsgo)

@gentritbiba gentritbiba force-pushed the feat/optimize-package-imports branch from d24b6fb to 45a4fd4 Compare February 26, 2026 19:21
@beniaminrychter
Copy link

/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
@gentritbiba gentritbiba force-pushed the feat/optimize-package-imports branch from 45a4fd4 to 8584d7a Compare February 27, 2026 17:15
@gentritbiba
Copy link
Contributor Author

I don't think bonk is reviewing this one  

Copy link
Member

@zebp zebp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to look closer at the next implementation before I finalize the review, but so far this looks good

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 1, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@138

commit: 3031846

@gentritbiba
Copy link
Contributor Author

Updated the DEFAULT_OPTIMIZE_PACKAGES list to include all packages from Next.js's defaults — added the full effect ecosystem (17 packages) and all react-icons/* sub-packages (32 packages). The list now matches Next.js's config exactly, plus radix-ui as an intentional addition.

Add the full effect ecosystem (17 packages) and all react-icons/*
sub-packages (32 packages) to match Next.js's built-in defaults.
@gentritbiba gentritbiba force-pushed the feat/optimize-package-imports branch from a8bed35 to 670812f Compare March 2, 2026 05:18
@southpolesteve
Copy link
Collaborator

/bigbonk review this please

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Module-level caches never clearedbarrelExportMapCache and barrelSubpkgOrigin persist across HMR and pnpm install in dev
  2. "effect" in the quick-check causes unnecessary parsing of nearly every React file in RSC/SSR
  3. resolveExportsValue missing "node" condition — could resolve the wrong barrel entry file for server environments
  4. Minor: inconsistent this.environment access patterns, cosmetic nit on IIFE indentation

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 3, 2026

Review posted successfully. Here's a summary of what I flagged on PR #138:

Key findings:

  1. Module-level caches never cleared (index.ts:245, 252) — barrelExportMapCache and barrelSubpkgOrigin are module-level Maps that persist across HMR and dependency updates in dev mode. Other caches in the file have invalidation hooks; these need the same treatment.

  2. "effect" causes unnecessary AST parsing (index.ts:280) — The quick string check code.includes(pkg) matches "effect" in useEffect, sideEffect, etc., triggering AST parsing for nearly every React component in RSC/SSR environments. Suggested tightening the check to look for quoted package names.

  3. Missing "node" export condition (index.ts:346) — resolveExportsValue only checks "import", "module", and "default". Since this runs in server environments, it should prefer the "node" condition to resolve the correct barrel entry file. Provided a concrete suggestion.

  4. Minor items — Inconsistent this.environment access pattern between resolveId and transform (cosmetic), IIFE indentation style, missing trailing semicolons in replacements (works via ASI but imprecise), verbose comment block in test file that should be condensed, and no test coverage for the client-environment guard.

github run

- 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>
@gentritbiba
Copy link
Contributor Author

Addressed Bonk's review feedback:

  1. Fixed "effect" false-positive quick-check — The code.includes(pkg) check now uses quoted string matching (code.includes('"effect"')) to avoid triggering unnecessary AST parsing on files that merely contain useEffect, sideEffect, etc.

  2. Added "node" export conditionresolveExportsValue now checks ["node", "import", "module", "default"] so server environments resolve the correct barrel entry path for packages that nest their Node.js entry under a "node" condition.

  3. Scoped barrel caches to plugin instancebarrelExportMapCache and barrelSubpkgOrigin moved from module-level into the plugin IIFE, preventing stale cache across dev restarts. buildBarrelExportMap now accepts an optional cache parameter.

Also merged upstream/main to resolve conflicts. All 2158 tests pass, typecheck/build/lint clean.

@gentritbiba
Copy link
Contributor Author

Need anything else from me to merge this?

@hyoban
Copy link
Contributor

hyoban commented Mar 20, 2026

@james-elicx @southpolesteve Sorry to bother you, but could you have the bot review this again? It would help improve compatibility.

@james-elicx
Copy link
Collaborator

Yeah of course no worries. Let me resolve the conflicts and then I'll re-request it. Sorry for the delay

@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. resolvePackageEntry resolves its own entry independently of resolveExportsValue — for packages with strict exports that don't expose ./package.json, the fallback req.resolve(packageName) may return a CJS entry that differs from the ESM barrel entry found via exports["."]. This creates a mismatch where the AST is parsed from the wrong file.
  2. resolveExportsValue doesn't handle the "types" condition — it will incorrectly resolve .d.ts files as barrel entries for packages that list "types" before "import" (increasingly common).
  3. Synchronous FS in transformresolvePackageEntry does multiple readFileSync/existsSync calls 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).
  4. Test gap — no end-to-end transform test that verifies actual code rewriting (MagicString output). The "transform" describe block only tests buildBarrelExportMap in isolation.

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"]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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.

let pkgJson: PackageJson | null = null;

try {
const pkgJsonPath = req.resolve(path.join(packageName, "package.json"));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
const pkgJsonPath = req.resolve(path.join(packageName, "package.json"));
const pkgJsonPath = req.resolve(`${packageName}/package.json`);

if (!packages.has(importSource)) continue;

// Build or retrieve the barrel export map for this package
const barrelEntry = resolvePackageEntry(importSource, root);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

}

// Replace the original import with the optimized one(s)
s.overwrite(node.start, node.end, replacements.join(";\n"));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 statement

This 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:

Suggested change
s.overwrite(node.start, node.end, replacements.join(";\n"));
s.overwrite(node.start, node.end, replacements.join(";\n") + ";");

exportMapCache: new Map<string, BarrelExportMap>(),
subpkgOrigin: new Map<string, string>(),
};
let optimizedPackages: Set<string> | null = null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +231 to +261
* 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.
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

"@mui/icons-material",
"recharts",
"react-use",
"effect",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

* 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[] = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 20, 2026

Review posted successfully. Here's a summary of the feedback on PR #138:

Key findings:

  1. path.join breaks scoped packages on Windows (index.ts:440) — path.join("@radix-ui/react-slot", "package.json") produces backslashes on Windows, which require.resolve() won't handle. Suggested using template literal concatenation instead.

  2. resolvePackageEntry called repeatedly with no result caching (index.ts:3859) — The barrel export map is cached, but the entry path resolution itself (which does require.resolve, readFileSync, existsSync, and directory walking) is repeated for every file that imports from the same barrel package. Suggested adding an entryPathCache Map.

  3. Lazy optimizedPackages Set never invalidated (index.ts:3787) — Since nextConfig is assigned in the config hook, the lazy init creates a timing dependency. Suggested initializing eagerly in buildStart instead.

  4. Replacement omits trailing semicolons (index.ts:3953) — The join uses ";\n" between statements but the final statement has none. Minor ASI reliance; suggested appending ";".

  5. Test describe block named "transform" doesn't test the transform (optimize-imports.test.ts:230) — All tests in this block call _buildBarrelExportMap directly rather than exercising the actual Vite transform handler, MagicString rewriting, or import replacement logic. The core value proposition (actual import rewriting) has no test coverage.

  6. 30-line comment block in tests should be condensed to 3-4 lines.

Also noted positively: the "effect" false-positive fix and "node" export condition from the previous review were both addressed. The default package list now matches Next.js. Noted that auto-detection of unlisted barrel packages (which Next.js does) could be a follow-up.

github run

@james-elicx
Copy link
Collaborator

I'll address the bonk comments once I'm back from lunch.

…ath fix, trailing semicolons, fixture-based transform tests
james-elicx

This comment was marked as spam.

…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.
@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

if (isNamespace) {
// Each namespace import gets its own statement
for (const { local } of locals) {
replacements.push(`import * as ${local} from ${JSON.stringify(source)}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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).

return local;
});
replacements.push(
`import { ${importSpecs.join(", ")} } from ${JSON.stringify(source)}`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
`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).

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"]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

} else {
// Group named imports from the same source
const importSpecs = locals.map(({ local, originalName }) => {
if (originalName && originalName !== local) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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"`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

// Use quoted forms to avoid false positives (e.g. "effect" in "useEffect").
const packages = optimizedPackages;
let hasBarrelImport = false;
for (const pkg of packages) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines 265 to 590
/**
* 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;
}

/**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Reduce index.ts cognitive load
  2. Make the barrel optimization independently testable without importing all of vinext
  3. Keep the exported test helper (_buildBarrelExportMap) cleaner

Not blocking for this PR, but worth considering as a follow-up.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 20, 2026

Review posted successfully. Here's what I flagged on PR #138:

Critical finding

Relative source paths resolve against the wrong directory. When barrel entry files use relative re-exports (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 user's file (app/page.tsx/app/chunk.js), not the barrel package (/node_modules/lodash-es/chunk.js).

This affects the majority of packages in DEFAULT_OPTIMIZE_PACKAGESlucide-react, lodash-es, antd, rxjs, @heroicons/react/*, @tabler/icons-react, etc. Only radix-ui (which uses absolute package specifiers like @radix-ui/react-slot) works correctly.

The existing tests don't catch this because they verify string output but never run Vite's resolver on the result.

Other feedback

  • "module" export condition is a bundler convention, not Node.js — low risk but noted
  • export { default as X } produces import { default as X } instead of idiomatic import X from
  • The test at line 323 asserts the broken relative path output as correct
  • The ~330 lines of barrel optimization helpers could be extracted to a separate file
  • Minor perf note on pre-building quoted package strings for the quick-check loop

github run

…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.
@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Synchronous readFileSync in the transform handler blocks the event loopreadFileSafe (line 627) does fs.readFileSync inside the transform handler. 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 like antd with 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 making buildBarrelExportMap async (reading files with fs.promises.readFile) and making the transform handler async. 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.

  2. import * as Pkg from "barrel" has no test coverage — The ImportNamespaceSpecifier case at line 711-713 correctly sets allResolved = false, but there's no test that verifies import * as R from "radix-ui" is left unchanged. This is a one-line test addition.

  3. resolvePackageEntry creates a new createRequire on every call — Lines 204 and 275 each create a new createRequire instance. Since resolvePackageEntry is called per barrel package (cached via entryPathCache), this is at most ~80 calls for the default list. Not a real issue, just noting.

  4. readFileSafe closure could be hoisted further — The closure at line 627 is currently scoped to the handler function (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.

  5. 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 Plugin type assertion removal question
    • Switch statements suggestion for spec.type
    • Non-null assertion cleanup
    • _buildBarrelExportMap no 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;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(".")) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
}

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 21, 2026

Review posted successfully on PR #138. Here's a summary of what I flagged:

Key findings

  1. Synchronous readFileSync in transform handler — The barrel export map is built synchronously on the first import of each barrel package. For packages with deep re-export trees (e.g., antd), this blocks the event loop during initial resolution. Cached after first hit, so it's a one-time cost per barrel per build. Suggested making it async as a follow-up.

  2. Missing test for import * as Pkg from "barrel" — The ImportNamespaceSpecifier guard correctly bails out, but has no test coverage. Provided a concrete suggestion.

  3. james-elicx's unresolved comments — Several maintainer comments appear still open (subpath exports, type assertions, switch statements, non-null assertion cleanup). These should be addressed or explicitly deferred before merge.

  4. Minor items — Redundant type annotation in next-config.ts, the "module" export condition being a bundler convention, and the debug logging TODO for allResolved bail-out.

What's solid

All critical issues from the extensive prior review rounds have been resolved: relative path resolution against barrel dir, mixed namespace/named grouping via ::${isNamespace}, cache invalidation in buildStart, "effect" false-positive fix, "react-server" condition with environment-aware keying, entryPathCache, Windows path handling, trailing semicolons, and comprehensive end-to-end transform tests with real tmp fixtures. The satisfies Plugin usage is correct.

github run

…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)
@james-elicx
Copy link
Collaborator

/bigbonk review

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 21, 2026

APIError: This model does not support assistant message prefill. The conversation must end with a user message.

github run

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 21, 2026

@james-elicx Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@james-elicx
Copy link
Collaborator

/bigbonk re-review the pr

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. import type { Plugin } from "vite" in source file — This is consistent with other plugins in src/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. ✅

  2. 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.

  3. satisfies Plugin — Already addressed. ✅

  4. 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 ?? []) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
} 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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 21, 2026

Review posted successfully. Here's a summary of what I flagged on PR #138:

Key findings

  1. Redundant type annotation in next-config.ts (line 509) — : string[] is inferred from the filter predicate; provided a concrete suggestion to remove it.

  2. originalName && truthy guard (optimize-imports.ts:780) — Should be originalName !== undefined for precision, since empty string is falsy. Provided a suggestion.

  3. Sync readFileSync in transform handler — One-time-per-barrel blocking cost is acceptable for v1, but noted as a follow-up to make async for large barrel packages like antd.

  4. Debug logging TODO (optimize-imports.ts:721) — When allResolved bails, there's no diagnostic signal. Recommended addressing as a fast follow-up.

  5. Minor items — Magic number 10 for walk-up depth could be a named constant, astName error message could include node context, wildcard extension candidates are thorough but missing .cjs/.cts (fine for ESM barrels).

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.

github run

@james-elicx
Copy link
Collaborator

/bigbonk review the DIFF / changes in this pr. ignore other peoples review comments when you review the pr.

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 infers string[] from the filter predicate. Trivial cleanup.
  • Subpath exports (line 300 comment) — only exports["."] is checked. The code has a good NOTE comment 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)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the magic number 10 would be clearer as a named constant.

Suggested change
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
} 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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 21, 2026

Review posted successfully. Here's a summary of what I flagged on PR #138:

Key findings

  1. registeredBarrels dedup is not environment-scoped (optimize-imports.ts:732) — When RSC processes a barrel first and registers sub-packages under the "rsc" origin map, SSR skips because the same barrelEntry is already in registeredBarrels, leaving SSR's origin map empty. The resolveId fallback compensates by checking the other environment's map, but this is a coupling that could silently break if the fallback logic changes. Suggested scoping the key to ${envKey}:${barrelEntry}.

  2. Redundant type annotation in next-config.ts (line 509) — james-elicx flagged this; TypeScript infers string[] from the filter predicate. Provided a concrete suggestion.

  3. Minor items — magic number 10 for walk-up depth could be a named constant, originalName && vs originalName !== undefined for precision, astName null returns could benefit from debug logging context, and the wildcard candidate list (18 entries) creates many speculative file reads per barrel (acceptable one-time cost, worth noting for future profiling).

What's solid

All critical issues from the extensive prior review rounds are resolved: relative path resolution via normalizeSource, mixed namespace/named grouping via ::${isNamespace}, cache invalidation in buildStart, "effect" false-positive fix, "react-server" condition with environment-aware keying, entryPathCache, Windows path handling, trailing semicolons, and comprehensive end-to-end transform tests with real tmp fixtures. The satisfies Plugin usage is correct. The namespace import test gap is now covered.

github run

…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.
@james-elicx james-elicx dismissed their stale review March 21, 2026 14:30

no longer applicable

@james-elicx
Copy link
Collaborator

/bigbonk review the DIFF of this pr

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. resolvePackageInfo creates a createRequire per call, and resolvePackageEntry creates another one — Both are async and called per barrel package. resolvePackageInfo creates one at line 245, then if that resolves, resolvePackageEntry creates a second one at line 318. These share the same projectRoot and could share a single createRequire instance. More importantly, all these functions use async fs.readFile but call synchronous createRequire + req.resolve which does synchronous filesystem probing. The async/sync mix is fine for correctness but worth noting — the async in these functions only matters for the fs.readFile calls.

  2. The "module" export condition in resolveExportsValue resolves 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.

  3. Two open items from james-elicx's review still appear unresolved: the subpath exports suggestion (iterating all subpath exports, not just exports["."]) and the as Pluginsatisfies Plugin change. The latter was actually addressed (the code uses satisfies Plugin now — good), but the subpath exports item deserves at least a tracking comment. It's documented as a NOTE comment 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"));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a second createRequire instance for the same projectRootresolvePackageInfo 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++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic number 10 would be clearer as a named constant:

Suggested change
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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
} else if (originalName !== undefined && originalName !== local) {
} else if (originalName !== undefined && originalName !== local) {

}
}
}
// Non-relative wildcard re-exports (e.g. `export * from "other-pkg"`) are
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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"];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 getNextConfigoptimizePackageImports, decoupling from the default list entirely. Not worth changing now — the comment is sufficient.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 21, 2026

Review posted successfully. Here's a summary of what I flagged on PR #138:

Key findings

  1. Duplicate createRequire instances (lines 245, 318) — resolvePackageInfo and resolvePackageEntry each create their own createRequire for the same projectRoot. Could share one instance. Minor perf note since entryPathCache means these are only hit once per barrel per build.

  2. "module" export condition ordering (line 214) — It's a bundler convention, not a Node.js condition. Placing it after "default" would be more correct for server environments. Low risk since packages with "module" almost always have "import" too. Non-blocking.

  3. Maintainer review itemssatisfies Plugin is addressed (good). Subpath exports limitation is documented at line 301 with a NOTE comment, which is sufficient.

Non-blocking inline comments

  • Magic number 10 for walk-up depth could be a named constant (line 259)
  • subpkgOrigin first-wins dedup assumption should document why it's safe (line 756)
  • originalName && vs originalName !== undefined for precision (line 887)
  • astName could add debug logging for unexpected node shapes (line 55)
  • Positive callouts on the nested wildcard test (line 330), RSC/SSR environment-scoping regression test (line 752), and the overall safe fallback design

What's solid

All 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 DEBUG-gated diagnostic logging is a nice touch for developer experience.

github run

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.
@james-elicx
Copy link
Collaborator

/bigbonk review the DIFF in this pr

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. resolveId cross-environment fallback has a subtle race with environment-scoped registeredBarrels — The scoping fix is correct, but the resolveId fallback that checks the other environment's map creates a tight coupling that deserves documentation.
  2. resolvePackageEntry creates redundant createRequire instances — Minor perf, but easy to fix.
  3. readFileSafe is async but the transform handler calls await in a tight loop — Correct but worth noting as a future perf improvement target.
  4. Several minor itemsoriginalName && 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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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"));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a second createRequire instance for the same projectRootresolvePackageInfo 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++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
} else if (originalName !== undefined && originalName !== local) {
} else if (originalName !== undefined && originalName !== local) {

!entry.source.startsWith("/") &&
!entry.source.startsWith(".") &&
!envOriginMap.has(entry.source)
) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
) {
!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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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"];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
// 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).

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 21, 2026

Review posted successfully. Here's a summary of what I flagged on PR #138:

Key findings

  1. resolveId cross-environment fallback coupling (line 681) — The bidirectional fallback between RSC and SSR subpkgOrigin maps is pragmatic but creates an implicit coupling. Suggested adding a comment explaining when the fallback fires and why it's safe.

  2. Redundant createRequire instances (line 328) — resolvePackageEntry creates a second createRequire for the same projectRoot when the exports/module/main paths don't yield a result, despite resolvePackageInfo already having created one. Minor perf, non-blocking.

  3. Magic number for walk-up depth (line 269) — The hardcoded 10 in the package root walk-up loop would be clearer as a named constant (MAX_PKG_ROOT_WALK_DEPTH).

  4. originalName && precision (line 914) — Conflates undefined with empty string; originalName !== undefined is more precise.

  5. subpkgOrigin first-wins dedup (line 782) — The assumption is correct but deserves an inline comment explaining why it's safe (same resolved path regardless of which barrel's context is used).

  6. astName silent null return (line 55) — Adding debug logging for unexpected node shapes would help diagnose future edge cases.

  7. "module" export condition ordering (line 224) — It's a bundler convention, not a Node.js condition. Low risk but noted.

  8. Test fixture naming (test line 564) — lucide-react barrel with @radix-ui sub-packages is initially confusing, though documented.

  9. Non-relative wildcard skip comment (line 490) — The user-visible consequence (entire import statement falls back) deserves a note alongside the implementation comment.

All issues are non-blocking. The implementation is solid, well-tested, and addresses all critical bugs from prior review rounds.

github run

@james-elicx
Copy link
Collaborator

james-elicx commented Mar 21, 2026

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.

@gentritbiba
Copy link
Contributor Author

Is bonk ever satisfied 😅

@james-elicx james-elicx enabled auto-merge (squash) March 21, 2026 15:16
@james-elicx james-elicx merged commit fa03a06 into cloudflare:main Mar 21, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: optimize barrel imports for RSC-incompatible packages shadcn fail : React.createContext is not a function

6 participants