From 3f8d153c1a5fbd5599afa2348e891758cb4d0912 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 12:12:51 +0100 Subject: [PATCH 1/4] fix(rsc): crawl linked packages for React libraries to keep on the server --- .../plugin-rsc/e2e/linked-package.test.ts | 212 ++++++++++++++++++ packages/plugin-rsc/src/plugin.ts | 9 +- .../plugins/crawl-framework-packages.test.ts | 151 +++++++++++++ .../src/plugins/crawl-framework-packages.ts | 83 +++++++ 4 files changed, 450 insertions(+), 5 deletions(-) create mode 100644 packages/plugin-rsc/e2e/linked-package.test.ts create mode 100644 packages/plugin-rsc/src/plugins/crawl-framework-packages.test.ts create mode 100644 packages/plugin-rsc/src/plugins/crawl-framework-packages.ts diff --git a/packages/plugin-rsc/e2e/linked-package.test.ts b/packages/plugin-rsc/e2e/linked-package.test.ts new file mode 100644 index 000000000..a7ab2c922 --- /dev/null +++ b/packages/plugin-rsc/e2e/linked-package.test.ts @@ -0,0 +1,212 @@ +import fs from 'node:fs' +import path from 'node:path' +import { expect, test } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' +import { waitForHydration } from './helper' + +test.describe(() => { + const root = 'examples/e2e/temp/linked-package' + + test.beforeAll(async () => { + // Dependency and import graph: + // + // app + // `-- @vitejs/test-workspace-ui (linked, "react" in "dependencies") + // |-- @vitejs/test-client-lib ("react" in "peerDependencies") + // `-- @vitejs/test-workspace-nested (linked, "react" in "dependencies") + // `-- @vitejs/test-nested-client-lib ("react" in "peerDependencies") + // + // Linked packages resolve outside node_modules, so Vite treats them as + // source and discovers their bare imports for pre-bundling. The client + // libraries are only reachable through the linked packages, and their + // "use client" directive sits on an internal module that pre-bundling + // would drop, so they must be excluded from the server optimizers. + const json = (value: unknown) => JSON.stringify(value, null, 2) + const clientLib = (name: string, label: string) => ({ + [`${name}/package.json`]: json({ + name: name.split('node_modules/')[1], + version: '1.0.0', + type: 'module', + exports: './index.js', + peerDependencies: { + react: '*', + }, + }), + [`${name}/index.js`]: /* js */ ` + export { TestClient } from './client.js' + `, + [`${name}/client.js`]: /* js */ ` + 'use client' + + import React from 'react' + + const CountContext = React.createContext(0) + + function Count() { + return ${JSON.stringify(label + ': ')} + React.useContext(CountContext) + } + + export function TestClient() { + const [count, setCount] = React.useState(0) + return React.createElement( + CountContext.Provider, + { value: count }, + React.createElement( + 'button', + { onClick: () => setCount((value) => value + 1) }, + React.createElement(Count), + ), + ) + } + `, + }) + await setupInlineFixture({ + src: 'examples/starter-extra', + dest: root, + files: { + 'package.json': { + edit: (source) => { + const packageJson = JSON.parse(source) + packageJson.dependencies = { + ...packageJson.dependencies, + '@vitejs/test-workspace-ui': '0.0.0', + } + return JSON.stringify(packageJson, null, 2) + '\n' + }, + }, + 'src/root.tsx': /* tsx */ ` + import { TestWorkspace } from '@vitejs/test-workspace-ui/server' + + export function Root() { + return ( + + + + + + ) + } + `, + 'packages/ui/package.json': json({ + name: '@vitejs/test-workspace-ui', + version: '0.0.0', + private: true, + type: 'module', + exports: { + './server': './server.js', + }, + dependencies: { + '@vitejs/test-client-lib': '1.0.0', + '@vitejs/test-workspace-nested': '0.0.0', + react: '*', + }, + }), + 'packages/ui/server.js': /* js */ ` + import { TestClient } from '@vitejs/test-client-lib' + import { TestNested } from '@vitejs/test-workspace-nested/server' + import React from 'react' + + export function TestWorkspace() { + return React.createElement( + 'div', + null, + React.createElement(TestClient), + React.createElement(TestNested), + ) + } + `, + ...clientLib( + 'packages/ui/node_modules/@vitejs/test-client-lib', + 'Workspace client', + ), + 'packages/nested/package.json': json({ + name: '@vitejs/test-workspace-nested', + version: '0.0.0', + private: true, + type: 'module', + exports: { + './server': './server.js', + }, + dependencies: { + '@vitejs/test-nested-client-lib': '1.0.0', + react: '*', + }, + }), + 'packages/nested/server.js': /* js */ ` + import { TestClient } from '@vitejs/test-nested-client-lib' + import React from 'react' + + export function TestNested() { + return React.createElement(TestClient) + } + `, + ...clientLib( + 'packages/nested/node_modules/@vitejs/test-nested-client-lib', + 'Nested workspace client', + ), + }, + }) + // link workspace packages like a package manager would + const link = (target: string, linkPath: string) => { + const absoluteLinkPath = path.join(root, linkPath) + fs.mkdirSync(path.dirname(absoluteLinkPath), { recursive: true }) + fs.symlinkSync( + path.relative(path.dirname(absoluteLinkPath), path.join(root, target)), + absoluteLinkPath, + 'dir', + ) + } + link('packages/ui', 'node_modules/@vitejs/test-workspace-ui') + link( + 'packages/nested', + 'packages/ui/node_modules/@vitejs/test-workspace-nested', + ) + }) + + function defineTest(f: ReturnType) { + test('client libraries imported by linked packages', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await page + .getByRole('button', { name: 'Workspace client: 0', exact: true }) + .click() + await expect( + page.getByRole('button', { name: 'Workspace client: 1', exact: true }), + ).toBeVisible() + await page + .getByRole('button', { + name: 'Nested workspace client: 0', + exact: true, + }) + .click() + await expect( + page.getByRole('button', { + name: 'Nested workspace client: 1', + exact: true, + }), + ).toBeVisible() + }) + } + + for (const mode of ['dev', 'build'] as const) { + test.describe(mode, () => { + defineTest(useFixture({ root, mode })) + }) + } + + // `@cloudflare/vite-plugin` enables dependency discovery in the `rsc` environment + test.describe('dev-cloudflare', () => { + defineTest(useFixture({ root, mode: 'dev', command: 'pnpm cf:dev' })) + }) + + test.describe('build-cloudflare', () => { + defineTest( + useFixture({ + root, + mode: 'build', + buildCommand: 'pnpm cf:build', + command: 'pnpm cf:preview', + }), + ) + }) +}) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 6cb24453e..b4d25be72 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -25,9 +25,9 @@ import { normalizePath, parseAstAsync, } from 'vite' -import { crawlFrameworkPkgs } from 'vitefu' import vitePluginRscCore from './core/plugin' import { cjsModuleRunnerPlugin } from './plugins/cjs' +import { crawlFrameworkPackages } from './plugins/crawl-framework-packages' import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url' import { ensureEnvironmentImportsEntryFallback, @@ -511,8 +511,7 @@ export default function vitePluginRsc( } // crawl packages with "react" in "peerDependencies" to bundle react deps on server // see https://github.com/svitejs/vitefu/blob/d8d82fa121e3b2215ba437107093c77bde51b63b/src/index.js#L95-L101 - const result = await crawlFrameworkPkgs({ - root: process.cwd(), + const frameworkPackages = await crawlFrameworkPackages(process.cwd(), { isBuild: env.command === 'build', isFrameworkPkgByJson(pkgJson) { if ([PKG_NAME, 'react-dom'].includes(pkgJson.name)) { @@ -528,13 +527,13 @@ export default function vitePluginRsc( 'server-only', 'client-only', PKG_NAME, - ...result.ssr.noExternal.sort(), + ...frameworkPackages.sort(), ] const optimizeDepsExclude = noExternal.filter( (pkg) => !['react', 'react-dom', 'react-server-dom-webpack'].includes(pkg), ) - hasReactServerDomWebpack = result.ssr.noExternal.includes( + hasReactServerDomWebpack = frameworkPackages.includes( 'react-server-dom-webpack', ) const reactServerDomPackageName = hasReactServerDomWebpack diff --git a/packages/plugin-rsc/src/plugins/crawl-framework-packages.test.ts b/packages/plugin-rsc/src/plugins/crawl-framework-packages.test.ts new file mode 100644 index 000000000..687211072 --- /dev/null +++ b/packages/plugin-rsc/src/plugins/crawl-framework-packages.test.ts @@ -0,0 +1,151 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { crawlFrameworkPackages } from './crawl-framework-packages' + +describe(crawlFrameworkPackages, () => { + let root: string + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'rsc-crawl-')) + }) + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }) + }) + + function writePackage(dir: string, pkgJson: Record) { + fs.mkdirSync(path.join(root, dir), { recursive: true }) + fs.writeFileSync( + path.join(root, dir, 'package.json'), + JSON.stringify({ version: '1.0.0', ...pkgJson }), + ) + } + + // install a package into `node_modules` of `parent` + function installPackage( + parent: string, + pkgJson: { name: string } & Record, + ) { + writePackage(path.join(parent, 'node_modules', pkgJson.name), pkgJson) + } + + // link a package outside `node_modules` into `node_modules` of `parent`, like a workspace package + function linkPackage(parent: string, name: string, target: string) { + const linkPath = path.join(root, parent, 'node_modules', name) + fs.mkdirSync(path.dirname(linkPath), { recursive: true }) + fs.symlinkSync( + path.relative(path.dirname(linkPath), path.join(root, target)), + linkPath, + 'dir', + ) + } + + function crawl() { + return crawlFrameworkPackages(path.join(root, 'app'), { + isBuild: false, + isFrameworkPkgByJson(pkgJson) { + const deps = pkgJson['peerDependencies'] + return deps && 'react' in deps + }, + }) + } + + const reactLibrary = (name: string, extra?: Record) => ({ + name, + peerDependencies: { react: '*' }, + ...extra, + }) + + it('framework packages of the app', async () => { + writePackage('app', { name: 'app', dependencies: { 'react-lib': '*' } }) + installPackage('app', reactLibrary('react-lib')) + expect(await crawl()).toEqual(['react-lib']) + }) + + it('framework packages imported only by a linked package', async () => { + writePackage('app', { name: 'app', dependencies: { ui: '*' } }) + writePackage('packages/ui', { + name: 'ui', + dependencies: { react: '*', 'react-lib': '*' }, + }) + linkPackage('app', 'ui', 'packages/ui') + installPackage('packages/ui', reactLibrary('react-lib')) + installPackage('packages/ui', { name: 'react' }) + expect(await crawl()).toEqual(['react-lib']) + }) + + it('linked packages of linked packages', async () => { + writePackage('app', { name: 'app', dependencies: { ui: '*' } }) + writePackage('packages/ui', { + name: 'ui', + dependencies: { primitives: '*' }, + }) + writePackage('packages/primitives', { + name: 'primitives', + dependencies: { 'react-lib': '*' }, + }) + linkPackage('app', 'ui', 'packages/ui') + linkPackage('packages/ui', 'primitives', 'packages/primitives') + installPackage('packages/primitives', reactLibrary('react-lib')) + expect(await crawl()).toEqual(['react-lib']) + }) + + it('devDependencies of linked packages', async () => { + writePackage('app', { name: 'app', devDependencies: { ui: '*' } }) + writePackage('packages/ui', { + name: 'ui', + peerDependencies: { 'react-lib': '*' }, + devDependencies: { 'react-lib': '*' }, + }) + linkPackage('app', 'ui', 'packages/ui') + installPackage('packages/ui', reactLibrary('react-lib')) + expect(await crawl()).toEqual(['react-lib']) + }) + + it('dependencies of framework packages reached through a linked package', async () => { + writePackage('app', { name: 'app', dependencies: { ui: '*' } }) + writePackage('packages/ui', { + name: 'ui', + dependencies: { 'react-lib': '*' }, + }) + linkPackage('app', 'ui', 'packages/ui') + installPackage( + 'packages/ui', + reactLibrary('react-lib', { dependencies: { 'react-lib-dep': '*' } }), + ) + installPackage( + 'packages/ui/node_modules/react-lib', + reactLibrary('react-lib-dep'), + ) + expect((await crawl()).sort()).toEqual(['react-lib', 'react-lib-dep']) + }) + + it('skips non-framework packages installed in node_modules', async () => { + // not linked, so vitefu's rule still applies: Vite pre-bundles it as a + // whole, including the React libraries it imports + writePackage('app', { name: 'app', dependencies: { 'non-react': '*' } }) + installPackage('app', { + name: 'non-react', + dependencies: { 'react-lib': '*' }, + }) + installPackage('app/node_modules/non-react', reactLibrary('react-lib')) + expect(await crawl()).toEqual([]) + }) + + it('handles linked packages that depend on each other', async () => { + writePackage('app', { name: 'app', dependencies: { a: '*' } }) + writePackage('packages/a', { name: 'a', dependencies: { b: '*' } }) + writePackage('packages/b', { + name: 'b', + dependencies: { a: '*', app: '*', 'react-lib': '*' }, + }) + linkPackage('app', 'a', 'packages/a') + linkPackage('packages/a', 'b', 'packages/b') + linkPackage('packages/b', 'a', 'packages/a') + linkPackage('packages/b', 'app', 'app') + installPackage('packages/b', reactLibrary('react-lib')) + expect(await crawl()).toEqual(['react-lib']) + }) +}) diff --git a/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts b/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts new file mode 100644 index 000000000..bf043d219 --- /dev/null +++ b/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts @@ -0,0 +1,83 @@ +import fs from 'node:fs' +import path from 'node:path' +import { + crawlFrameworkPkgs, + findClosestPkgJsonPath, + findDepPkgJsonPath, +} from 'vitefu' + +type CrawlOptions = Pick< + Parameters[0], + 'isBuild' | 'isFrameworkPkgByJson' +> + +/** + * Crawl the framework packages of the app at `root` and of every linked + * package it depends on, returning their names for `ssr.noExternal`. + * + * vitefu only descends into framework packages, so React libraries imported + * by a workspace package that lists "react" in "dependencies" are missed. + * Linked packages resolve outside node_modules and Vite treats them as source, + * so the libraries they import need the same handling as the app's own. + */ +export async function crawlFrameworkPackages( + root: string, + options: CrawlOptions, +): Promise { + const result = await crawlFrameworkPkgs({ root, ...options }) + const names = new Set(result.ssr.noExternal) + for (const dir of await findLinkedPackageDirs(root)) { + const linked = await crawlFrameworkPkgs({ + root: dir, + ...options, + // skip packages found already, along with their dependencies + isFrameworkPkgByName: (name) => (names.has(name) ? false : undefined), + }) + for (const name of linked.ssr.noExternal) { + names.add(name) + } + } + return [...names] +} + +async function findLinkedPackageDirs(root: string): Promise { + const rootPkgJsonPath = await findClosestPkgJsonPath(root) + if (!rootPkgJsonPath) { + return [] + } + const seen = new Set([fs.realpathSync(rootPkgJsonPath)]) + const dirs: string[] = [] + const queue = [rootPkgJsonPath] + while (queue.length > 0) { + const pkgJsonPath = queue.shift()! + let pkgJson: Record + try { + pkgJson = JSON.parse(await fs.promises.readFile(pkgJsonPath, 'utf-8')) + } catch { + continue + } + const deps = [ + ...Object.keys(pkgJson['dependencies'] ?? {}), + ...Object.keys(pkgJson['devDependencies'] ?? {}), + ] + await Promise.all( + deps.map(async (dep) => { + const depPkgJsonPath = await findDepPkgJsonPath( + dep, + path.dirname(pkgJsonPath), + ) + if ( + !depPkgJsonPath || + seen.has(depPkgJsonPath) || + /[/\\]node_modules[/\\]/.test(depPkgJsonPath) + ) { + return + } + seen.add(depPkgJsonPath) + dirs.push(path.dirname(depPkgJsonPath)) + queue.push(depPkgJsonPath) + }), + ) + } + return dirs.sort() +} From 3825060398f565248e1a74db800e3dd0fe9ef8b4 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 12:27:22 +0100 Subject: [PATCH 2/4] refactor(rsc): skip unreadable linked packages and simplify linked package crawl --- .../plugin-rsc/e2e/linked-package.test.ts | 91 ++++++++++--------- .../plugins/crawl-framework-packages.test.ts | 24 ++++- .../src/plugins/crawl-framework-packages.ts | 74 ++++++++++----- 3 files changed, 116 insertions(+), 73 deletions(-) diff --git a/packages/plugin-rsc/e2e/linked-package.test.ts b/packages/plugin-rsc/e2e/linked-package.test.ts index a7ab2c922..f9f33f39c 100644 --- a/packages/plugin-rsc/e2e/linked-package.test.ts +++ b/packages/plugin-rsc/e2e/linked-package.test.ts @@ -22,44 +22,48 @@ test.describe(() => { // "use client" directive sits on an internal module that pre-bundling // would drop, so they must be excluded from the server optimizers. const json = (value: unknown) => JSON.stringify(value, null, 2) - const clientLib = (name: string, label: string) => ({ - [`${name}/package.json`]: json({ - name: name.split('node_modules/')[1], - version: '1.0.0', - type: 'module', - exports: './index.js', - peerDependencies: { - react: '*', - }, - }), - [`${name}/index.js`]: /* js */ ` - export { TestClient } from './client.js' - `, - [`${name}/client.js`]: /* js */ ` - 'use client' + // a React library whose "use client" directive is on an internal module + const clientLibrary = (parent: string, name: string, label: string) => { + const dir = `${parent}/node_modules/${name}` + return { + [`${dir}/package.json`]: json({ + name, + version: '1.0.0', + type: 'module', + exports: './index.js', + peerDependencies: { + react: '*', + }, + }), + [`${dir}/index.js`]: /* js */ ` + export { TestClient } from './client.js' + `, + [`${dir}/client.js`]: /* js */ ` + 'use client' - import React from 'react' + import React from 'react' - const CountContext = React.createContext(0) + const CountContext = React.createContext(0) - function Count() { - return ${JSON.stringify(label + ': ')} + React.useContext(CountContext) - } + function Count() { + return ${JSON.stringify(label + ': ')} + React.useContext(CountContext) + } - export function TestClient() { - const [count, setCount] = React.useState(0) - return React.createElement( - CountContext.Provider, - { value: count }, - React.createElement( - 'button', - { onClick: () => setCount((value) => value + 1) }, - React.createElement(Count), - ), - ) - } - `, - }) + export function TestClient() { + const [count, setCount] = React.useState(0) + return React.createElement( + CountContext.Provider, + { value: count }, + React.createElement( + 'button', + { onClick: () => setCount((value) => value + 1) }, + React.createElement(Count), + ), + ) + } + `, + } + } await setupInlineFixture({ src: 'examples/starter-extra', dest: root, @@ -115,8 +119,9 @@ test.describe(() => { ) } `, - ...clientLib( - 'packages/ui/node_modules/@vitejs/test-client-lib', + ...clientLibrary( + 'packages/ui', + '@vitejs/test-client-lib', 'Workspace client', ), 'packages/nested/package.json': json({ @@ -140,21 +145,19 @@ test.describe(() => { return React.createElement(TestClient) } `, - ...clientLib( - 'packages/nested/node_modules/@vitejs/test-nested-client-lib', + ...clientLibrary( + 'packages/nested', + '@vitejs/test-nested-client-lib', 'Nested workspace client', ), }, }) // link workspace packages like a package manager would const link = (target: string, linkPath: string) => { - const absoluteLinkPath = path.join(root, linkPath) + const absoluteLinkPath = path.resolve(root, linkPath) fs.mkdirSync(path.dirname(absoluteLinkPath), { recursive: true }) - fs.symlinkSync( - path.relative(path.dirname(absoluteLinkPath), path.join(root, target)), - absoluteLinkPath, - 'dir', - ) + // a junction doesn't need extra privileges on Windows (ignored elsewhere) + fs.symlinkSync(path.resolve(root, target), absoluteLinkPath, 'junction') } link('packages/ui', 'node_modules/@vitejs/test-workspace-ui') link( diff --git a/packages/plugin-rsc/src/plugins/crawl-framework-packages.test.ts b/packages/plugin-rsc/src/plugins/crawl-framework-packages.test.ts index 687211072..852ab2fdf 100644 --- a/packages/plugin-rsc/src/plugins/crawl-framework-packages.test.ts +++ b/packages/plugin-rsc/src/plugins/crawl-framework-packages.test.ts @@ -35,11 +35,8 @@ describe(crawlFrameworkPackages, () => { function linkPackage(parent: string, name: string, target: string) { const linkPath = path.join(root, parent, 'node_modules', name) fs.mkdirSync(path.dirname(linkPath), { recursive: true }) - fs.symlinkSync( - path.relative(path.dirname(linkPath), path.join(root, target)), - linkPath, - 'dir', - ) + // a junction doesn't need extra privileges on Windows (ignored elsewhere) + fs.symlinkSync(path.join(root, target), linkPath, 'junction') } function crawl() { @@ -134,6 +131,23 @@ describe(crawlFrameworkPackages, () => { expect(await crawl()).toEqual([]) }) + it('skips linked packages without a valid package.json', async () => { + writePackage('app', { + name: 'app', + dependencies: { broken: '*', ui: '*' }, + }) + writePackage('packages/ui', { + name: 'ui', + dependencies: { 'react-lib': '*' }, + }) + fs.mkdirSync(path.join(root, 'packages/broken'), { recursive: true }) + fs.writeFileSync(path.join(root, 'packages/broken/package.json'), '{') + linkPackage('app', 'broken', 'packages/broken') + linkPackage('app', 'ui', 'packages/ui') + installPackage('packages/ui', reactLibrary('react-lib')) + expect(await crawl()).toEqual(['react-lib']) + }) + it('handles linked packages that depend on each other', async () => { writePackage('app', { name: 'app', dependencies: { a: '*' } }) writePackage('packages/a', { name: 'a', dependencies: { b: '*' } }) diff --git a/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts b/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts index bf043d219..7ac0c8581 100644 --- a/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts +++ b/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts @@ -1,24 +1,31 @@ import fs from 'node:fs' import path from 'node:path' import { + type CrawlFrameworkPkgsOptions, crawlFrameworkPkgs, findClosestPkgJsonPath, findDepPkgJsonPath, } from 'vitefu' type CrawlOptions = Pick< - Parameters[0], + CrawlFrameworkPkgsOptions, 'isBuild' | 'isFrameworkPkgByJson' > +type PackageJson = { + dependencies?: Record + devDependencies?: Record +} + /** * Crawl the framework packages of the app at `root` and of every linked * package it depends on, returning their names for `ssr.noExternal`. * - * vitefu only descends into framework packages, so React libraries imported - * by a workspace package that lists "react" in "dependencies" are missed. - * Linked packages resolve outside node_modules and Vite treats them as source, - * so the libraries they import need the same handling as the app's own. + * vitefu only descends into framework packages, so it misses the React + * libraries imported by a workspace package that lists "react" in + * "dependencies". Linked packages resolve outside node_modules and Vite treats + * them as source, so the libraries they import need the same handling as the + * app's own. */ export async function crawlFrameworkPackages( root: string, @@ -26,11 +33,12 @@ export async function crawlFrameworkPackages( ): Promise { const result = await crawlFrameworkPkgs({ root, ...options }) const names = new Set(result.ssr.noExternal) + // crawl one at a time so each crawl skips the packages found by earlier + // ones, along with their dependencies for (const dir of await findLinkedPackageDirs(root)) { const linked = await crawlFrameworkPkgs({ root: dir, ...options, - // skip packages found already, along with their dependencies isFrameworkPkgByName: (name) => (names.has(name) ? false : undefined), }) for (const name of linked.ssr.noExternal) { @@ -40,44 +48,62 @@ export async function crawlFrameworkPackages( return [...names] } +/** + * Find the linked packages that `root` depends on, directly or through other + * linked packages: workspace packages and `link:`/`file:` dependencies, which + * resolve outside node_modules. + */ async function findLinkedPackageDirs(root: string): Promise { const rootPkgJsonPath = await findClosestPkgJsonPath(root) - if (!rootPkgJsonPath) { + const rootPkgJson = rootPkgJsonPath && (await readPkgJson(rootPkgJsonPath)) + if (!rootPkgJsonPath || !rootPkgJson) { return [] } - const seen = new Set([fs.realpathSync(rootPkgJsonPath)]) + const visited = new Set([fs.realpathSync(rootPkgJsonPath)]) const dirs: string[] = [] - const queue = [rootPkgJsonPath] - while (queue.length > 0) { - const pkgJsonPath = queue.shift()! - let pkgJson: Record - try { - pkgJson = JSON.parse(await fs.promises.readFile(pkgJsonPath, 'utf-8')) - } catch { - continue - } + + async function visitDependencies(pkgJsonPath: string, pkgJson: PackageJson) { const deps = [ - ...Object.keys(pkgJson['dependencies'] ?? {}), - ...Object.keys(pkgJson['devDependencies'] ?? {}), + ...Object.keys(pkgJson.dependencies ?? {}), + ...Object.keys(pkgJson.devDependencies ?? {}), ] await Promise.all( deps.map(async (dep) => { + // the real path, so linked packages point outside node_modules const depPkgJsonPath = await findDepPkgJsonPath( dep, path.dirname(pkgJsonPath), ) if ( !depPkgJsonPath || - seen.has(depPkgJsonPath) || - /[/\\]node_modules[/\\]/.test(depPkgJsonPath) + isInNodeModules(depPkgJsonPath) || + visited.has(depPkgJsonPath) ) { return } - seen.add(depPkgJsonPath) - dirs.push(path.dirname(depPkgJsonPath)) - queue.push(depPkgJsonPath) + visited.add(depPkgJsonPath) + const depPkgJson = await readPkgJson(depPkgJsonPath) + if (depPkgJson) { + dirs.push(path.dirname(depPkgJsonPath)) + await visitDependencies(depPkgJsonPath, depPkgJson) + } }), ) } + + await visitDependencies(rootPkgJsonPath, rootPkgJson) + // sort for a stable crawl order, since dependencies resolve concurrently return dirs.sort() } + +function isInNodeModules(file: string): boolean { + return /[/\\]node_modules[/\\]/.test(file) +} + +async function readPkgJson(file: string): Promise { + try { + return JSON.parse(await fs.promises.readFile(file, 'utf-8')) + } catch { + return undefined + } +} From a3fa6c4741ddcd026a2725cd1a982affb3de8bb6 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 12:48:14 +0100 Subject: [PATCH 3/4] perf(rsc): skip packages already checked by earlier crawls Also run the linked package e2e test with hoisted dependencies, where builds externalize the client libraries without the linked crawl. --- .../plugin-rsc/e2e/linked-package.test.ts | 33 +++++++++++++------ .../src/plugins/crawl-framework-packages.ts | 26 ++++++++++----- 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/packages/plugin-rsc/e2e/linked-package.test.ts b/packages/plugin-rsc/e2e/linked-package.test.ts index f9f33f39c..9a2fa47d8 100644 --- a/packages/plugin-rsc/e2e/linked-package.test.ts +++ b/packages/plugin-rsc/e2e/linked-package.test.ts @@ -4,8 +4,19 @@ import { expect, test } from '@playwright/test' import { setupInlineFixture, useFixture } from './fixture' import { waitForHydration } from './helper' -test.describe(() => { - const root = 'examples/e2e/temp/linked-package' +// "isolated" installs each package's dependencies next to it, like pnpm. +// "hoisted" installs every dependency in the app's node_modules, like npm. +for (const layout of ['isolated', 'hoisted'] as const) { + test.describe(layout, () => { + defineLinkedPackageTests(layout) + }) +} + +function defineLinkedPackageTests(layout: 'isolated' | 'hoisted') { + const root = `examples/e2e/temp/linked-package-${layout}` + // the node_modules that the dependencies of the package at `dir` go in + const nodeModules = (dir: string) => + layout === 'hoisted' ? 'node_modules' : path.posix.join(dir, 'node_modules') test.beforeAll(async () => { // Dependency and import graph: @@ -17,14 +28,16 @@ test.describe(() => { // `-- @vitejs/test-nested-client-lib ("react" in "peerDependencies") // // Linked packages resolve outside node_modules, so Vite treats them as - // source and discovers their bare imports for pre-bundling. The client - // libraries are only reachable through the linked packages, and their - // "use client" directive sits on an internal module that pre-bundling - // would drop, so they must be excluded from the server optimizers. + // source. The client libraries are only reachable through the linked + // packages, and their "use client" directive sits on an internal module. + // Unless they're in "noExternal" and excluded from the server optimizers, + // dev externalizes them, or pre-bundles them and drops the directive when + // dependency discovery is enabled. Builds only externalize dependencies + // that resolve from the app, so they fail in the hoisted layout. const json = (value: unknown) => JSON.stringify(value, null, 2) // a React library whose "use client" directive is on an internal module const clientLibrary = (parent: string, name: string, label: string) => { - const dir = `${parent}/node_modules/${name}` + const dir = `${nodeModules(parent)}/${name}` return { [`${dir}/package.json`]: json({ name, @@ -159,10 +172,10 @@ test.describe(() => { // a junction doesn't need extra privileges on Windows (ignored elsewhere) fs.symlinkSync(path.resolve(root, target), absoluteLinkPath, 'junction') } - link('packages/ui', 'node_modules/@vitejs/test-workspace-ui') + link('packages/ui', `${nodeModules('.')}/@vitejs/test-workspace-ui`) link( 'packages/nested', - 'packages/ui/node_modules/@vitejs/test-workspace-nested', + `${nodeModules('packages/ui')}/@vitejs/test-workspace-nested`, ) }) @@ -212,4 +225,4 @@ test.describe(() => { }), ) }) -}) +} diff --git a/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts b/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts index 7ac0c8581..b652ba157 100644 --- a/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts +++ b/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts @@ -7,9 +7,8 @@ import { findDepPkgJsonPath, } from 'vitefu' -type CrawlOptions = Pick< - CrawlFrameworkPkgsOptions, - 'isBuild' | 'isFrameworkPkgByJson' +type CrawlOptions = Required< + Pick > type PackageJson = { @@ -31,15 +30,26 @@ export async function crawlFrameworkPackages( root: string, options: CrawlOptions, ): Promise { - const result = await crawlFrameworkPkgs({ root, ...options }) + // names of the packages checked so far, framework packages or not + const checked = new Set() + const crawlOptions: CrawlOptions = { + ...options, + isFrameworkPkgByJson(pkgJson) { + checked.add(pkgJson.name) + return options.isFrameworkPkgByJson(pkgJson) + }, + } + const result = await crawlFrameworkPkgs({ root, ...crawlOptions }) const names = new Set(result.ssr.noExternal) - // crawl one at a time so each crawl skips the packages found by earlier - // ones, along with their dependencies + // crawl one at a time so each crawl skips the packages checked by earlier + // ones, along with their dependencies. vitefu only passes the name, so if + // linked packages install different versions of a package, only the first + // one found is checked. for (const dir of await findLinkedPackageDirs(root)) { const linked = await crawlFrameworkPkgs({ root: dir, - ...options, - isFrameworkPkgByName: (name) => (names.has(name) ? false : undefined), + ...crawlOptions, + isFrameworkPkgByName: (name) => (checked.has(name) ? false : undefined), }) for (const name of linked.ssr.noExternal) { names.add(name) From f778b657ee2f9fc0b828a4f41c00cf743452a536 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 19 Oct 2026 12:57:23 +0100 Subject: [PATCH 4/4] fix(rsc): only skip packages found by name in earlier crawls Skip the dependency names of framework packages and the package names of other packages, so a framework package installed under an npm alias is still checked under its own name. Merge names after each crawl so the app's crawl is unchanged. --- .../plugin-rsc/e2e/linked-package.test.ts | 24 ++------ .../plugins/crawl-framework-packages.test.ts | 18 ++++++ .../src/plugins/crawl-framework-packages.ts | 58 ++++++++++--------- 3 files changed, 55 insertions(+), 45 deletions(-) diff --git a/packages/plugin-rsc/e2e/linked-package.test.ts b/packages/plugin-rsc/e2e/linked-package.test.ts index 9a2fa47d8..30ef14453 100644 --- a/packages/plugin-rsc/e2e/linked-package.test.ts +++ b/packages/plugin-rsc/e2e/linked-package.test.ts @@ -183,24 +183,12 @@ function defineLinkedPackageTests(layout: 'isolated' | 'hoisted') { test('client libraries imported by linked packages', async ({ page }) => { await page.goto(f.url()) await waitForHydration(page) - await page - .getByRole('button', { name: 'Workspace client: 0', exact: true }) - .click() - await expect( - page.getByRole('button', { name: 'Workspace client: 1', exact: true }), - ).toBeVisible() - await page - .getByRole('button', { - name: 'Nested workspace client: 0', - exact: true, - }) - .click() - await expect( - page.getByRole('button', { - name: 'Nested workspace client: 1', - exact: true, - }), - ).toBeVisible() + for (const label of ['Workspace client', 'Nested workspace client']) { + const button = (count: number) => + page.getByRole('button', { name: `${label}: ${count}`, exact: true }) + await button(0).click() + await expect(button(1)).toBeVisible() + } }) } diff --git a/packages/plugin-rsc/src/plugins/crawl-framework-packages.test.ts b/packages/plugin-rsc/src/plugins/crawl-framework-packages.test.ts index 852ab2fdf..5d5e93bb7 100644 --- a/packages/plugin-rsc/src/plugins/crawl-framework-packages.test.ts +++ b/packages/plugin-rsc/src/plugins/crawl-framework-packages.test.ts @@ -119,6 +119,24 @@ describe(crawlFrameworkPackages, () => { expect((await crawl()).sort()).toEqual(['react-lib', 'react-lib-dep']) }) + it('framework packages installed under an alias elsewhere', async () => { + writePackage('app', { + name: 'app', + dependencies: { 'react-lib-v2': 'npm:react-lib@2', ui: '*' }, + }) + writePackage( + 'app/node_modules/react-lib-v2', + reactLibrary('react-lib', { version: '2.0.0' }), + ) + writePackage('packages/ui', { + name: 'ui', + dependencies: { 'react-lib': '*' }, + }) + linkPackage('app', 'ui', 'packages/ui') + installPackage('packages/ui', reactLibrary('react-lib')) + expect((await crawl()).sort()).toEqual(['react-lib', 'react-lib-v2']) + }) + it('skips non-framework packages installed in node_modules', async () => { // not linked, so vitefu's rule still applies: Vite pre-bundles it as a // whole, including the React libraries it imports diff --git a/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts b/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts index b652ba157..c42807486 100644 --- a/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts +++ b/packages/plugin-rsc/src/plugins/crawl-framework-packages.ts @@ -30,32 +30,37 @@ export async function crawlFrameworkPackages( root: string, options: CrawlOptions, ): Promise { - // names of the packages checked so far, framework packages or not + const frameworkNames = new Set() + // names of the packages checked by earlier crawls const checked = new Set() - const crawlOptions: CrawlOptions = { - ...options, - isFrameworkPkgByJson(pkgJson) { - checked.add(pkgJson.name) - return options.isFrameworkPkgByJson(pkgJson) - }, - } - const result = await crawlFrameworkPkgs({ root, ...crawlOptions }) - const names = new Set(result.ssr.noExternal) // crawl one at a time so each crawl skips the packages checked by earlier - // ones, along with their dependencies. vitefu only passes the name, so if - // linked packages install different versions of a package, only the first - // one found is checked. - for (const dir of await findLinkedPackageDirs(root)) { - const linked = await crawlFrameworkPkgs({ + // ones, along with their dependencies. vitefu only passes the dependency + // name, so if linked packages install different versions of a package, only + // the first one found is checked. + for (const dir of [root, ...(await findLinkedPackageDirs(root))]) { + // package names, which differ from dependency names for npm aliases + const otherNames: string[] = [] + const result = await crawlFrameworkPkgs({ root: dir, - ...crawlOptions, + isBuild: options.isBuild, isFrameworkPkgByName: (name) => (checked.has(name) ? false : undefined), + isFrameworkPkgByJson(pkgJson) { + const isFrameworkPkg = options.isFrameworkPkgByJson(pkgJson) + if (!isFrameworkPkg) { + otherNames.push(pkgJson.name) + } + return isFrameworkPkg + }, }) - for (const name of linked.ssr.noExternal) { - names.add(name) + for (const name of result.ssr.noExternal) { + frameworkNames.add(name) + checked.add(name) + } + for (const name of otherNames) { + checked.add(name) } } - return [...names] + return [...frameworkNames] } /** @@ -72,7 +77,8 @@ async function findLinkedPackageDirs(root: string): Promise { const visited = new Set([fs.realpathSync(rootPkgJsonPath)]) const dirs: string[] = [] - async function visitDependencies(pkgJsonPath: string, pkgJson: PackageJson) { + // visit the dependencies of the package at `dir` + async function visitDependencies(dir: string, pkgJson: PackageJson) { const deps = [ ...Object.keys(pkgJson.dependencies ?? {}), ...Object.keys(pkgJson.devDependencies ?? {}), @@ -80,10 +86,7 @@ async function findLinkedPackageDirs(root: string): Promise { await Promise.all( deps.map(async (dep) => { // the real path, so linked packages point outside node_modules - const depPkgJsonPath = await findDepPkgJsonPath( - dep, - path.dirname(pkgJsonPath), - ) + const depPkgJsonPath = await findDepPkgJsonPath(dep, dir) if ( !depPkgJsonPath || isInNodeModules(depPkgJsonPath) || @@ -94,14 +97,15 @@ async function findLinkedPackageDirs(root: string): Promise { visited.add(depPkgJsonPath) const depPkgJson = await readPkgJson(depPkgJsonPath) if (depPkgJson) { - dirs.push(path.dirname(depPkgJsonPath)) - await visitDependencies(depPkgJsonPath, depPkgJson) + const depDir = path.dirname(depPkgJsonPath) + dirs.push(depDir) + await visitDependencies(depDir, depPkgJson) } }), ) } - await visitDependencies(rootPkgJsonPath, rootPkgJson) + await visitDependencies(path.dirname(rootPkgJsonPath), rootPkgJson) // sort for a stable crawl order, since dependencies resolve concurrently return dirs.sort() }