diff --git a/packages/plugin-rsc/e2e/client-package-resolution.test.ts b/packages/plugin-rsc/e2e/client-package-resolution.test.ts new file mode 100644 index 000000000..ba8909dc7 --- /dev/null +++ b/packages/plugin-rsc/e2e/client-package-resolution.test.ts @@ -0,0 +1,126 @@ +import { expect, test } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' +import { waitForHydration } from './helper' + +test.describe(() => { + const root = 'examples/e2e/temp/client-package-resolution' + + test.beforeAll(async () => { + // Dependency and import graph: + // + // app + // |-- @vitejs/test-client-dep@1 (exports only its root) + // `-- @vitejs/test-server-dep + // `-- @vitejs/test-client-dep@2 (exports "./client") + // + // root.tsx -> test-server-dep -> test-client-dep@2/client + // ^ resolves from the actual importer + // + // The plugin also probes v2's "./client" specifier from the app root. That + // finds v1 and throws because v1 does not export "./client", so the plugin + // must keep the fully resolved nested v2 module ID instead. + const json = (value: unknown) => JSON.stringify(value, null, 2) + await setupInlineFixture({ + src: 'examples/starter-extra', + dest: root, + files: { + 'package.json': { + edit: (source) => { + const packageJson = JSON.parse(source) + packageJson.dependencies = { + ...packageJson.dependencies, + '@vitejs/test-client-dep': '1.0.0', + '@vitejs/test-server-dep': '1.0.0', + } + return JSON.stringify(packageJson, null, 2) + '\n' + }, + }, + 'src/root.tsx': /* tsx */ ` + import { TestServer } from '@vitejs/test-server-dep/server' + + export function Root() { + return ( + + + + + + ) + } + `, + 'node_modules/@vitejs/test-server-dep/package.json': json({ + name: '@vitejs/test-server-dep', + version: '1.0.0', + type: 'module', + exports: { + './server': './server.js', + }, + dependencies: { + '@vitejs/test-client-dep': '2.0.0', + }, + peerDependencies: { + react: '*', + }, + }), + 'node_modules/@vitejs/test-server-dep/server.js': /* js */ ` + import { TestClient } from '@vitejs/test-client-dep/client' + import React from 'react' + + export function TestServer() { + return React.createElement(TestClient) + } + `, + 'node_modules/@vitejs/test-client-dep/package.json': json({ + name: '@vitejs/test-client-dep', + version: '1.0.0', + type: 'module', + exports: './index.js', + }), + 'node_modules/@vitejs/test-client-dep/index.js': /* js */ ``, + 'node_modules/@vitejs/test-server-dep/node_modules/@vitejs/test-client-dep/package.json': + json({ + name: '@vitejs/test-client-dep', + version: '2.0.0', + type: 'module', + exports: { + './client': './client.js', + }, + peerDependencies: { + react: '*', + }, + }), + 'node_modules/@vitejs/test-server-dep/node_modules/@vitejs/test-client-dep/client.js': /* js */ ` + 'use client' + + import React from 'react' + + export function TestClient() { + const [count, setCount] = React.useState(0) + return React.createElement( + 'button', + { onClick: () => setCount((value) => value + 1) }, + 'Nested client: ' + count, + ) + } + `, + }, + }) + }) + + for (const mode of ['dev', 'build'] as const) { + test.describe(mode, () => { + const f = useFixture({ root, mode }) + + test('uses the client package resolved from its importer', async ({ + page, + }) => { + await page.goto(f.url()) + await waitForHydration(page) + await page.getByRole('button', { name: 'Nested client: 0' }).click() + await expect( + page.getByRole('button', { name: 'Nested client: 1' }), + ).toBeVisible() + }) + }) + } +}) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index a87a471ee..5349ea8b5 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -1731,11 +1731,17 @@ function vitePluginUseClient( this.environment.config.root, 'index.html', ) - const resolvedAtRoot = await this.resolve( - source, - rootImporter, - options, - ) + let resolvedAtRoot + try { + resolvedAtRoot = await this.resolve( + source, + rootImporter, + options, + ) + } catch { + // A different version at the root may not export this subpath. + return + } if (!resolvedAtRoot || resolvedAtRoot.id !== resolved.id) { return }