From 0e08b852504321a89f6b587c36845ac1ccb82689 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 2 May 2026 16:48:28 +0100 Subject: [PATCH 1/3] fix: put client-in-server-package-proxy paths through fs proxy --- packages/plugin-rsc/src/plugin.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 5cbe58502..9a4d5b220 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -60,6 +60,7 @@ import { cleanUrl, directRequestRE, evalValue, + FS_PREFIX, normalizeViteImportAnalysisUrl, prepareError, } from './plugins/vite-utils' @@ -1628,10 +1629,11 @@ function vitePluginUseClient( '\0virtual:vite-rsc/client-in-server-package-proxy/'.length, ), ) + const url = path.posix.join(FS_PREFIX, id) // TODO: avoid `export default undefined` return ` - export * from ${JSON.stringify(id)}; - import * as __all__ from ${JSON.stringify(id)}; + export * from ${JSON.stringify(url)}; + import * as __all__ from ${JSON.stringify(url)}; export default __all__.default; ` } From aed891154a3f890248be31b135d408bb12ad71e8 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 2 May 2026 16:50:31 +0100 Subject: [PATCH 2/3] remove label req --- .github/workflows/release-continuous.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-continuous.yml b/.github/workflows/release-continuous.yml index f8cb9d2f0..291b8175e 100644 --- a/.github/workflows/release-continuous.yml +++ b/.github/workflows/release-continuous.yml @@ -14,7 +14,7 @@ jobs: if: > github.repository == 'vitejs/vite-plugin-react' && (github.event_name == 'push' || - (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger: preview'))) + (github.event_name == 'pull_request')) runs-on: ubuntu-latest steps: - name: Checkout code From 1d7b3fa02d8fd4e7f78a283174362cfaac562a14 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 2 May 2026 17:11:22 +0100 Subject: [PATCH 3/3] derive bare specifier isntead --- packages/plugin-rsc/src/plugin.ts | 38 +++- .../src/plugins/derive-bare-specifier.test.ts | 149 ++++++++++++++++ .../src/plugins/derive-bare-specifier.ts | 168 ++++++++++++++++++ 3 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 packages/plugin-rsc/src/plugins/derive-bare-specifier.test.ts create mode 100644 packages/plugin-rsc/src/plugins/derive-bare-specifier.ts diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 9a4d5b220..5874882fe 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -27,6 +27,7 @@ import { import { crawlFrameworkPkgs } from 'vitefu' import vitePluginRscCore from './core/plugin' import { cjsModuleRunnerPlugin } from './plugins/cjs' +import { deriveBareSpecifier } from './plugins/derive-bare-specifier' import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url' import { ensureEnvironmentImportsEntryFallback, @@ -1645,17 +1646,44 @@ function vitePluginUseClient( resolveId: { order: 'pre', async handler(source, importer, options) { - if ( - this.environment.name === serverEnvironmentName && - bareImportRE.test(source) && - !(source === 'client-only' || source === 'server-only') - ) { + if (this.environment.name !== serverEnvironmentName) return + if (source === 'client-only' || source === 'server-only') return + + // Bare-specifier import: stash directly. The `source` itself is the + // bare specifier we want to use in the proxy. + if (bareImportRE.test(source)) { const resolved = await this.resolve(source, importer, options) if (resolved && resolved.id.includes('/node_modules/')) { packageSources.set(resolved.id, source) return resolved } + return + } + + // Non-bare (relative or absolute) import resolving into node_modules: + // derive the package's own bare specifier from package.json + exports + // so that the working `client-package-proxy` branch handles it the + // same as a bare-specifier import. Without this, internal relative + // imports between files of the same package fall into the broken + // `client-in-server-package-proxy` fallback, which generates absolute + // filesystem paths in proxy modules. + const resolved = await this.resolve(source, importer, options) + if (!resolved || !resolved.id.includes('/node_modules/')) return + if (packageSources.has(resolved.id)) return resolved + + const cleanedId = cleanUrl(resolved.id) + const derivedSpec = deriveBareSpecifier(cleanedId) + if (!derivedSpec) return resolved + + // Round-trip the derived specifier through the resolver: only trust + // it if it resolves back to the same file. Defends against bugs in + // the reverse-exports walker and against environments where the + // package's own bare specifier doesn't resolve symmetrically. + const verify = await this.resolve(derivedSpec, importer, options) + if (verify && cleanUrl(verify.id) === cleanedId) { + packageSources.set(resolved.id, derivedSpec) } + return resolved }, }, load: { diff --git a/packages/plugin-rsc/src/plugins/derive-bare-specifier.test.ts b/packages/plugin-rsc/src/plugins/derive-bare-specifier.test.ts new file mode 100644 index 000000000..7b0a69244 --- /dev/null +++ b/packages/plugin-rsc/src/plugins/derive-bare-specifier.test.ts @@ -0,0 +1,149 @@ +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { deriveBareSpecifier } from './derive-bare-specifier' + +describe(deriveBareSpecifier, () => { + let tmpRoot: string + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'derive-bare-spec-')) + }) + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }) + }) + + function makePackage(name: string, manifest: object, files: Record): string { + const dir = path.join(tmpRoot, 'node_modules', name) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name, ...manifest }, null, 2), + ) + for (const [rel, content] of Object.entries(files)) { + const filePath = path.join(dir, rel) + fs.mkdirSync(path.dirname(filePath), { recursive: true }) + fs.writeFileSync(filePath, content) + } + return dir + } + + it('returns null for files outside any package', () => { + expect(deriveBareSpecifier(path.join(tmpRoot, 'random.js'))).toBe(null) + }) + + it('handles wildcard subpath exports', () => { + const dir = makePackage( + 'vinext', + { exports: { './shims/*': './dist/shims/*.js' } }, + { 'dist/shims/foo.js': '' }, + ) + expect(deriveBareSpecifier(path.join(dir, 'dist/shims/foo.js'))).toBe( + 'vinext/shims/foo', + ) + }) + + it('handles literal subpath exports', () => { + const dir = makePackage( + 'pkg', + { exports: { './cache': './dist/cache.js' } }, + { 'dist/cache.js': '' }, + ) + expect(deriveBareSpecifier(path.join(dir, 'dist/cache.js'))).toBe( + 'pkg/cache', + ) + }) + + it('handles main entry "."', () => { + const dir = makePackage( + 'pkg', + { exports: { '.': './dist/index.js' } }, + { 'dist/index.js': '' }, + ) + expect(deriveBareSpecifier(path.join(dir, 'dist/index.js'))).toBe('pkg') + }) + + it('handles conditional exports (prefers import)', () => { + const dir = makePackage( + 'pkg', + { + exports: { + './shims/*': { + import: './dist/shims/*.js', + require: './dist/shims/*.cjs', + }, + }, + }, + { 'dist/shims/foo.js': '', 'dist/shims/foo.cjs': '' }, + ) + expect(deriveBareSpecifier(path.join(dir, 'dist/shims/foo.js'))).toBe( + 'pkg/shims/foo', + ) + }) + + it('handles string-shorthand exports', () => { + const dir = makePackage( + 'pkg', + { exports: './dist/index.js' }, + { 'dist/index.js': '' }, + ) + expect(deriveBareSpecifier(path.join(dir, 'dist/index.js'))).toBe('pkg') + }) + + it('handles top-level conditional shorthand (no subpath keys)', () => { + const dir = makePackage( + 'pkg', + { exports: { import: './dist/index.js', require: './dist/index.cjs' } }, + { 'dist/index.js': '', 'dist/index.cjs': '' }, + ) + expect(deriveBareSpecifier(path.join(dir, 'dist/index.js'))).toBe('pkg') + }) + + it('returns null when exports field hides the file', () => { + const dir = makePackage( + 'pkg', + { exports: { './public': './dist/public.js' } }, + { 'dist/public.js': '', 'dist/private.js': '' }, + ) + expect(deriveBareSpecifier(path.join(dir, 'dist/private.js'))).toBe(null) + }) + + it('falls back to relative path when no exports field is set', () => { + const dir = makePackage( + 'pkg', + { main: 'index.js' }, + { 'lib/foo.js': '' }, + ) + expect(deriveBareSpecifier(path.join(dir, 'lib/foo.js'))).toBe( + 'pkg/lib/foo.js', + ) + }) + + it('returns null for packages without a name', () => { + const dir = path.join(tmpRoot, 'node_modules', 'nameless') + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'package.json'), '{}') + fs.writeFileSync(path.join(dir, 'foo.js'), '') + expect(deriveBareSpecifier(path.join(dir, 'foo.js'))).toBe(null) + }) + + it('prefers more specific subpath when multiple match', () => { + const dir = makePackage( + 'pkg', + { + exports: { + './*': './dist/*', + './shims/*': './dist/shims/*.js', + }, + }, + { 'dist/shims/foo.js': '' }, + ) + // The literal-with-suffix (./shims/*) is more specific than the + // catch-all (./*). + expect(deriveBareSpecifier(path.join(dir, 'dist/shims/foo.js'))).toBe( + 'pkg/shims/foo', + ) + }) +}) diff --git a/packages/plugin-rsc/src/plugins/derive-bare-specifier.ts b/packages/plugin-rsc/src/plugins/derive-bare-specifier.ts new file mode 100644 index 000000000..89ba6d5ad --- /dev/null +++ b/packages/plugin-rsc/src/plugins/derive-bare-specifier.ts @@ -0,0 +1,168 @@ +/** + * Derive a package's own bare specifier for an absolute filesystem path + * resolved into `node_modules/`. + * + * Used to populate the `packageSources` map when a `'use client'` module is + * reached via a non-bare-specifier import (e.g. a relative path between two + * files of the same package). Without this, the only way to get into the + * working `client-package-proxy` branch is to have observed the file via a + * bare-specifier import — which fails for packages that import their own + * internal client modules via relative paths. + * + * Returns the bare specifier (e.g. `"vinext/shims/foo"`) if the file is + * exposed by the package's `exports` field, or `null` otherwise. Callers + * should round-trip the result through `this.resolve()` to confirm it + * resolves back to the same id before relying on it. + */ +import fs from 'node:fs' +import path from 'node:path' +import { normalizePath } from 'vite' + +const SERVER_CONDITION_PRIORITY = ['import', 'module', 'default', 'node'] + +/** + * Walk up from `absolutePath` looking for the closest enclosing `package.json`. + * Returns the directory containing it, or `null`. + */ +function findClosestPackageRoot(absolutePath: string): string | null { + let dir = path.dirname(absolutePath) + // eslint-disable-next-line no-constant-condition + while (true) { + if (fs.existsSync(path.join(dir, 'package.json'))) return dir + const parent = path.dirname(dir) + if (parent === dir) return null + dir = parent + } +} + +/** + * Walk a (possibly nested) conditional-exports value and return the first + * string target reachable via server-relevant conditions. Falls back to the + * first string target if no preferred condition matches. + */ +function pickConditionTarget(value: unknown): string | null { + if (typeof value === 'string') return value + if (Array.isArray(value)) { + for (const entry of value) { + const r = pickConditionTarget(entry) + if (r !== null) return r + } + return null + } + if (value === null || typeof value !== 'object') return null + const obj = value as Record + for (const cond of SERVER_CONDITION_PRIORITY) { + if (cond in obj) { + const r = pickConditionTarget(obj[cond]) + if (r !== null) return r + } + } + // Fall back to any condition that yields a string target. + for (const v of Object.values(obj)) { + const r = pickConditionTarget(v) + if (r !== null) return r + } + return null +} + +/** + * Specificity for sorting subpath keys: literal keys outrank patterns; among + * patterns, longer prefixes win. + */ +function subpathSpecificity(key: string): number { + const star = key.indexOf('*') + if (star === -1) return key.length + 100_000 + return star +} + +/** + * Reverse-map `fileRelative` (e.g. `"./dist/shims/foo.js"`) to the subpath + * exposed by the `exports` field (e.g. `"./shims/foo"`). Returns `null` if no + * subpath exposes this file. + */ +function reverseExports( + exports: unknown, + fileRelative: string, +): string | null { + // Sugar: `"exports": "./foo.js"` is shorthand for `{ ".": "./foo.js" }`. + if (typeof exports === 'string') { + return exports === fileRelative ? '.' : null + } + // Conditional-only at top level (no subpath keys) is also shorthand for `.`. + if ( + exports !== null && + typeof exports === 'object' && + !Array.isArray(exports) && + !Object.keys(exports).some((k) => k === '.' || k.startsWith('./')) + ) { + const target = pickConditionTarget(exports) + return target === fileRelative ? '.' : null + } + if (exports === null || typeof exports !== 'object' || Array.isArray(exports)) { + return null + } + + const entries = Object.entries(exports as Record) + entries.sort(([a], [b]) => subpathSpecificity(b) - subpathSpecificity(a)) + + for (const [key, value] of entries) { + const target = pickConditionTarget(value) + if (target === null) continue + + const keyStar = key.indexOf('*') + const targetStar = target.indexOf('*') + if ((keyStar === -1) !== (targetStar === -1)) continue + + if (keyStar === -1) { + if (target === fileRelative) return key + } else { + const targetPrefix = target.slice(0, targetStar) + const targetSuffix = target.slice(targetStar + 1) + if ( + fileRelative.startsWith(targetPrefix) && + fileRelative.endsWith(targetSuffix) && + fileRelative.length >= targetPrefix.length + targetSuffix.length + ) { + const wildcard = fileRelative.slice( + targetPrefix.length, + fileRelative.length - targetSuffix.length, + ) + if (wildcard.length === 0) continue + return key.slice(0, keyStar) + wildcard + key.slice(keyStar + 1) + } + } + } + return null +} + +/** + * Try to derive the bare specifier (e.g. `"vinext/shims/foo"`) under which + * `absolutePath` is exposed. Returns `null` if the file isn't reachable via a + * bare specifier (no enclosing package, package.json has no `name`, exports + * field hides this subpath, etc). + */ +export function deriveBareSpecifier(absolutePath: string): string | null { + const pkgRoot = findClosestPackageRoot(absolutePath) + if (!pkgRoot) return null + let pkg: { name?: unknown; exports?: unknown } + try { + pkg = JSON.parse( + fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf-8'), + ) + } catch { + return null + } + if (typeof pkg.name !== 'string' || pkg.name.length === 0) return null + + const fileRelative = + './' + normalizePath(path.relative(pkgRoot, absolutePath)) + + // No exports field: any deep import works (Node's pre-exports behavior). + if (pkg.exports === undefined) { + return pkg.name + fileRelative.slice(1) + } + + const subpath = reverseExports(pkg.exports, fileRelative) + if (subpath === null) return null + return pkg.name + (subpath === '.' ? '' : subpath.slice(1)) +}