Skip to content

Commit 561aefa

Browse files
fix(rsc): preserve exported client reference subpaths
Client reference dedupe currently rewrites every package file imported through the RSC package proxy back to the package root. That loses subpath identity when a package exposes client modules through an exported subpath instead of its root barrel. The plugin now derives a bare import specifier from package metadata, preserving exact and pattern exports while keeping private internals on the package root so existing context dedupe behavior stays intact. Regression coverage exercises exported subpaths, scoped packages, export patterns, private internals, and legacy deep imports.
1 parent 7d29908 commit 561aefa

2 files changed

Lines changed: 412 additions & 41 deletions

File tree

packages/vinext/src/plugins/client-reference-dedup.ts

Lines changed: 213 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,208 @@
1+
import { readFile as readFileFromFs } from "node:fs/promises";
12
import type { Plugin } from "vite";
23

4+
type ReadPackageJson = (path: string) => Promise<string>;
5+
6+
type ClientReferenceDedupOptions = {
7+
readFile?: ReadPackageJson;
8+
};
9+
10+
type PackageImportSpecifier = {
11+
packageName: string;
12+
specifier: string;
13+
};
14+
15+
type PackagePath = {
16+
packageName: string;
17+
packageRoot: string;
18+
relativePath: string;
19+
};
20+
21+
function isRecord(value: unknown): value is Record<string, unknown> {
22+
return typeof value === "object" && value !== null && !Array.isArray(value);
23+
}
24+
25+
function defaultReadPackageJson(path: string): Promise<string> {
26+
return readFileFromFs(path, "utf8");
27+
}
28+
29+
function parsePackagePath(absolutePath: string): PackagePath | null {
30+
const marker = "/node_modules/";
31+
const lastIdx = absolutePath.lastIndexOf(marker);
32+
if (lastIdx === -1) return null;
33+
34+
const rest = absolutePath.slice(lastIdx + marker.length);
35+
const parts = rest.split("/");
36+
const packagePartCount = rest.startsWith("@") ? 2 : 1;
37+
const packageParts = parts.slice(0, packagePartCount);
38+
39+
if (packageParts.length < packagePartCount || packageParts.some((part) => part === "")) {
40+
return null;
41+
}
42+
43+
const packageName = packageParts.join("/");
44+
const relativeParts = parts.slice(packagePartCount);
45+
46+
return {
47+
packageName,
48+
packageRoot: absolutePath.slice(0, lastIdx + marker.length + packageName.length),
49+
relativePath: relativeParts.join("/"),
50+
};
51+
}
52+
353
/**
454
* Extract the bare package name from an absolute file path containing node_modules.
555
*
656
* Handles scoped packages (`@org/name`) and nested node_modules.
757
* Returns `null` if the path doesn't contain `/node_modules/`.
858
*/
959
export function extractPackageName(absolutePath: string): string | null {
10-
const marker = "/node_modules/";
11-
const lastIdx = absolutePath.lastIndexOf(marker);
12-
if (lastIdx === -1) return null;
60+
return parsePackagePath(absolutePath)?.packageName ?? null;
61+
}
1362

