Skip to content

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

Open
gentritbiba wants to merge 18 commits intocloudflare:mainfrom
gentritbiba:feat/optimize-package-imports
Open

feat: optimize barrel imports for RSC-incompatible packages#138
gentritbiba wants to merge 18 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: ab8f106

@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.
@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 20, 2026

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

Key findings

  1. Test imports use "vitest" and "vite" instead of "vite-plus/test" and "vite-plus" — 61 of 64 test files in the repo follow the Vite+ convention. The test file should be updated for consistency. Two concrete suggestions provided.

  2. Absolute paths for relative barrel sources may need extension resolution — When a barrel re-exports via ./chunk (no extension), the transform emits an absolute extensionless path. Vite's resolver should handle this, but there's no test that creates the actual target file to verify the emitted paths resolve correctly.

  3. export * from wildcard silently disables optimization — No debug-level log when allResolved bails out because an imported name comes through a wildcard export *. This makes it hard for users to diagnose why their barrel isn't being optimized.

  4. Minor itemsoriginalName && vs originalName !== undefined guard (cosmetic), subpkgOrigin dedup comment could be more explicit about nested node_modules scenarios, "react-server" TODO should note it should go before "node" in priority.

What's solid

All critical issues from the previous 5+ review rounds have been addressed: relative path resolution, mixed namespace/named grouping, cache invalidation, "effect" false-positive fix, "node" export condition, entryPathCache, Windows path.join fix, trailing semicolons, and end-to-end transform tests. The implementation is well-structured and the code is clean.

github run

- Fix test imports: vitest → vite-plus/test, Plugin from vite → vite-plus,
  following the convention used by 82 of 85 test files in this repo
- Add TODO comment for extensionless absolute paths produced when barrel
  sources lack file extensions (Vite resolves these correctly in practice)
- Expand subpkgOrigin first-wins comment to explicitly note the nested
  node_modules edge case and why it is out of scope for the default list
@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, painful problem — RSC crashes from barrel re-exports that eagerly evaluate React.createContext() at module top-level. The implementation is clean, well-architected, and has been thoroughly refined through multiple review rounds. All previously-identified critical issues (relative path resolution, mixed namespace/named grouping, cache invalidation, "effect" false positives, "node" export condition, Windows path.join for scoped packages) have been addressed.

The code is cleanly extracted into its own file, caches are properly scoped and cleared in buildStart, and the test suite covers both unit-level buildBarrelExportMap tests and end-to-end transform tests with real tmp fixtures. The resolveId hook for pnpm strict hoisting is a thoughtful touch that many implementations miss.

I have a few minor observations below — none are blocking.

Non-blocking notes

  1. ExportNamedDeclaration with declaration but no source — barrels that define and export inline (e.g. export const VERSION = "1.0") are correctly handled: they fall through without being added to the export map (no specifiers and no source). If user code tries to import VERSION, the allResolved guard will correctly skip the rewrite. Good.

  2. Extensionless absolute paths from relative barrel sources — the TODO comment at line 530 acknowledges this. Vite's resolver handles it, so this works. Just confirming there's no gap.

  3. export * from (wildcard) silently bails — when allResolved is false because a name comes through a wildcard, there's no diagnostic signal. For a future iteration, a debug-level log would help users understand why optimization isn't kicking in for a particular import.

  4. resolveExportsValue and "react-server" condition — the TODO at line 160 is well-placed. When auto-detection of barrel packages is added, "react-server" should go before "node" in the priority list since RSC environments may expose a narrower barrel with fewer client-only re-exports.

}
}
// export * from "sub-pkg" — not resolved eagerly (left unchanged at transform time)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: This comment sits after the else if chain but inside the for loop body. At first glance it reads like dead code. Consider moving it into an explicit else if branch with a comment, making it clearer this is an intentional no-op:

} else if (node.type === "ExportAllDeclaration" && !node.exported) {
  // export * from "sub-pkg" — wildcard re-exports are intentionally
  // not resolved. If user code imports a name that only comes through
  // a wildcard, the allResolved guard in the transform will leave the
  // import unchanged.
}

This makes the design decision explicit in the code rather than just a trailing comment. Very minor — not blocking.

if (spec.type === "ImportSpecifier") {
const imported = astName(spec.imported!);
specifiers.push({ local: spec.local.name, imported });
if (!exportMap.has(imported)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

When allResolved bails out due to a missing name (likely from a wildcard export *), there's no signal to the developer. For large barrels where most names are in the map but one isn't, this silently disables optimization for the entire import statement.

Consider adding a debug-level log in a follow-up:

if (!allResolved) {
  // TODO: add debug logging so users can diagnose why optimization was skipped
  // e.g. "Skipping barrel optimization for 'lodash-es': 'debounce' not found in export map (may come from export *)"
}

Non-blocking — the current behavior (leaving the import unchanged) is correct and safe.

for (const { local, originalName } of locals) {
if (originalName === "default") {
defaultLocals.push(local);
} else if (originalName !== undefined && originalName !== local) {
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 originalName !== undefined guard would be slightly more precise than originalName &&, since an empty string is falsy but technically a valid (though unrealistic) AST name. The current code is fine in practice — just noting for consistency with the strict typing elsewhere in this file.

if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});

it("rewrites namespace re-export: import { Slot } from 'pkg' → import * as Slot from 'sub-pkg'", async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor readability note: the test comment at lines 301-303 explains this well. The choice of lucide-react as the package name with @radix-ui sub-packages is initially confusing but the comment clarifies it. Good.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 20, 2026

Review submitted successfully. Here's a summary of my review on PR #138:

Approved — the barrel import optimization is well-implemented and ready to merge.

What I reviewed

  • packages/vinext/src/plugins/optimize-imports.ts (606 lines) — the core plugin implementation
  • tests/optimize-imports.test.ts (490 lines) — unit and end-to-end transform tests
  • packages/vinext/src/index.ts — plugin registration and export
  • packages/vinext/src/config/next-config.tsoptimizePackageImports config plumbing
  • tests/next-config.test.ts — fixture update for new config field
  • README.md — documentation update

Key strengths

  • Clean extraction into its own file under plugins/
  • All critical issues from 6+ prior review rounds addressed (relative path resolution, namespace/named grouping, cache invalidation, "effect" false positives, "node" export condition, Windows path handling)
  • Proper cache scoping with buildStart clearing
  • resolveId hook for pnpm strict hoisting
  • Thorough test coverage with real tmp filesystem fixtures

Non-blocking inline comments

  1. The export * from wildcard no-op could be made more explicit with its own else if branch
  2. Debug logging when allResolved bails would help users diagnose optimization gaps
  3. Minor: originalName !== undefined vs originalName && for precision
  4. Test fixture naming is initially confusing but adequately documented

github run

@james-elicx
Copy link
Collaborator

Skimmed through the diff a bit manually and have some extra thoughts. I'll address them tomorrow.

- Support wildcard re-exports (export * from './sub') by recursively
  parsing sub-modules and merging their exports into the barrel map
- Add circular-reference guard to prevent infinite loops in recursive
  export map building
- Refactor buildBarrelExportMap into buildExportMapFromFile helper
  with shared recursive logic
- Use switch statements for spec.type handling instead of if-else chains
- Remove non-null assertions and type assertions; use optional chaining
  and typeof guards throughout
- Extract resolvePackageInfo helper to separate concerns in
  resolvePackageEntry
- Tests now import directly from optimize-imports.ts instead of via
  the full vinext() plugin array
- Remove _buildBarrelExportMap re-export from index.ts (no longer needed
  since the plugin has its own file)
- Fix type assertion for optimizePackageImports in next-config.ts using
  a proper type-guard filter
- Apply README nit: 'is always optimized' → 'are always optimized'
- Add tests for wildcard export resolution, no-overwrite semantics,
  and circular re-export safety
@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 feature that addresses a real pain point — RSC crashes from barrel re-exports that eagerly call React.createContext(). The architecture is well-considered: AST-based export map building, recursive wildcard resolution, pnpm strict mode handling via resolveId + subpkgOrigin, and proper cache scoping with buildStart clearing. The test coverage is thorough, especially the end-to-end transform tests with real tmp fixtures.

The previous review rounds have done a great job catching and fixing the critical issues. I'm focusing on what remains unresolved and a few new things I noticed.

Issues to address

  1. Wildcard re-export source paths are relative to the wrong file — When export * from "./sub" is recursively resolved, entries from the sub-module retain their original relative source values (e.g., "./button" in components.js). These are relative to the sub-module's directory, but the transform resolves them against the barrel entry's directory (barrelDir). This works when all files are in the same directory (as in the current tests), but breaks for nested structures where components/index.js exports { Button } from "./Button" — the source "./Button" would need to resolve against components/, not the barrel root. The resolvePackageEntry returns the barrel entry, not the sub-module. Severity: medium — most real barrels keep re-exports flat, but antd and others have nested structures.

  2. "react-server" condition should be addressed before shipping (per james-elicx's comment) — The TODO at line 160-163 acknowledges this, but given this plugin specifically targets RSC environments, resolving the wrong barrel entry could silently break packages that expose a narrower RSC-safe barrel via "react-server". This should go before "node" in the priority list.

  3. as Plugin type assertion at line 725 — james-elicx asked about removing this. The transform: { filter, handler } object form may not match the Plugin type directly, but rather than asserting, consider using satisfies Plugin or restructuring to avoid the cast.

  4. Type assertion in next-config.ts — james-elicx asked if the type assertion in the config resolution is necessary. The rawOptimize extraction with Array.isArray + .filter should be sufficient without needing the : string[] annotation since TypeScript can infer it from the filter predicate.

Minor / non-blocking

See inline comments for additional details.

* TODO: The "react-server" condition is increasingly common — packages like `react`,
* `react-dom`, and `next-intl` use it to expose RSC-compatible entry points. Since this
* plugin targets RSC/SSR environments, "react-server" should be added to the preferred
* condition list (before "node") in a future pass.
Copy link
Contributor

Choose a reason for hiding this comment

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

Agreeing with james-elicx here — "react-server" should be supported before this ships, not deferred as a TODO. Since this plugin explicitly targets RSC/SSR environments, packages that expose a different (narrower, RSC-safe) barrel under "react-server" would resolve the wrong entry file without it.

The fix is straightforward — add "react-server" before "node" in the condition list:

Suggested change
* condition list (before "node") in a future pass.
* condition list (before "node") in a future pass.

Actually, replacing the TODO with the implementation:

for (const key of ["react-server", "node", "import", "module", "default"]) {

Since "react-server" is only set by packages that know about RSC, any package without it will simply skip the condition and fall through to "node" / "import" as before.

};
},
},
} as Plugin;
Copy link
Contributor

Choose a reason for hiding this comment

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

Per james-elicx's comment — this as Plugin cast exists because the transform: { filter, handler } object form doesn't match the standard Plugin type definition.

Could this be addressed by typing the return as Plugin and letting TypeScript complain about the specific incompatibility, then fixing it properly? Or using satisfies Plugin? The as Plugin hides any future type drift.

If the Vite types don't support the object-form transform natively yet, at minimum add a comment explaining why the assertion is needed:

  // `as Plugin` needed because Vite's Plugin type doesn't yet model
  // the object-form `transform: { filter, handler }` pattern.
  } as Plugin;

* React.createContext() in RSC environments where createContext doesn't exist.
*/

import type { Plugin } from "vite";
Copy link
Contributor

Choose a reason for hiding this comment

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

This project uses Vite+ — source files in the codebase should ideally import from "vite-plus" rather than "vite" directly. Looking at other plugins in packages/vinext/src/plugins/, I see they import from "vite" as well (e.g., client-reference-dedup.ts), so this is consistent with the existing pattern.

Just noting for awareness — the "vite-plus" convention seems stricter for test files (which the test file already follows correctly).

// extensionless absolute paths (e.g. `/node_modules/lodash-es/chunk`).
// Vite's resolver handles extension resolution on these paths, so this
// works in practice, but a future improvement would be to resolve the
// extension here (or verify via the barrel AST that the file exists).
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 the TODO here about extensionless absolute paths. This is worth addressing: when the barrel has export { X } from "./chunk" (no extension), the emitted import becomes something like /node_modules/lodash-es/chunk — an extensionless absolute path.

Vite's resolver should handle this, but it's an unusual path through the resolver (most imports are bare specifiers or relative). A defensive approach would be to check if the source has an extension and, if not, probe for common extensions using the readFile callback that's already available. Not blocking, but reduces reliance on Vite's resolver behavior for an edge case.

async resolveId(source) {
// Only apply on server environments (RSC/SSR). The client uses Vite's
// dep optimizer which handles barrel CJS→ESM conversion correctly.
if ((this as { environment?: { name?: string } }).environment?.name === "client") return;
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 statements on spec.type here instead of the nested if chain. While the current code is readable, switch would be more idiomatic for type-discriminated unions and matches the pattern already used in buildExportMapFromFile (line 315). Consistency within the same file would help.

Also applies to the specifier type check around line 602.

@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. Wildcard re-export source paths resolve against the wrong directory (optimize-imports.ts:359) — When export * from "./sub" is recursively resolved, entries from sub-modules retain their relative source paths. The transform then resolves these against the barrel entry's directory (barrelDir), not the sub-module's directory. This works for flat structures (all current tests) but breaks for nested package layouts like antd where sub-modules are in subdirectories. The fix is to resolve relative sources to absolute paths during buildExportMapFromFile itself.

  2. "react-server" condition should be implemented, not deferred (optimize-imports.ts:163) — Agreeing with james-elicx that this TODO should be addressed before shipping. The plugin targets RSC/SSR environments, so packages exposing an RSC-safe barrel via "react-server" would resolve the wrong entry without it.

  3. as Plugin type assertion (optimize-imports.ts:725) — Should be documented or replaced with satisfies Plugin.

  4. Double AST parse (optimize-imports.ts:444) — The barrel entry is parsed once for validation and again inside buildExportMapFromFile, doubling the work.

  5. Test gap for nested wildcard re-exports (optimize-imports.test.ts:573) — The wildcard test has all files in the same directory, so it doesn't catch the relative-path resolution bug.

  6. Several minor items: switch statement consistency (per james-elicx), extensionless absolute path TODO, subpath exports limitation documentation.

github run

- Resolve relative sources to absolute paths inside buildExportMapFromFile
  so nested wildcard re-exports (e.g. antd-style components/) resolve against
  the correct sub-module directory instead of the barrel root
- Add react-server condition to resolveExportsValue (before node/import) for
  RSC-compatible entry points in packages like react and react-dom
- Add NOTE comment in resolvePackageEntry documenting that only exports["."]
  is checked, not subpath exports
- Eliminate double readFile call in buildBarrelExportMap by passing the
  already-read content to buildExportMapFromFile via new initialContent param
- Update subpkgOrigin guard to check for absolute paths (not startsWith ".")
  since relative sources are now stored as absolute paths in the export map
- Add test for nested subdirectory wildcard path resolution correctness
- Update existing unit tests to expect absolute paths in export map entries
…ronment

SSR renders with the full React runtime and must not resolve react-server
export condition entries. Thread preferReactServer (env.name === 'rsc')
through resolveExportsValue and resolvePackageEntry, and key entryPathCache
by environment prefix to keep RSC and SSR barrel entries separate.
@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 addresses a real pain point — RSC crashes from barrel re-exports that eagerly evaluate React.createContext(). The architecture is sound: cleanly extracted into its own plugin file, AST-based export map with recursive wildcard resolution, pnpm strict hoisting handled via resolveId + subpkgOrigin, proper cache scoping with buildStart clearing, ::${isNamespace} composite keys, and "react-server" condition awareness. The test coverage is thorough — both unit tests for buildBarrelExportMap and end-to-end transform tests with real tmp filesystem fixtures.

All critical issues from the extensive prior review rounds have been addressed. I have a few remaining observations — one functional concern and several minor items.

Issues

  1. Double AST parse in buildBarrelExportMap — The barrel entry is parsed once for validation (line 472), then the content is passed to buildExportMapFromFile which parses it again (line 308-309). The initialContent parameter avoids a redundant readFile, but parseAst is still called twice on the same content. This doubles the CPU work for every barrel entry. See inline comment.

  2. ExportNamedDeclaration with declaration but no source and no specifiers — Barrels with inline declarations like export const VERSION = "1.0" fall through without being added to the map (correct), but if user code does import { VERSION } from "barrel", the allResolved guard will bail, silently disabling optimization for the entire import statement. This is correct behavior but there's no diagnostic signal. A debug log would help users understand why optimization isn't kicking in for a particular import.

  3. resolvePackageEntry returns OS-native paths on Windows from the require.resolve fallback (line 271) — The exports and module/main branches normalize with .split(path.sep).join("/"), but the req.resolve(packageName) fallback at line 271 only does the split after the fact. This is fine, but the path flows into cache keys and subpkgOrigin values. On Windows, cache key format could mismatch if the same path arrives via different code branches. Low risk since CI is Linux and deploy target is Workers.

Non-blocking nits

See inline comments for specifics on the double parse, the as Plugin assertion, and the test fixture naming.

// Pre-built quoted forms used for the per-file quick-check. Computed once in
// buildStart so the transform loop doesn't allocate template literals per file.
let quotedPackages: string[] = [];

Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: optimizedPackages is typed as Set<string> and initialized to an empty new Set(), but quotedPackages is typed as string[] initialized to []. They're always populated together in buildStart, so the empty initializers are just for the type checker. This is fine — just noting that if buildStart somehow doesn't run before transform (shouldn't happen in Vite's lifecycle), the transform would silently no-op rather than crash. Good defensive default.

barrelCaches.subpkgOrigin.clear();
},

