From 012c53270cb6f9f962fb1183b18b077261ee658b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B2=92=E7=B2=92=E6=A9=99?= Date: Wed, 15 Apr 2026 10:25:42 +0800 Subject: [PATCH 01/13] fix(plugins): resolve optimized imports past local barrels Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../vinext/src/plugins/optimize-imports.ts | 297 +++++++++++++++++- tests/optimize-imports.test.ts | 57 +++- 2 files changed, 339 insertions(+), 15 deletions(-) diff --git a/packages/vinext/src/plugins/optimize-imports.ts b/packages/vinext/src/plugins/optimize-imports.ts index c6f9cd701..48143fe8d 100644 --- a/packages/vinext/src/plugins/optimize-imports.ts +++ b/packages/vinext/src/plugins/optimize-imports.ts @@ -64,6 +64,8 @@ type DeclarationNode = { declarations?: Array<{ id: { name: string } }>; }; +type ReadFileFn = (filepath: string) => Promise; + /** Caches used by the optimize-imports plugin, scoped to a plugin instance. */ type BarrelCaches = { /** Barrel export maps keyed by resolved entry file path. */ @@ -107,6 +109,131 @@ type AstBodyNode = { // so that when Vite adds proper typing it can be removed in one place. type PluginCtx = { environment?: { name?: string } }; +function localModuleCandidates(modulePath: string): string[] { + return [ + modulePath, + `${modulePath}.js`, + `${modulePath}.mjs`, + `${modulePath}.cjs`, + `${modulePath}.ts`, + `${modulePath}.tsx`, + `${modulePath}.jsx`, + `${modulePath}.mts`, + `${modulePath}.cts`, + `${modulePath}/index.js`, + `${modulePath}/index.mjs`, + `${modulePath}/index.cjs`, + `${modulePath}/index.ts`, + `${modulePath}/index.tsx`, + `${modulePath}/index.jsx`, + `${modulePath}/index.mts`, + `${modulePath}/index.cts`, + ]; +} + +function toRelativeModuleSpecifier(fromFile: string, toFile: string): string { + const relativePath = path.relative(path.dirname(fromFile), toFile).split(path.sep).join("/"); + return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; +} + +function findOptimizedPackageForFile(id: string, packages: Set): string | null { + const normalizedId = id.split("?")[0].split(path.sep).join("/"); + let match: string | null = null; + for (const pkg of packages) { + if ( + normalizedId.includes(`/node_modules/${pkg}/`) && + (match === null || pkg.length > match.length) + ) { + match = pkg; + } + } + return match; +} + +async function resolveLocalModuleFile( + modulePath: string, + readFile: ReadFileFn, +): Promise<{ filePath: string; content: string } | null> { + for (const candidate of localModuleCandidates(modulePath)) { + const content = await readFile(candidate); + if (content !== null) { + return { filePath: candidate, content }; + } + } + return null; +} + +function buildFallbackExportMap(filePath: string, content: string): BarrelExportMap { + const exportMap: BarrelExportMap = new Map(); + + const recordDirectExport = (exportName: string, originalName = exportName) => { + exportMap.set(exportName, { + source: filePath, + isNamespace: false, + originalName, + }); + }; + + const declarationPatterns = [ + /^\s*export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm, + /^\s*export\s+class\s+([A-Za-z_$][\w$]*)/gm, + /^\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gm, + ]; + for (const pattern of declarationPatterns) { + for (const match of content.matchAll(pattern)) { + recordDirectExport(match[1]); + } + } + + for (const match of content.matchAll(/^\s*export\s+default\s+([A-Za-z_$][\w$]*)\s*;?/gm)) { + recordDirectExport("default", "default"); + if (!exportMap.has(match[1])) { + recordDirectExport(match[1]); + } + } + + return exportMap; +} + +async function buildSafeWildcardExportMap( + filePath: string, + readFile: ReadFileFn, + cache: Map, + visited = new Set(), +): Promise { + if (visited.has(filePath)) return null; + visited.add(filePath); + + const content = await readFile(filePath); + if (!content) return null; + + let ast: ReturnType; + try { + ast = parseAst(content); + } catch { + const fallbackMap = buildFallbackExportMap(filePath, content); + return fallbackMap.size > 0 ? fallbackMap : null; + } + + const fileDir = path.dirname(filePath); + for (const node of ast.body as AstBodyNode[]) { + if (node.type !== "ExportAllDeclaration" || node.exported) continue; + const rawSource = typeof node.source?.value === "string" ? node.source.value : null; + if (!rawSource || !rawSource.startsWith(".")) return null; + + const resolved = await resolveLocalModuleFile( + path.resolve(fileDir, rawSource).split(path.sep).join("/"), + readFile, + ); + if (!resolved) return null; + + const nestedMap = await buildSafeWildcardExportMap(resolved.filePath, readFile, cache, visited); + if (!nestedMap) return null; + } + + return buildExportMapFromFile(filePath, readFile, cache, new Set(), content); +} + /** * Packages whose barrel imports are automatically optimized. * Matches Next.js's built-in optimizePackageImports defaults plus radix-ui. @@ -327,8 +454,9 @@ async function resolvePackageEntry( * - `import * as X; export { X }` — indirect namespace re-export * - `export * from "./sub"` — wildcard: recursively parse sub-module and merge exports * - * Returns an empty map when the file cannot be read or has a parse error, so that - * recursive wildcard calls degrade gracefully without aborting the whole barrel walk. + * Returns an empty map when the file cannot be read. On parse errors it falls back to + * a small regex-based export scan so simple JSX-in-.js leaf modules can still contribute + * direct named/default exports without aborting the whole barrel walk. * * @param initialContent - Pre-read file content for `filePath`. If provided, skips the * `readFile` call for the entry file — avoids a redundant read when the caller @@ -336,7 +464,7 @@ async function resolvePackageEntry( */ async function buildExportMapFromFile( filePath: string, - readFile: (filepath: string) => Promise, + readFile: ReadFileFn, cache: Map, visited: Set, initialContent?: string, @@ -355,7 +483,9 @@ async function buildExportMapFromFile( try { ast = parseAst(content); } catch { - return new Map(); + const fallbackMap = buildFallbackExportMap(filePath, content); + cache.set(filePath, fallbackMap); + return fallbackMap; } const exportMap: BarrelExportMap = new Map(); @@ -369,6 +499,45 @@ async function buildExportMapFromFile( const fileDir = path.dirname(filePath); + async function resolveConcreteLocalEntry( + entry: BarrelExportEntry, + exportName: string, + seen = new Set(), + ): Promise { + if (!entry.source.startsWith("/")) return entry; + + const visitKey = `${entry.source}:${exportName}`; + if (seen.has(visitKey)) return entry; + seen.add(visitKey); + + for (const candidate of localModuleCandidates(entry.source)) { + const candidateContent = await readFile(candidate); + if (candidateContent === null) continue; + + const subMap = await buildExportMapFromFile( + candidate, + readFile, + cache, + new Set(), + candidateContent, + ); + const nextEntry = subMap.get(exportName); + if (!nextEntry) return entry; + + if ( + nextEntry.source === entry.source && + nextEntry.isNamespace === entry.isNamespace && + nextEntry.originalName === entry.originalName + ) { + return entry; + } + + return resolveConcreteLocalEntry(nextEntry, exportName, seen); + } + + return entry; + } + /** * Normalize a source specifier: resolve relative paths to absolute so that * entries in the export map always store absolute paths for file references. @@ -517,11 +686,17 @@ async function buildExportMapFromFile( const exported = astName(spec.exported); const local = astName(spec.local); if (exported !== null) { - exportMap.set(exported, { + const entry: BarrelExportEntry = { source, isNamespace: false, originalName: local ?? undefined, - }); + }; + exportMap.set( + exported, + source.startsWith("/") + ? await resolveConcreteLocalEntry(entry, local ?? exported) + : entry, + ); } } } @@ -596,7 +771,7 @@ async function buildExportMapFromFile( export async function buildBarrelExportMap( packageName: string, resolveEntry: (pkg: string) => string | null, - readFile: (filepath: string) => Promise, + readFile: ReadFileFn, cache?: Map, ): Promise { const entryPath = resolveEntry(packageName); @@ -667,8 +842,9 @@ export function createOptimizeImportsPlugin( // or shape mismatches that the return-type check alone would accept silently. return { name: "vinext:optimize-imports", - // No enforce — runs after JSX transform so parseAst gets plain JS. - // The transform hook still rewrites imports before Vite resolves them. + enforce: "pre", + // Run before downstream graph analyzers like plugin-rsc so rewritten imports + // and flattened local client barrels are visible before they are inspected. buildStart() { // Initialize eagerly (rather than lazily) so that nextConfig is fully @@ -718,10 +894,8 @@ export function createOptimizeImportsPlugin( }, }, async handler(code, id) { - // Only apply on server environments (RSC/SSR). The client uses Vite's - // dep optimizer which handles barrel imports correctly. const env = (this as PluginCtx).environment; - if (env?.name === "client") return null; + const isClient = env?.name === "client"; // "react-server" export condition should only be preferred in the RSC environment. // SSR renders with the full React runtime and must NOT resolve react-server entries. const preferReactServer = env?.name === "rsc"; @@ -732,6 +906,7 @@ export function createOptimizeImportsPlugin( // Use quoted forms to avoid false positives (e.g. "effect" in "useEffect"). // quotedPackages is pre-built in buildStart to avoid per-file allocations. const packages = optimizedPackages; + const optimizedPackageForFile = findOptimizedPackageForFile(id, packages); let hasBarrelImport = false; for (const quoted of quotedPackages) { if (code.includes(quoted)) { @@ -739,7 +914,9 @@ export function createOptimizeImportsPlugin( break; } } - if (!hasBarrelImport) return null; + const hasFlattenableWildcardExport = + optimizedPackageForFile !== null && code.includes("export * from"); + if (!hasBarrelImport && !hasFlattenableWildcardExport) return null; let ast: ReturnType; try { @@ -751,6 +928,86 @@ export function createOptimizeImportsPlugin( const s = new MagicString(code); let hasChanges = false; const root = getRoot(); + const normalizedId = id.split("?")[0].split(path.sep).join("/"); + + if (optimizedPackageForFile !== null) { + for (const node of ast.body as AstBodyNode[]) { + if (node.type !== "ExportAllDeclaration" || node.exported) continue; + + const rawSource = typeof node.source?.value === "string" ? node.source.value : null; + if (!rawSource || !rawSource.startsWith(".")) continue; + + const resolved = await resolveLocalModuleFile( + path.resolve(path.dirname(normalizedId), rawSource).split(path.sep).join("/"), + readFileSafe, + ); + if (!resolved) continue; + + const exportMap = await buildSafeWildcardExportMap( + resolved.filePath, + readFileSafe, + barrelCaches.exportMapCache, + ); + if (!exportMap) continue; + + const bySource = new Map< + string, + { + source: string; + exports: Array<{ exported: string; originalName: string | undefined }>; + isNamespace: boolean; + } + >(); + + let canRewrite = true; + for (const [exported, entry] of exportMap) { + if (exported === "default") continue; + const source = entry.source.startsWith("/") + ? toRelativeModuleSpecifier(normalizedId, entry.source) + : entry.source; + if (!source) { + canRewrite = false; + break; + } + const key = `${source}::${entry.isNamespace}`; + let group = bySource.get(key); + if (!group) { + group = { source, exports: [], isNamespace: entry.isNamespace }; + bySource.set(key, group); + } + group.exports.push({ exported, originalName: entry.originalName }); + } + + if (!canRewrite || bySource.size === 0) continue; + + const replacements: string[] = []; + for (const { source, exports, isNamespace } of bySource.values()) { + if (isNamespace) { + for (const { exported } of exports) { + replacements.push(`export * as ${exported} from ${JSON.stringify(source)}`); + } + continue; + } + + const specs = exports + .filter(({ originalName }) => originalName !== "default") + .map(({ exported, originalName }) => { + if (originalName && originalName !== exported) { + return `${originalName} as ${exported}`; + } + return exported; + }); + if (specs.length > 0) { + replacements.push(`export { ${specs.join(", ")} } from ${JSON.stringify(source)}`); + } + } + + if (replacements.length === 0) continue; + + s.overwrite(node.start, node.end, replacements.join(";\n") + ";"); + hasChanges = true; + } + } for (const node of ast.body as AstBodyNode[]) { if (node.type !== "ImportDeclaration") continue; @@ -916,6 +1173,20 @@ export function createOptimizeImportsPlugin( }); } + // The client environment only opts into rewrites that stay on fully + // resolved local files. Bare sub-package rewrites can require the + // barrel package's resolution context under strict pnpm layouts, which + // is handled on the server side via resolveId + subpkgOrigin but is + // intentionally left unchanged for the client graph. + if ( + isClient && + [...bySource.values()].some( + ({ source }) => !source.startsWith("/") && !source.startsWith("."), + ) + ) { + continue; + } + // Build replacement import statements const replacements: string[] = []; for (const { source, locals, isNamespace } of bySource.values()) { diff --git a/tests/optimize-imports.test.ts b/tests/optimize-imports.test.ts index 445926c88..f9066dbcf 100644 --- a/tests/optimize-imports.test.ts +++ b/tests/optimize-imports.test.ts @@ -39,8 +39,7 @@ describe("vinext:optimize-imports plugin", () => { () => "/fake/root", ) as Plugin; expect(plugin.name).toBe("vinext:optimize-imports"); - // No enforce — runs after JSX transform so parseAst gets plain JS - expect(plugin.enforce).toBeUndefined(); + expect(plugin.enforce).toBe("pre"); }); // ── Guard clauses ──────────────────────────────────────────── @@ -939,6 +938,60 @@ describe("vinext:optimize-imports transform", () => { expect(result!.code).not.toContain(`from "./`); }); + it("issue-845 multi-hop antd barrel rewrites past intermediate barrel index", async () => { + // Ported from issue-845 parity notes: `.sisyphus/evidence/task-1-parity-matrix.md` + // Repro shape matches the documented `antd` optimization path, where the root barrel + // points at `es/button/index.js` but the concrete implementation lives deeper. + tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "vinext-optimize-test-"))); + fs.writeFileSync( + path.join(tmpDir, "package.json"), + JSON.stringify({ name: "test-app", type: "module" }), + ); + + const pkgDir = path.join(tmpDir, "node_modules", "antd"); + fs.mkdirSync(path.join(pkgDir, "es", "button"), { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ name: "antd", type: "module", main: "./index.js" }), + ); + fs.writeFileSync( + path.join(pkgDir, "index.js"), + `export { Button } from "./es/button/index.js";`, + ); + fs.writeFileSync( + path.join(pkgDir, "es", "button", "index.js"), + [`export * from "./style.js";`, `export { Button } from "./button.js";`].join("\n"), + ); + fs.writeFileSync(path.join(pkgDir, "es", "button", "style.js"), `export const wave = true;`); + fs.writeFileSync( + path.join(pkgDir, "es", "button", "button.js"), + `export function Button() { return null; }`, + ); + + const plugin = createOptimizeImportsPlugin( + () => undefined, + () => tmpDir, + ) as Plugin; + const buildStartHook = unwrapHook((plugin as any).buildStart); + if (buildStartHook) await buildStartHook.call(plugin); + const transform = unwrapHook(plugin.transform)!; + + const result = await (transform as any).call( + { ...plugin, environment: { name: "rsc" } }, + `import { Button } from "antd";`, + "/app/page.tsx", + ); + + expect(result).not.toBeNull(); + + const intermediateBarrel = path.join(pkgDir, "es", "button", "index"); + const concreteModule = path.join(pkgDir, "es", "button", "button.js"); + + expect(result!.code).not.toContain(`from "antd"`); + expect(result!.code).not.toContain(JSON.stringify(intermediateBarrel)); + expect(result!.code).toContain(JSON.stringify(concreteModule)); + }); + it("populates subpkgOrigin independently for RSC and SSR when they share the same barrel entry", async () => { // Regression test: registeredBarrels used to be keyed only by barrelEntry (not envKey:barrelEntry). // When RSC and SSR share the same barrel entry path (common — most packages have no react-server From e8079c8e6d003d824eb7bd08099f3e7c9a0ef0df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B2=92=E7=B2=92=E6=A9=99?= Date: Wed, 15 Apr 2026 10:26:04 +0800 Subject: [PATCH 02/13] test: cover antd App Router build regression Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- tests/optimize-imports-build.test.ts | 160 +++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 tests/optimize-imports-build.test.ts diff --git a/tests/optimize-imports-build.test.ts b/tests/optimize-imports-build.test.ts new file mode 100644 index 000000000..0d0f04bec --- /dev/null +++ b/tests/optimize-imports-build.test.ts @@ -0,0 +1,160 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import fs from "node:fs"; +import path from "node:path"; +import { createBuilder } from "vite"; +import { describe, expect, it } from "vite-plus/test"; +import vinext from "../packages/vinext/src/index.js"; + +async function withTempDir(prefix: string, run: (tmpDir: string) => Promise): Promise { + const tempParent = path.resolve(import.meta.dirname, "../.sisyphus/tmp"); + fs.mkdirSync(tempParent, { recursive: true }); + + const tmpDir = await mkdtemp(path.join(tempParent, prefix)); + try { + return await run(tmpDir); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } +} + +function writeFixtureFile(root: string, filePath: string, content: string) { + const absPath = path.join(root, filePath); + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, content); +} + +async function buildApp(root: string) { + const rscOutDir = path.join(root, "dist", "server"); + const ssrOutDir = path.join(root, "dist", "server", "ssr"); + const clientOutDir = path.join(root, "dist", "client"); + + const builder = await createBuilder({ + root, + configFile: false, + plugins: [vinext({ appDir: root, rscOutDir, ssrOutDir, clientOutDir })], + logLevel: "silent", + }); + + await builder.buildApp(); +} + +describe("optimizePackageImports production builds", () => { + it("builds an App Router app when an optimized antd barrel resolves through a use-client export-star boundary", async () => { + // issue-845 repro scaffold and package pins are recorded in: + // .sisyphus/evidence/task-1-parity-matrix.md + await withTempDir("vinext-optimize-imports-build-", async (root) => { + writeFixtureFile( + root, + "package.json", + JSON.stringify( + { name: "vinext-optimize-imports-build", private: true, type: "module" }, + null, + 2, + ), + ); + writeFixtureFile( + root, + "tsconfig.json", + JSON.stringify( + { + compilerOptions: { + target: "ES2022", + module: "ESNext", + moduleResolution: "bundler", + jsx: "react-jsx", + strict: true, + skipLibCheck: true, + types: ["vite/client", "@vitejs/plugin-rsc/types"], + }, + include: ["app", "*.ts", "*.tsx"], + }, + null, + 2, + ), + ); + + writeFixtureFile( + root, + "app/layout.tsx", + `import type { ReactNode } from "react"; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} +`, + ); + writeFixtureFile( + root, + "app/page.tsx", + `import AntdDemo from "./components/AntdDemo"; + +export default function HomePage() { + return ; +} +`, + ); + writeFixtureFile( + root, + "app/components/AntdDemo.tsx", + `"use client"; + +import { Button } from "antd"; + +export default function AntdDemo() { + return