14-
const rest = absolutePath.slice(lastIdx + marker.length);
15-
if (rest.startsWith("@")) {
16-
// Scoped package: @org/name
17-
const parts = rest.split("/");
18-
if (parts.length < 2) return null;
19-
return `${parts[0]}/${parts[1]}`;
63+
function normalizeExportTarget(target: string): string {
64+
return target.startsWith("./") ? target.slice(2) : target;
65+
}
66+
67+
function matchExportTarget(target: string, relativePath: string): string | null {
68+
const normalizedTarget = normalizeExportTarget(target);
69+
const wildcardIndex = normalizedTarget.indexOf("*");
70+
71+
if (wildcardIndex === -1) {
72+
return normalizedTarget === relativePath ? "" : null;
73+
}
74+
75+
const beforeWildcard = normalizedTarget.slice(0, wildcardIndex);
76+
const afterWildcard = normalizedTarget.slice(wildcardIndex + 1);
77+
78+
if (!relativePath.startsWith(beforeWildcard) || !relativePath.endsWith(afterWildcard)) {
79+
return null;
80+
}
81+
82+
return relativePath.slice(beforeWildcard.length, relativePath.length - afterWildcard.length);
83+
}
84+
85+
function exportKeyToSpecifier(
86+
packageName: string,
87+
exportKey: string,
88+
wildcardMatch: string,
89+
): string {
90+
if (exportKey === ".") return packageName;
91+
92+
const subpath = exportKey.startsWith("./") ? exportKey.slice(2) : exportKey;
93+
const resolvedSubpath = subpath.includes("*") ? subpath.replace("*", wildcardMatch) : subpath;
94+
95+
return `${packageName}/${resolvedSubpath}`;
96+
}
97+
98+
function collectExportTargets(value: unknown): string[] {
99+
if (typeof value === "string") return [value];
100+
if (Array.isArray(value)) return value.flatMap((entry) => collectExportTargets(entry));
101+
if (!isRecord(value)) return [];
102+
103+
return Object.values(value).flatMap((entry) => collectExportTargets(entry));
104+
}
105+
106+
function findExportSpecifier(
107+
packageName: string,
108+
exportsValue: unknown,
109+
relativePath: string,
110+
): string | null {
111+
if (!isRecord(exportsValue)) {
112+
for (const target of collectExportTargets(exportsValue)) {
113+
const wildcardMatch = matchExportTarget(target, relativePath);
114+
if (wildcardMatch !== null) {
115+
return exportKeyToSpecifier(packageName, ".", wildcardMatch);
116+
}
117+
}
118+
return null;
119+
}
120+
121+
const entries = Object.entries(exportsValue);
122+
const hasSubpathKeys = entries.some(([key]) => key === "." || key.startsWith("./"));
123+
if (!hasSubpathKeys) {
124+
for (const target of collectExportTargets(exportsValue)) {
125+
const wildcardMatch = matchExportTarget(target, relativePath);
126+
if (wildcardMatch !== null) {
127+
return exportKeyToSpecifier(packageName, ".", wildcardMatch);
128+
}
129+
}
130+
return null;
20131
}
21-
// Regular package: name
22-
const slashIdx = rest.indexOf("/");
23-
return slashIdx === -1 ? rest : rest.slice(0, slashIdx);
132+
133+
let bestMatch: { key: string; wildcard: string } | null = null;
134+
135+
for (const [key, value] of entries) {
136+
if (key !== "." && !key.startsWith("./")) continue;
137+
138+
for (const target of collectExportTargets(value)) {
139+
const wildcardMatch = matchExportTarget(target, relativePath);
140+
if (wildcardMatch === null) continue;
141+
142+
if (!bestMatch || key.length > bestMatch.key.length) {
143+
bestMatch = { key, wildcard: wildcardMatch };
144+
}
145+
}
146+
}
147+
148+
return bestMatch ? exportKeyToSpecifier(packageName, bestMatch.key, bestMatch.wildcard) : null;
149+
}
150+
151+
function getLegacyEntry(packageJson: Record<string, unknown>): string {
152+
const browser = packageJson.browser;
153+
if (typeof browser === "string") return browser;
154+
155+
const module = packageJson.module;
156+
if (typeof module === "string") return module;
157+
158+
const main = packageJson.main;
159+
return typeof main === "string" ? main : "index.js";
160+
}
161+
162+
function matchesLegacyEntry(legacyEntry: string, relativePath: string): boolean {
163+
const normalizedEntry = normalizeExportTarget(legacyEntry);
164+
return normalizedEntry === relativePath || `${normalizedEntry}.js` === relativePath;
165+
}
166+
167+
/**
168+
* Convert an absolute package file path into the least lossy bare import
169+
* specifier that can be handed back to Vite's dependency optimizer.
170+
*/
171+
export async function extractPackageImportSpecifier(
172+
absolutePath: string,
173+
readPackageJson: ReadPackageJson = defaultReadPackageJson,
174+
): Promise<PackageImportSpecifier | null> {
175+
const packagePath = parsePackagePath(absolutePath);
176+
if (!packagePath) return null;
177+
178+
const { packageName, packageRoot, relativePath } = packagePath;
179+
if (relativePath === "") {
180+
return { packageName, specifier: packageName };
181+
}
182+
183+
let packageJson: Record<string, unknown> | null = null;
184+
try {
185+
const rawPackageJson = await readPackageJson(`${packageRoot}/package.json`);
186+
const parsedPackageJson: unknown = JSON.parse(rawPackageJson);
187+
packageJson = isRecord(parsedPackageJson) ? parsedPackageJson : null;
188+
} catch {
189+
packageJson = null;
190+
}
191+
192+
if (!packageJson) {
193+
return { packageName, specifier: packageName };
194+
}
195+
196+
if ("exports" in packageJson) {
197+
const exportedSpecifier = findExportSpecifier(packageName, packageJson.exports, relativePath);
198+
return { packageName, specifier: exportedSpecifier ?? packageName };
199+
}
200+
201+
const specifier = matchesLegacyEntry(getLegacyEntry(packageJson), relativePath)
202+
? packageName
203+
: `${packageName}/${relativePath}`;
204+
205+
return { packageName, specifier };
24206
}
25207

26208
const DEDUP_PREFIX = "\0vinext:dedup/";
@@ -37,8 +219,10 @@ const PROXY_MARKER = "virtual:vite-rsc/client-in-server-package-proxy/";
37219
*
38220
* Dev-only — production builds use the SSR manifest which handles this correctly.
39221
*/
40-
export function clientReferenceDedupPlugin(): Plugin {
222+
export function clientReferenceDedupPlugin(options: ClientReferenceDedupOptions = {}): Plugin {
41223
let excludeSet = new Set<string>();
224+
const readPackageJson = options.readFile ?? defaultReadPackageJson;
225+
const packageImportCache = new Map<string, Promise<PackageImportSpecifier | null>>();
42226

43227
return {
44228
name: "vinext:client-reference-dedup",
@@ -55,7 +239,7 @@ export function clientReferenceDedupPlugin(): Plugin {
55239

56240
resolveId: {
57241
filter: { id: /node_modules/ },
58-
handler(id, importer) {
242+
async handler(id, importer) {
59243
// Only operate in the client environment
60244
if (this.environment?.name !== "client") return;
61245

@@ -65,19 +249,23 @@ export function clientReferenceDedupPlugin(): Plugin {
65249
// Only handle absolute paths through node_modules
66250
if (!id.startsWith("/") || !id.includes("/node_modules/")) return;
67251

68-
const pkgName = extractPackageName(id);
69-
if (!pkgName) return;
252+
const packageName = extractPackageName(id);
253+
if (!packageName) return;
70254

71255
// Respect user's optimizeDeps.exclude
72-
if (excludeSet.has(pkgName)) return;
73-
74-
// Lossy mapping: we collapse submodule paths (e.g. `pkg/dist/Button.js`)
75-
// to the bare package name (`pkg`), assuming the package entry barrel-exports
76-
// the same symbols. This holds for well-designed component libraries — the
77-
// primary target of this plugin. A more precise approach would resolve through
78-
// the package's `exports` map to find an exact subpath, but the barrel-export
79-
// assumption is sufficient for the common case.
80-
return `${DEDUP_PREFIX}${pkgName}`;
256+
if (excludeSet.has(packageName)) return;
257+
258+
let packageImportPromise = packageImportCache.get(id);
259+
if (!packageImportPromise) {
260+
packageImportPromise = extractPackageImportSpecifier(id, readPackageJson);
261+
packageImportCache.set(id, packageImportPromise);
262+
}
263+
264+
const packageImport = await packageImportPromise;
265+
if (!packageImport) return;
266+
if (excludeSet.has(packageImport.specifier)) return;
267+
268+
return `${DEDUP_PREFIX}${packageImport.specifier}`;
81269
},
82270
},
83271

0 commit comments

Comments
 (0)