Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 45 additions & 24 deletions packages/vinext/src/plugins/optimize-imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ type BarrelExportEntry = {
source: string;
isNamespace: boolean;
originalName?: string;
/**
* The outermost module on the way to `source` that RSC replaces with
* references or wrappers because of its directive. RSC must import the name
* from it instead.
*/
rscBoundary?: string;
/**
* The same for SSR, where only "use server" and "use cache" modules are
* replaced with references.
*/
ssrBoundary?: string;
};

type BarrelExportMap = Map<string, BarrelExportEntry>;
Expand Down Expand Up @@ -398,7 +409,7 @@ async function buildExportMapFromFile(
}

const exportMap: BarrelExportMap = new Map();
const isBoundary = findBoundaryDirective(ast) !== null;
const boundaryDirective = findBoundaryDirective(ast);

// Track import bindings: local name → { source, isNamespace, originalName }
const importBindings = new Map<
Expand Down Expand Up @@ -614,11 +625,17 @@ async function buildExportMapFromFile(
}
}

if (isBoundary) {
// Every name a boundary module exports must be imported from the module
// itself, including names reached through `export *` into or out of it.
for (const name of exportMap.keys()) {
exportMap.set(name, { source: filePath, isNamespace: false, originalName: name });
if (boundaryDirective) {
// Record this module as the boundary for every name it exports, including
// names reached through `export *` into or out of it. Outer modules
// override inner ones, since an importer reaches them first.
const isSsrBoundary = boundaryDirective !== "use client";
for (const [name, entry] of exportMap) {
exportMap.set(name, {
...entry,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Preserving the leaf source here means the registration loop at lines 903-913 now registers bare dependencies hidden behind a directive boundary, even when this environment's effective source is the boundary module. In RSC, for example, a "use client" barrel that re-exports from a nested package causes resolveId to redirect unrelated direct imports of that package to the barrel's private copy (subpkgOrigin is global per environment and first-wins). Before this change the boundary replaced source, so that stale registration did not occur. Build this registration from the environment-effective entries (getEntry) so only sources this environment can actually emit are installed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f5acbef: the registration loop now uses this environment's sources (getEntry), so a package behind a boundary isn't registered in RSC. Added a test that fails without it.

rscBoundary: filePath,
ssrBoundary: isSsrBoundary ? filePath : entry.ssrBoundary,
});
}
}

Expand Down Expand Up @@ -759,14 +776,12 @@ export function createOptimizeImportsPlugin(
// In pnpm strict mode, sub-packages like @radix-ui/react-slot are only
// resolvable from the barrel package's location, not from user code.
// Use Vite's own resolver (not createRequire) so it picks the ESM entry.
// subpkgOrigin is keyed by environment; prefer the current env's map but
// fall back to the other env's map for the case where only one environment
// has transformed files that import from a given barrel (e.g. a barrel
// only reachable from the RSC graph may still need resolving from SSR).
const envName = (this as PluginCtx).environment?.name ?? "ssr";
const barrelEntry =
barrelCaches.subpkgOrigin.get(envName)?.get(source) ??
barrelCaches.subpkgOrigin.get(envName === "rsc" ? "ssr" : "rsc")?.get(source);
// subpkgOrigin is keyed like the transform below, which registers every
// specifier it emits in its own environment's map. The other environment's
// map may hold specifiers this one never emits, such as packages behind a
// "use client" boundary in RSC.
const envKey = (this as PluginCtx).environment?.name === "rsc" ? "rsc" : "ssr";
const barrelEntry = barrelCaches.subpkgOrigin.get(envKey)?.get(source);
if (!barrelEntry) return;
const resolved = await this.resolve(source, barrelEntry, { skipSelf: true });
return resolved ?? undefined;
Expand Down Expand Up @@ -852,6 +867,13 @@ export function createOptimizeImportsPlugin(
barrelCaches.exportMapCache,
);
if (!exportMap || !barrelEntry) continue;
// The module to import a name from in this environment, stopping at
// the module that this environment replaces with references.
const getEntry = (name: string): BarrelExportEntry | undefined => {
const entry = exportMap.get(name);
const boundary = preferReactServer ? entry?.rscBoundary : entry?.ssrBoundary;
return boundary ? { source: boundary, isNamespace: false, originalName: name } : entry;
};

// Register sub-package sources so resolveId can find them from
// the barrel's context (needed for pnpm strict hoisting).
Expand All @@ -876,16 +898,15 @@ export function createOptimizeImportsPlugin(
envOriginMap = new Map<string, string>();
barrelCaches.subpkgOrigin.set(envKey, envOriginMap);
}
for (const entry of exportMap.values()) {
if (
!entry.source.startsWith("/") &&
!entry.source.startsWith(".") &&
!envOriginMap.has(entry.source)
) {
for (const name of exportMap.keys()) {
// Only the sources this environment can emit, so a package behind
// a boundary isn't registered.
const source = getEntry(name)!.source;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The per-environment filtering here is still bypassed by the resolveId cross-environment fallback at lines 785-786. After SSR transforms this use-client barrel it registers the bare leaf source here; a later RSC resolution misses its own map, reads that SSR entry, and resolves the package from the barrel location, potentially selecting the private nested copy instead of the app copy. On main, the shared boundary source prevented SSR from registering this leaf, so this is introduced by the split maps. Please prevent fallback entries that this environment cannot emit (or remove the cross-environment fallback), and cover the SSR-transform-then-RSC-resolve sequence.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in f7be786: resolveId now looks up the map keyed like the transform (rsc or ssr) and no longer falls back to the other environment's. Every specifier an environment emits is registered in its own map first, so the fallback only ever served specifiers that environment didn't emit. The test now runs the SSR transform before the RSC one and checks that only SSR resolves from the barrel.

if (!source.startsWith("/") && !source.startsWith(".") && !envOriginMap.has(source)) {
// First barrel to register this specifier (within this environment) wins.
// Sub-package specifiers are keyed per environment so that RSC and SSR
// barrel entries don't cross-contaminate each other's resolution context.
envOriginMap.set(entry.source, barrelEntry);
envOriginMap.set(source, barrelEntry);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

In SSR, this newly registers bare leaves behind a "use client" barrel, but the map is still global by source within that environment and resolveId does not inspect the importer. A module that imports { Slot } from an optimized barrel with a nested @radix-ui/react-slot and also imports @radix-ui/react-slot directly will therefore resolve both imports from the barrel location; likewise, the first of two barrels with different nested versions wins. On main the client boundary prevented this SSR registration, so this is newly reachable here. Resolve the emitted leaf to an absolute id during the transform, or scope the origin mapping to the transformed importer/edge, so unrelated SSR imports retain their own package resolution.

@james-elicx james-elicx Oct 19, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correction after testing it on a fixture: this can't happen as described. Vite's vite:resolve runs before this plugin's resolveId, so any bare import the importing module can resolve, including a direct import of @radix-ui/react-slot, is resolved from the importer and never reaches subpkgOrigin. The map only handles specifiers the importer can't resolve, which is the pnpm strict case it exists for. I checked with a barrel that re-exports its own copy of a package the app also depends on: the app's direct import got the app's copy. That leaves two optimized barrels that both re-export different versions of a package the app can't resolve itself, which applies on main too and isn't specific to this PR. Not changing this here.

}
}
}
Expand Down Expand Up @@ -952,7 +973,7 @@ export function createOptimizeImportsPlugin(
// name gains nothing from a rewrite.
if (
isSubpath &&
specifiers.every(({ imported }) => exportMap.get(imported)?.source === barrelEntry)
specifiers.every(({ imported }) => getEntry(imported)?.source === barrelEntry)
) {
continue;
}
Expand All @@ -962,7 +983,7 @@ export function createOptimizeImportsPlugin(
const resolvedPackageSources = new Map<string, string>();
if (isSubpath) {
for (const { imported } of specifiers) {
const source = exportMap.get(imported)?.source;
const source = getEntry(imported)?.source;
if (!source || path.isAbsolute(source) || resolvedPackageSources.has(source))
continue;
const resolvedId = await resolveFileId(this, source, barrelEntry);
Expand All @@ -985,7 +1006,7 @@ export function createOptimizeImportsPlugin(
}
>();
for (const { local, imported } of specifiers) {
const entry = exportMap.get(imported);
const entry = getEntry(imported);
if (!entry) continue;
// Sources in the export map are already absolute paths (for file references)
// or bare package specifiers — no further resolution needed.
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion tests/after-response-close-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,11 @@ describe("closeAfterResponseWithBody on the Cloudflare Workers runtime", () => {
// createIsolatedFixture swaps in a plain workspace node_modules symlink
// (here, cf-app-basic's, for @cloudflare/vite-plugin + wrangler), which
// drops app-basic's own `file:./__test_packages__/*` local packages.
// Exclude the four routes that depend on those — unrelated to this
// Exclude the routes that depend on those — unrelated to this
// regression — so the rest of the fixture still builds.
(src) =>
!src.includes(`${path.sep}app${path.sep}context-dedup-test`) &&
!src.includes(`${path.sep}app${path.sep}optimize-imports-client-barrel`) &&
!src.includes(`${path.sep}app${path.sep}nextjs-compat${path.sep}node-modules-css`) &&
!src.includes(
`${path.sep}app${path.sep}nextjs-compat${path.sep}dynamic${path.sep}out-of-root-package`,
Expand Down
24 changes: 24 additions & 0 deletions tests/e2e/app-router/optimize-imports-client-barrel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { test, expect } from "@playwright/test";
import { waitForAppRouterHydration } from "../helpers";

const BASE = "http://localhost:4174";

// fake-client-barrel is in the fixture's optimizePackageImports. Its entry is a
// "use client" barrel, and its unused leaf sets a global when it is evaluated.
test.describe('optimizePackageImports with a "use client" barrel', () => {
test("SSR only evaluates the modules that define the imported names", async ({ request }) => {
const res = await request.get(`${BASE}/optimize-imports-client-barrel`);
expect(res.status()).toBe(200);
const html = await res.text();
expect(html).toContain('data-testid="client-barrel-counter"');
expect(html).toContain('data-unused-loaded="false"');
});

test("the component hydrates", async ({ page }) => {
await page.goto(`${BASE}/optimize-imports-client-barrel`);
await waitForAppRouterHydration(page);
const counter = page.getByTestId("client-barrel-counter");
await counter.click();
await expect(counter).toHaveText("Count: 1");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createElement, useState } from "react";

export function Counter() {
const [count, setCount] = useState(0);
return createElement(
"button",
{
"data-testid": "client-barrel-counter",
// Hydration can't match: the client loads the whole barrel.
"data-unused-loaded": String(globalThis.__fakeClientBarrelUnusedLoaded === true),
suppressHydrationWarning: true,
onClick: () => setCount((value) => value + 1),
},
`Count: ${count}`,
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"use client";

// A "use client" barrel, like antd's entry.
export { Counter } from "./counter.js";
export { Unused } from "./unused.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "fake-client-barrel",
"version": "1.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"main": "index.js",
"exports": {
".": "./index.js"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
globalThis.__fakeClientBarrelUnusedLoaded = true;

export function Unused() {
return null;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import { Counter } from "fake-client-barrel";

export default function OptimizeImportsClientBarrelPage() {
return <Counter />;
}
3 changes: 3 additions & 0 deletions tests/fixtures/app-basic/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const nextConfig: NextConfig = {
// don't assert the method's absence against this fixture.
experimental: {
gestureTransition: true,
// Used by E2E: optimize-imports-client-barrel.spec.ts — a "use client"
// barrel whose imports SSR rewrites to the modules that define them.
optimizePackageImports: ["fake-client-barrel"],
},

// Used by Vitest: app-router-production-server.test.ts — a next/dynamic()
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/app-basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"type": "module",
"dependencies": {
"@vitejs/plugin-rsc": "catalog:",
"fake-client-barrel": "file:./__test_packages__/fake-client-barrel",
"fake-context-lib": "file:./__test_packages__/fake-context-lib",
"fake-css-lib": "file:./__test_packages__/fake-css-lib",
"fake-css-module-lib": "file:./__test_packages__/fake-css-module-lib",
Expand Down
Loading
Loading