Skip to content

Vite started from another working directory: plugin-rsc externalizes the app's React-peer dependencies, so build-time and dev PPR shell captures are skipped #1044

Open

Description

@ivogt

Status: open, waiting on upstream. The cause is in @vitejs/plugin-rsc (0.5.35): it looks for the dependencies the ssr environment must bundle starting from process.cwd(), not from the Vite root. It affects dev and build whenever Vite is started from a directory other than the app's. Until it is fixed there, run vite from the app directory. No workaround is planned in rango for now; the evaluation and the options are in the comment below.

Building an app with vite build <dir> from another working directory skips every build-time PPR shell. The build still exits 0 and each route falls back to runtime capture, so nothing fails; the prebuilt shells are just missing.

Found while verifying the fix for #1037 (PR #1045). Before that fix this build failed earlier (the build-time prerender of /build-env), which hid it.

Reproduction

With the #1037 fix (PR #1045), from the repository root:

./tests/cloudflare-basic/node_modules/.bin/vite build tests/cloudflare-basic
[rango] Shell-prerendering 5 URL(s)...
[DocumentCache] middleware: TypeError: Cannot read properties of null (reading 'useState')
    at exports.useState (node_modules/.pnpm/[email protected]/node_modules/react/cjs/react.production.js:549:33)
    at useIsSSR (node_modules/.pnpm/[email protected][email protected]/node_modules/spin-delay/src/index.ts:31:29)
    at useSpinDelay (node_modules/.pnpm/[email protected][email protected]/node_modules/spin-delay/src/index.ts:46:17)
    at NavigationProgress (tests/cloudflare-basic/src/components/NavigationProgress.tsx:8:24)
    at renderWithHooks (tests/cloudflare-basic/node_modules/.vite/deps_ssr/react-dom-server.edge.development-Dv2CV4uZ.js:2744:18)
[rango]   SHELL SKIP /ppr-shell/prerendered/alpha       (81ms) - Cannot read properties of null (reading 'useState'); route keeps runtime capture
... (all 5 shells)

The same build run inside tests/cloudflare-basic: 5 of 5 SHELL OK, 0 skipped.

Cause

plugin-rsc's rsc plugin runs crawlFrameworkPkgs({ root: process.cwd(), ... }) in its config() hook (dist/plugin-CLvIS10V.js:759) to list the packages with a react peer dependency, and puts them in the ssr environment's resolve.noExternal. From another working directory the crawl reads the wrong package.json, so the app's dependencies (here spin-delay) are externalized, Node loads them from node_modules with their own react, and the renderer in deps_ssr is a different copy: a hook called there has no dispatcher.

The dev server started from the repository root shows the same error ([rango] Dev shell capture error ... (route keeps runtime capture)), and still answers 200.

Expected

The same result from any working directory: the shells are captured, in dev and in the build. That needs plugin-rsc to crawl from the config's root (one line upstream), or rango to run the same crawl from the Vite root and add what is missing (about 15 to 25 lines, duplicating plugin-rsc).

Separately, a build that skips every shell it was asked to prerender should say so more loudly than a per-route line: today the exit code is 0.

Test

tests/cloudflare-basic built from the repository root should report SHELL OK for its five prerendered shells. #1034 added a second-working-directory build test for the node fixture (cache-versions-build.test.ts); it has no ppr build shells, which is why this was not seen there.

Activity

  1. ivogt commented on Oct 4, 2026

    @ivogt
    CollaboratorAuthor

    Evaluated. The cause is upstream, in @vitejs/plugin-rsc (0.5.35), and it affects dev as well as the build.

    Cause

    plugin-rsc's rsc plugin decides which dependencies the ssr environment must not externalize by crawling for packages with a react peer dependency, and it starts that crawl at the working directory, not at the Vite root (dist/plugin-CLvIS10V.js:759):

    const result = await crawlFrameworkPkgs({
      root: process.cwd(),
      ...

    From the repository root that reads the monorepo's package.json, so the app's own dependencies are not found. The resolved config of the capture's temporary server differs between the two runs in exactly one value, the ssr environment's resolve.noExternal:

    • from the app directory: react, react-dom, server-only, client-only, @vitejs/plugin-rsc, @rangojs/router, @testing-library/react, rango-e2e-deep-context-lib, spin-delay
    • from the repository root: react, react-dom, server-only, client-only, @vitejs/plugin-rsc

    root, cacheDir, dedupe, conditions, optimizeDeps and ssr.external are identical. So from another directory spin-delay is externalized, Node loads it from node_modules with its own react, and the renderer in deps_ssr is a different copy: no dispatcher, useState on null.

    Dev too

    vite tests/cloudflare-basic started from the repository root answers 200, and logs the same Invalid hook call / Cannot read properties of null (reading 'useState') with [rango] Dev shell capture error for /ppr-shell/prerendered/alpha (route keeps runtime capture).

    Confirmed by experiment

    Forcing the temporary server's ssr environment to resolve.noExternal: true (one line in createTempRscServer, src/vite/router-discovery.ts, build only) gives five SHELL OK from the repository root and five from the app directory. Not committed: it is blunt (every dependency goes through the optimizer) and was not tried on the node and vercel presets.

    Forwarding the main config's noExternal to the temporary server does not help: that list comes from the same crawl.

    Options

    1. Upstream: plugin-rsc crawls from the config's root. One line there; the real fix.
    2. In rango, until then: run the same crawl from the Vite root in rango's own config() hook and add what is missing to the ssr environment's noExternal, for the main config and the temporary server, dev and build. About 15 to 25 lines; it duplicates what plugin-rsc does.
    3. Document that vite has to be run from the app directory.

    Also read in the same code, not tested

    • src/vite/utils/package-resolution.ts:20: isInstalledFromNpm() checks node_modules/@rangojs/router under process.cwd().
    • src/vite/plugins/vercel-output.ts:411: let root = process.cwd() (may be overwritten in configResolved).
    • plugin-rsc makes module ids relative to process.cwd() at dist/plugin-CLvIS10V.js:1335 and :1589.
  2. changed the title [-]Build-time PPR shells are all skipped when `vite build <dir>` runs from another working directory[/-] [+]Vite started from another working directory: plugin-rsc externalizes the app's React-peer dependencies, so build-time and dev PPR shell captures are skipped[/+] on Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions