Skip to content

fix(rsc): crawl linked packages for React libraries to keep on the server - #1521

Open
james-elicx wants to merge 4 commits into
vitejs:mainfrom
james-elicx:fix/rsc-crawl-linked-packages
Open

james-elicx wants to merge 4 commits into
vitejs:mainfrom
james-elicx:fix/rsc-crawl-linked-packages

Conversation

@james-elicx

@james-elicx james-elicx commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Description

rsc crawls the app's package.json with vitefu's crawlFrameworkPkgs to find React libraries (packages with react in peerDependencies). It adds them to resolve.noExternal and to optimizeDeps.exclude in the rsc and ssr environments. vitefu only descends into packages that are themselves framework packages. As a result, React libraries imported only by a linked workspace package that lists react under dependencies are never found. The shadcn monorepo template's @workspace/ui package has this layout.

Linked packages resolve outside node_modules, so Vite treats them as source. The React libraries they import therefore get the default handling for server dependencies:

  • With the default config, the rsc environment externalizes the library, so its "use client" module runs as a Server Component: TypeError: Cannot read properties of null (reading 'useState').
  • With @cloudflare/vite-plugin, which enables dependency discovery in the rsc environment, the library is pre-bundled. That drops the "use client" directive from its internal modules: TypeError: createContext is not a function.

Builds only externalize dependencies that resolve from the app, so they fail the same way when the libraries are hoisted to the app's node_modules (npm and yarn workspaces). With pnpm's isolated layout, builds bundle them and work. The same packages work when the app depends on them directly.

This PR crawls each linked package the app depends on (recursively, through dependencies and devDependencies) as if it were the app, then merges the framework packages it finds. A package counts as linked when its real path is outside node_modules, which is the rule Vite uses to treat a dependency as source. Workspace packages, link: and file: dependencies all qualify, whether or not they're private.

Downstream report: cloudflare/vinext#4006 (a shadcn monorepo with Base UI, on Cloudflare).

Alternatives considered

  • Only private workspace packages under workspaceRoot. vitefu's isPrivateWorkspacePackage uses this rule for crawling devDependencies. It would miss link:/file: dependencies and non-private workspace packages, even though Vite treats them as source too.
  • Changing vitefu so its crawl descends into linked packages. That's possible, but vitefu is shared with other frameworks. This change only uses vitefu's public API (crawlFrameworkPkgs, findDepPkgJsonPath, findClosestPkgJsonPath).

Performance

Each linked package costs one extra crawlFrameworkPkgs call at config time. Packages checked by earlier crawls, framework packages or not, are skipped through isFrameworkPkgByName. That cuts the cost by about two thirds when linked packages share dependencies (+21 / +101 / +462 ms without it in the synthetic projects below). vitefu only passes the dependency name to isFrameworkPkgByName. So if linked packages install different versions of a package, only the first version found is checked. The app's own crawl doesn't skip anything, so it finds exactly what it found before.

Medians of 41 runs on an Apple Silicon Mac, alternating between the existing crawl and the full one:

Project Existing crawl Added by this PR
App without linked packages 16 ms < 1 ms
shadcn monorepo template (4 linked packages) 2.9 ms +4.0 ms
Synthetic: 10 / 50 / 200 linked packages 0.5 / 2.1 / 8.1 ms +8 / +31 / +122 ms

In the shadcn monorepo repro on Cloudflare, I compared dev with the newly excluded libraries against the existing pre-bundled behaviour. A page rendering 60 lucide-react icons, reachable only through the workspace package, showed no measurable difference in cold start, warm start or steady-state requests. Its build output was identical, since the repro is a pnpm workspace.

Tests

  • src/plugins/crawl-framework-packages.test.ts covers linked packages, linked packages of linked packages, devDependencies, dependencies of framework packages reached through a linked package, linked packages that depend on each other, framework packages installed under an npm alias elsewhere, linked packages with an unreadable package.json (skipped instead of failing startup), and the existing behaviour for non-linked packages. Seven of the nine cases fail without the change.
  • e2e/linked-package.test.ts runs an app with a linked package and a nested linked package, each importing a client library whose "use client" module is internal. It runs in dev and build, with and without @cloudflare/vite-plugin, for both an isolated (pnpm) and a hoisted (npm) dependency layout. Five of the eight cases fail without the change: both dev variants in each layout, and the hoisted build. The Cloudflare builds pass either way, so those cases guard against regressions.

Notes for reviewers

The pre-commit hook runs oxlint --fix on staged packages/*/src files, but oxlint.config.ts ignores packages/plugin-rsc/**. A plugin-rsc-only commit therefore fails with "No files found to lint", so I committed with --no-verify after oxfmt --check passed.

Also run the linked package e2e test with hoisted dependencies, where
builds externalize the client libraries without the linked crawl.
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.
@james-elicx
james-elicx marked this pull request as ready for review October 19, 2026 12:15
@james-elicx

Copy link
Copy Markdown
Contributor Author

I tested this PR against the repro from cloudflare/vinext#4006: a shadcn monorepo (pnpm) whose @workspace/ui package uses Base UI. vinext renders App Router pages on top of @vitejs/plugin-rsc. For this, I copied the PR's build of @vitejs/plugin-rsc (f778b657, based on 0.5.38) into the repro and checked every route in a browser: hydration, toggling a switch, clicking a counter and opening a popover. The "Without this PR" column is a control run of 0.5.38 itself.

Each cell is dev / build, on the Cloudflare target unless noted:

Scenario Without this PR With this PR
Workspace UI package (react in dependencies, shadcn template) renders a server Base UI Button 500 / ✓ ✓ / ✓
Workspace "use client" Switch, toggled in the browser 500 / ✓ ✓ / ✓
Nested: app → workspace package → workspace package → its own "use client" library 500 / ✓ ✓ / ✓
UI package declares react as a peer dependency ✓ except nested 500 / ✓ ✓ / ✓
App lists @base-ui/react itself ✓ except nested 500 / ✓ ✓ / ✓
Server-rendered Popover.Root with a client Popover.Trigger 500 / ✓ client SyntaxError / ✓
Node target (no @cloudflare/vite-plugin) ✓ / ✓ ✓ / ✓

Next.js serves all of these routes in dev and build, with both webpack and Turbopack.

The dev Popover failure is unrelated to this PR. It fails the same way without it whenever the page gets past the crash, including when the app depends on @base-ui/react directly. In dev, the package-internal client reference is served raw, and the browser then fails with a SyntaxError when importing use-sync-external-store, which is CommonJS.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant