Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/release-continuous.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 37 additions & 7 deletions packages/plugin-rsc/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -60,6 +61,7 @@ import {
cleanUrl,
directRequestRE,
evalValue,
FS_PREFIX,
normalizeViteImportAnalysisUrl,
prepareError,
} from './plugins/vite-utils'
Expand Down Expand Up @@ -1628,10 +1630,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;
`
}
Expand All @@ -1643,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: {
Expand Down
149 changes: 149 additions & 0 deletions packages/plugin-rsc/src/plugins/derive-bare-specifier.test.ts
Original file line number Diff line number Diff line change
@@ -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, string>): 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',
)
})
})
Loading
Loading