diff --git a/packages/vinext/src/plugins/optimize-imports.ts b/packages/vinext/src/plugins/optimize-imports.ts index c6f9cd701..f3922ecd8 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,145 @@ type AstBodyNode = { // so that when Vite adds proper typing it can be removed in one place. type PluginCtx = { environment?: { name?: string } }; +function parseAstWithFileLang(code: string, filePath: string): ReturnType { + const cleanPath = filePath.split("?")[0]; + const ext = path.extname(cleanPath).toLowerCase(); + if (ext === ".tsx" || ext === ".jsx") { + return parseAst(code, { lang: "tsx" }); + } + return parseAst(code); +} + +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"); + const ident = match[1]; + if (ident !== "function" && ident !== "class" && !exportMap.has(ident)) { + recordDirectExport(ident); + } + } + + 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 = parseAstWithFileLang(content, filePath); + } 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; + // Only flatten local wildcard re-exports. External package wildcard re-exports + // require full package resolution and are intentionally left untouched. + // Bail on the entire file — if any wildcard target is external, flattening is + // unsafe and we fall back to leaving all `export *` statements as-is. + 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 +468,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,10 +478,11 @@ async function resolvePackageEntry( */ async function buildExportMapFromFile( filePath: string, - readFile: (filepath: string) => Promise, + readFile: ReadFileFn, cache: Map, visited: Set, initialContent?: string, + inProgress = new Set(), ): Promise { // Guard against circular re-exports if (visited.has(filePath)) return new Map(); @@ -348,14 +491,25 @@ async function buildExportMapFromFile( const cached = cache.get(filePath); if (cached) return cached; + if (inProgress.has(filePath)) { + return new Map(); + } + inProgress.add(filePath); + const content = initialContent ?? (await readFile(filePath)); - if (!content) return new Map(); + if (!content) { + inProgress.delete(filePath); + return new Map(); + } let ast: ReturnType; try { - ast = parseAst(content); + ast = parseAstWithFileLang(content, filePath); } catch { - return new Map(); + const fallbackMap = buildFallbackExportMap(filePath, content); + cache.set(filePath, fallbackMap); + inProgress.delete(filePath); + return fallbackMap; } const exportMap: BarrelExportMap = new Map(); @@ -369,6 +523,46 @@ 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, + inProgress, + ); + 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. @@ -460,27 +654,7 @@ async function buildExportMapFromFile( // Includes TypeScript-first (.ts/.tsx/.cts/.mts) and JSX (.jsx) extensions // for TypeScript-first internal libraries and monorepo packages that may // not compile to .js. Also includes .cjs for CommonJS-style re-export files. - const candidates = [ - subPath, - `${subPath}.js`, - `${subPath}.mjs`, - `${subPath}.cjs`, - `${subPath}.ts`, - `${subPath}.tsx`, - `${subPath}.jsx`, - `${subPath}.mts`, - `${subPath}.cts`, - // Directory-style sub-modules: `export * from "./components"` where - // `components/` is a directory with an index file. - `${subPath}/index.js`, - `${subPath}/index.mjs`, - `${subPath}/index.cjs`, - `${subPath}/index.ts`, - `${subPath}/index.tsx`, - `${subPath}/index.jsx`, - `${subPath}/index.mts`, - `${subPath}/index.cts`, - ]; + const candidates = localModuleCandidates(subPath); for (const candidate of candidates) { const candidateContent = await readFile(candidate); if (candidateContent !== null) { @@ -490,6 +664,7 @@ async function buildExportMapFromFile( cache, visited, candidateContent, + inProgress, ); for (const [name, entry] of subMap) { if (!exportMap.has(name)) { @@ -517,11 +692,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, + ); } } } @@ -579,6 +760,7 @@ async function buildExportMapFromFile( } cache.set(filePath, exportMap); + inProgress.delete(filePath); return exportMap; } @@ -596,7 +778,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); @@ -617,6 +799,7 @@ export async function buildBarrelExportMap( if (!content) return null; const visited = new Set(); + const inProgress = new Set(); // Pass the already-read content so buildExportMapFromFile skips the redundant // readFile call for the entry file (it would otherwise read it a second time). // buildExportMapFromFile also stores the result in exportMapCache (keyed by @@ -627,6 +810,7 @@ export async function buildBarrelExportMap( exportMapCache, visited, content, + inProgress, ); return exportMap; @@ -667,8 +851,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 +903,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 +915,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,11 +923,13 @@ 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 { - ast = parseAst(code); + ast = parseAstWithFileLang(code, id); } catch { return null; } @@ -751,6 +937,81 @@ 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; + } + >(); + + for (const [exported, entry] of exportMap) { + if (exported === "default") continue; + const source = entry.source.startsWith("/") + ? toRelativeModuleSpecifier(normalizedId, entry.source) + : entry.source; + 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 (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 +1177,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-build.test.ts b/tests/optimize-imports-build.test.ts new file mode 100644 index 000000000..360086c85 --- /dev/null +++ b/tests/optimize-imports-build.test.ts @@ -0,0 +1,188 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import fs from "node:fs"; +import os from "node:os"; +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"; + +function symlinkWorkspacePackage(root: string, packageName: string) { + const source = path.resolve(import.meta.dirname, "../node_modules", packageName); + const target = path.join(root, "node_modules", packageName); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.symlinkSync(source, target, "junction"); +} + +async function withTempDir(prefix: string, run: (tmpDir: string) => Promise): Promise { + const tmpDir = await mkdtemp(path.join(os.tmpdir(), prefix)); + fs.mkdirSync(path.join(tmpDir, "node_modules"), { recursive: true }); + symlinkWorkspacePackage(tmpDir, "react"); + symlinkWorkspacePackage(tmpDir, "react-dom"); + symlinkWorkspacePackage(tmpDir, "react-server-dom-webpack"); + 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); +} + +function readTextFilesRecursive(root: string): string { + let output = ""; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) { + output += readTextFilesRecursive(entryPath); + continue; + } + if (!entry.name.endsWith(".js")) continue; + output += fs.readFileSync(entryPath, "utf-8"); + } + return output; +} + +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