async resolveId(source) {
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 accesses the environment via (this as { environment?: { name?: string } }).environment?.name, while the transform handler at line 557 uses the same pattern. This is now consistent between the two hooks (good — earlier versions had a discrepancy with EnvironmentPluginContext). Just confirming this is intentional and works at runtime.

@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. Missing directory index resolution in wildcard candidates (optimize-imports.ts:377) — The wildcard export * from "./sub" resolution tries .js, .mjs, .ts extensions but doesn't try /index.js or /index.ts. Real packages like antd use directory-style sub-modules (export * from "./components" where components/index.js exists). This is a functional gap that would cause silent optimization failures for directory barrel structures. Provided a concrete suggestion.

  2. Double AST parse (optimize-imports.ts:472) — The barrel entry is parsed once for validation, then parsed again inside buildExportMapFromFile. The initialContent parameter avoids a redundant file read but not a redundant parse. Low-hanging optimization fruit.

  3. No diagnostic signal when allResolved bails (optimize-imports.ts:672) — When a name isn't found in the export map (e.g. from export * wildcard or inline export const), the entire import silently stays unchanged. A debug log would help users diagnose optimization gaps.

  4. Minor itemsas Plugin type assertion should be documented or changed to satisfies, redundant : string[] annotation in next-config.ts, confusing test fixture naming (lucide-react barrel with radix sub-packages).

What's solid

All critical issues from 8+ prior review rounds are resolved: relative path resolution, mixed namespace/named grouping, cache invalidation, "effect" false positives, "node" and "react-server" export conditions, entryPathCache, Windows path.join fix, trailing semicolons, and end-to-end transform tests. The implementation is well-structured and ready for merge after addressing the directory index resolution gap.

github run

…ation, test names, wildcard dir test

- Add TODO comment on allResolved bail-out suggesting debug logging
- Remove redundant ': string[]' annotation on optimizePackageImports (inferred)
- Rename namespace re-export test to use generic X/barrel names instead of Slot/lucide-react
- Update syntax-error test to match new empty-map-on-parse-error behavior (not null)
- Add test for directory-style 'export * from "./components"' where components/index.js exists
@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-structured feature solving a real pain point — RSC crashes from barrel re-exports that eagerly evaluate React.createContext() at module scope. The architecture is sound: cleanly extracted plugin file, AST-based export map with recursive wildcard resolution, pnpm strict hoisting handled via resolveId + subpkgOrigin, proper cache scoping with buildStart clearing, ::${isNamespace} composite keys, "react-server" condition awareness, and pre-built quoted strings for the quick-check. The test suite is thorough with both unit and end-to-end transform tests using real tmp fixtures.

The extensive prior review rounds have addressed the major issues (relative path resolution, namespace/named grouping, cache invalidation, "effect" false-positive, "node" export condition, Windows path.join, trailing semicolons). I have a few remaining observations — one functional gap and several minor items.

Key findings

  1. Wildcard extension candidates missing .mjs for index files and .tsx — The candidate list at line 384 tries [subPath, .js, .mjs, .ts, /index.js, /index.ts] but is missing /index.mjs and .tsx variants. Packages using export * from "./components" where the index file is components/index.mjs (ESM-first packages) or components/index.tsx (TypeScript-first packages like internal libraries) would silently fail to resolve. Given the filter at line 565 already includes .mjs files, this is an internal inconsistency.

  2. readFile called twice on successful wildcard candidate just to check existence — At line 395, readFile(candidate) !== null reads the file to check if it exists, then buildExportMapFromFile reads it again (no initialContent passed for recursive calls). The initialContent optimization is only applied for the entry file in buildBarrelExportMap. For barrels with multiple wildcard levels, each sub-module is read twice.

  3. resolveId hook runs on all environments including "ssr" but never on "client" — The guard at line 551 only skips "client", meaning this hook fires for every module resolution in both RSC and SSR. Since subpkgOrigin typically has very few entries, the Map lookup is cheap, but the hook registration still participates in Vite's resolve chain for every specifier. This is fine — just documenting the design.

  4. Test file has a lingering afterEach import but only one describe block uses itafterEach is imported at line 7 but only used in the transform describe block. Minor.

Overall this is solid work. The issues above are non-blocking. The implementation handles the motivating use case (radix-ui, lucide-react, etc.) correctly, and the safe fallback behavior (leaving imports unchanged when resolution fails) means the worst case is no optimization, not a crash.

Comment on lines +439 to +453
it("rewrites namespace re-export: import { X } from 'barrel' → import * as X from 'sub-pkg'", async () => {
// lucide-react is in DEFAULT_OPTIMIZE_PACKAGES. The barrel contents below use
// @radix-ui sub-packages intentionally — this tests the namespace rewrite path
// with an arbitrary barrel; the package name just needs to be in the optimized list.
const call = await setupTransform(
"lucide-react",
`export * as Slot from "@radix-ui/react-slot";\nexport * as Dialog from "@radix-ui/react-dialog";`,
);
const code = `import { Slot, Dialog } from "lucide-react";\nconst x = Slot;`;
const result = call(code, "/app/component.tsx");
expect(result).not.toBeNull();
expect(result!.code).toContain(`import * as Slot from "@radix-ui/react-slot"`);
expect(result!.code).toContain(`import * as Dialog from "@radix-ui/react-dialog"`);
expect(result!.code).not.toContain(`from "lucide-react"`);
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Previous reviews flagged the confusing test fixture naming (lucide-react barrel with radix sub-packages). The comment at line 441 helps, but consider using a more self-documenting fixture. For example, you could add a custom package name to optimizePackageImports via the getNextConfig callback instead of repurposing lucide-react:

const plugin = createOptimizeImportsPlugin(
  () => ({ optimizePackageImports: ["test-barrel"] } as any),
  () => tmpDir,
);

Then use test-barrel as the package name. This decouples the test from the default list and makes the fixture intent clear.

Non-blocking — the test is correct as-is.

* React.createContext() in RSC environments where createContext doesn't exist.
*/

import type { Plugin } from "vite";
Copy link
Contributor

Choose a reason for hiding this comment

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

Confirmed: other plugins in packages/vinext/src/plugins/ (async-hooks-stub.ts, client-reference-dedup.ts) also import from "vite" directly. This is consistent for source files — the "vite-plus" convention applies to test files. No action needed.

if (!allResolved) break;
}

// TODO: consider debug logging which specifier was unresolved
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 this TODO exists. When implementing the debug logging, consider using Vite's built-in createLogger or the this.warn / this.info methods available on the plugin context, so users can control verbosity through Vite's logLevel config. Something like:

if (!allResolved) {
  const missing = specifiers.find(s => !exportMap.has(s.imported));
  // Use Vite's logger when available — debug level so it's hidden by default
  // logger.info(`[optimize-imports] Skipping barrel optimization for "${importSource}": "${missing?.imported}" not found in export map`);
  continue;
}

@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. Wildcard extension candidates incomplete (optimize-imports.ts:384-392) — Missing /index.mjs, /index.tsx, and .tsx variants. The transform filter includes .mjs files but the wildcard resolution doesn't try index.mjs for directory-style sub-modules. ESM-first packages or TypeScript source packages would silently fail to resolve.

  2. Double file read on wildcard candidates (optimize-imports.ts:395-396) — readFile(candidate) checks existence by reading content, then buildExportMapFromFile reads it again because initialContent isn't passed for recursive calls. Suggested capturing the content and threading it through.

  3. readFile closure allocated per import per file (optimize-imports.ts:621-629) — The identical fs.readFileSync closure is created inside the for loop for every barrel import. Could be hoisted to the handler scope.

  4. subpkgOrigin registration runs redundantly (optimize-imports.ts:638-653) — Every file that imports from the same barrel re-iterates the export map to register sub-packages. Suggested a registeredBarrels Set to gate this.

  5. Minor items — Confusing test fixture naming (lucide-react with radix sub-packages), unused afterEach import scope, resolveEntry callback API mismatch, and a note on the debug logging TODO.

All issues are non-blocking. The implementation is well-architected, all critical bugs from prior review rounds have been fixed, and the safe fallback behavior (leaving imports unchanged on failure) is the right design choice.

github run

…ble-read, readFile hoisting, subpkg registration guard

- Add /index.mjs, /index.tsx, and .tsx to wildcard candidate list for ESM-first and TypeScript-first packages
- Pass candidateContent to buildExportMapFromFile in wildcard loop to avoid reading each sub-module file twice
- Add comment clarifying the no-op resolveEntry callback pattern
- Hoist readFileSafe closure outside the import-declaration for-loop (one allocation per transform, not per barrel import)
- Gate subpkgOrigin registration loop with registeredBarrels Set so 50 files importing the same barrel don't each iterate the full export map
@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 pain point — RSC crashes from barrel re-exports that eagerly evaluate React.createContext() at module scope. The implementation has been through extensive review and the critical issues (relative path resolution, namespace/named grouping, cache invalidation, "effect" false-positive, "node" export condition, Windows path handling) have all been addressed. The architecture is clean: extracted into its own file, AST-based export map building with recursive wildcard resolution, pnpm strict hoisting via resolveId + subpkgOrigin, and properly scoped caches.

I'm focusing on issues that haven't been fully addressed in prior rounds, plus one structural concern.

Summary of findings

  1. resolveId doesn't guard against absolute paths from transformed barrel entries — the subpkgOrigin registration correctly filters out relative and absolute paths, but the resolveId hook calls this.resolve(source, barrelEntry) without any protection. If a future code path or edge case causes an absolute path to land in subpkgOrigin, Vite's resolver would receive it as an importer, not a specifier, leading to confusing failures. Low risk given the current guards, but a defensive check would be cheap.

  2. buildBarrelExportMap double-cachesbuildExportMapFromFile stores the result in cache at line 462, then buildBarrelExportMap stores the exact same map again at line 506. This is harmless (same key, same value) but misleading — a reader might think the two cache entries differ.

  3. resolvePackageEntry fallback path lacks Windows normalization — The req.resolve(packageName) fallback at line 271 normalizes with .split(path.sep).join("/"), which is good. But resolvePackageInfo at line 210 uses req.resolve(packageName) to get mainEntry and then walks up with path.dirname / path.join — these produce OS-native paths for pkgDir. When pkgDir flows into path.resolve(pkgDir, entryPath) at lines 260/267, the result goes through the .split(path.sep).join("/") normalization, so the final output is safe. Just confirming this chain is correct.

  4. Test gap: no test for import * as Pkg from "barrel" being correctly skipped — the ImportNamespaceSpecifier case at line 706 sets allResolved = false, which is correct (can't optimize a full namespace import). But there's no test that verifies import * as R from "radix-ui" is left unchanged. This would be a simple addition.

  5. Minor: export { X as default } in a barrel is not tested — The barrel pattern export { X as default } (indirect default re-export via import binding) maps "default" in the export map. The transform handles ImportDefaultSpecifier by looking up "default" in the map, which works. The ImportDefaultSpecifier test (line 613) covers the user-side import, but the barrel side uses import Foo from "./foo"; export { Foo as default }. There's no test for the direct export { default } from "./sub" pattern (where the barrel itself re-exports a default). The code handles it correctly (line 437 would store originalName: "default" via astName(spec.local) where local.name === "default"), but explicit test coverage would be good.

Overall this is solid work — the safe fallback design (leave imports unchanged on any failure) is the right approach, and the test coverage is thorough. The issues above are non-blocking.

if (!entryPath) return null;

const exportMapCache = cache ?? new Map<string, BarrelExportMap>();

Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: buildBarrelExportMap checks exportMapCache.get(entryPath) at line 488, but buildExportMapFromFile also checks cache.get(filePath) at line 305 with the same key. The outer check short-circuits before reading the file content, which is good for performance on cache hits. But on the first call, the content is read at line 497 and then passed as initialContent to avoid a second read — so the double-existence-check is only meaningful for subsequent calls to buildBarrelExportMap with the same entry (which would be deduplicated by entryPathCache in the transform handler anyway).

This is fine — just noting the redundancy for awareness. The layered caching works correctly.

* Uses a pre-populated barrel export map cache so no real packages need to be
* installed. Each test uses a unique fake entry path to avoid cache collisions.
*/
import { describe, it, expect, afterEach } from "vite-plus/test";
Copy link
Contributor

Choose a reason for hiding this comment

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

Good — imports from "vite-plus/test" and "vite-plus" (line 16) match the project convention. This was fixed from a prior review round.

if (!allResolved) break;
}

// TODO: consider debug logging which specifier was unresolved
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 before the feature ships widely — silent optimization failures are hard to debug. Even a single console.debug (or Vite logger when available) that names the unresolved specifier would save users significant debugging time.

For example: when a barrel uses export * from "./utils" and user code imports a name that only comes through that wildcard, the import silently stays unchanged. The user sees no error, no warning — just "my barrel optimization isn't working". A debug log here closes that gap.

Non-blocking for merge, but recommend addressing as a fast follow-up.

// Try with the path as-is first, then with common extensions.
// Include .tsx for TypeScript-first internal libraries, and /index.mjs
// for ESM-first packages where the directory index is a .mjs file.
const candidates = [
Copy link
Contributor

Choose a reason for hiding this comment

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

The extension candidate list is comprehensive — .js, .mjs, .ts, .tsx for both direct files and directory indices. This covers the vast majority of real-world packages.

One edge case to note for the future: some packages use .cjs or .cts extensions for their barrel entries (especially CJS packages with conditional exports). These aren't in the candidate list, but they're also unlikely to appear in the DEFAULT_OPTIMIZE_PACKAGES list since the plugin targets ESM barrels. Worth a TODO comment if you want to be thorough.

// barrel don't each re-iterate the full export map.
if (!registeredBarrels.has(barrelEntry)) {
registeredBarrels.add(barrelEntry);
for (const entry of exportMap.values()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

The registeredBarrels gate is a nice optimization — avoids redundantly iterating the export map on every file that imports from the same barrel. Clean pattern.

// Key on both resolved source and isNamespace: a named import and a
// namespace import from the same sub-module must produce separate
// import statements.
const key = `${resolvedSource}::${entry.isNamespace}`;
Copy link
Contributor

Choose a reason for hiding this comment

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

The ::${entry.isNamespace} composite key is the correct fix for the mixed namespace/named grouping bug identified in earlier reviews. This ensures import * as X and import { Y } from the same sub-module produce separate import statements. Good.

expect(result!.code).not.toContain(`from "./b"`);
});

it("leaves import unchanged when a specifier is not in the barrel map", async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider adding a test for import * as R from "rxjs" (namespace import of the barrel itself) to verify it's left unchanged. The ImportNamespaceSpecifier case at optimize-imports.ts:706 sets allResolved = false, but this path has no explicit test coverage.

Suggested change
it("leaves import unchanged when a specifier is not in the barrel map", async () => {
it("leaves import unchanged when a specifier is not in the barrel map", async () => {
// rxjs is in DEFAULT_OPTIMIZE_PACKAGES
const call = await setupTransform("rxjs", `export * as Slot from "@radix-ui/react-slot";`);
// "Unknown" is not exported from the barrel
const code = `import { Slot, Unknown } from "rxjs";`;
const result = call(code, "/app/page.tsx");
expect(result).toBeNull();
});
it("leaves namespace import of barrel unchanged (cannot be optimized)", async () => {
const call = await setupTransform("rxjs", `export * as Slot from "@radix-ui/react-slot";`);
const code = `import * as R from "rxjs";`;
const result = call(code, "/app/page.tsx");
expect(result).toBeNull();
});


// Resolve optimizePackageImports from experimental config
const rawOptimize = experimental?.optimizePackageImports;
const optimizePackageImports = Array.isArray(rawOptimize)
Copy link
Contributor

Choose a reason for hiding this comment

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

The type annotation on optimizePackageImports is redundant — TypeScript infers string[] from the ternary with Array.isArray + .filter((x): x is string => ...). The rawOptimize.filter(...) returns string[] thanks to the type predicate, and the [] fallback is also string[] by inference.

Not a problem, just unnecessary verbosity. james-elicx flagged this too.

Suggested change
const optimizePackageImports = Array.isArray(rawOptimize)
const optimizePackageImports = Array.isArray(rawOptimize)
? rawOptimize.filter((x): x is string => typeof x === "string")
: [];

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 21, 2026

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

Key findings

  1. buildBarrelExportMap double-caches (optimize-imports.ts:506) — The export map is stored in the cache by buildExportMapFromFile at line 462, then stored again with the same key/value at line 506. Harmless but misleading — suggested removing the redundant set.

  2. Test gap: namespace import of barrel not testedimport * as R from "radix-ui" correctly triggers the allResolved = false guard (line 706), but there's no test verifying this path. Suggested adding one.

  3. Debug logging TODO (optimize-imports.ts:714) — When allResolved bails because a specifier isn't in the export map (e.g., from export * wildcard), there's no diagnostic signal. Recommended addressing as a fast follow-up.

  4. Minor: redundant type annotation in next-config.ts — TypeScript infers string[] from the filter predicate; the explicit annotation is unnecessary.

  5. Confirmed correct: Windows path normalization chain, registeredBarrels dedup optimization, ::${isNamespace} composite key fix, and import conventions matching project patterns.

All issues are non-blocking. The implementation is well-structured and the safe fallback design (leave imports unchanged on failure) is the right approach.

github run

…tx type alias

- Remove the redundant exportMapCache.set() in buildBarrelExportMap — buildExportMapFromFile
  already stores the result under the same key; the extra set was a no-op but misleading
- Extract PluginCtx type alias for the environment cast so all three hook sites share one
  definition and the verbose inline cast doesn't repeat across resolveId and transform
@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)
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