cloudflare / cloudflare/vinext
App Router SSR can hit invalid hook call when runtime resolves a different React instance than the project
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
## Summary
`vinext` already adds `resolve.dedupe` for React packages, but App Router SSR can still fail with `Invalid hook call` if the runtime entrypoints resolve `react` from a different package scope than the application.
This shows up when the install topology contains multiple physical React copies. In that situation, the app bundle can be deduped correctly by Vite, while vinext's own Node-side SSR/runtime modules still import `react` / `react-dom/server.edge` from a different scope.
The result is that the renderer and the component tree do not share the same hook dispatcher.
## Symptom
A minimal hookful component crashes during SSR with an error like:
```txt
Invalid hook call. Hooks can only be called inside of the body of a function component.
TypeError: Cannot read properties of null (reading 'useRef')
```
## Why this happens
`vinext` does the right thing at Vite config time by adding:
- `react`
- `react-dom`
- `react/jsx-runtime`
- `react/jsx-dev-runtime`
to `resolve.dedupe`.
However, that only protects modules flowing through the Vite app graph.
The App Router SSR/runtime entrypoints still use bare imports from vinext's own package/runtime scope, for example:
- `packages/vinext/src/server/app-ssr-entry.ts`
- `packages/vinext/src/server/dev-server.ts`
Those files import `react` and `react-dom/server.edge` directly. If the package manager/layout produces multiple physical React installs, those runtime imports can resolve to a different React instance than the one used by the application modules.
## Minimal reproduction
This is easiest to trigger in a workspace/install layout that produces nested React copies.
### 1. Create a minimal App Router app
`app/page.tsx`
```tsx
import Counter from "./counter";
export default function Page() {
return ;
}
```
`app/counter.tsx`
```tsx
"use client";
import { useRef } from "react";
export default function Counter() {
const ref = useRef("ok");
return
}
```
### 2. Confirm that different package scopes resolve different React paths
```bash
node - <<'NODE'
const { createRequire } = require('node:module')
const appReq = createRequire(process.cwd() + '/package.json')
const vinextReq = createRequire(require.resolve('vinext/package.json'))
const reactDomReq = createRequire(require.resolve('react-dom/package.json'))
console.log('app react =>', appReq.resolve('react'))
console.log('vinext react =>', vinextReq.resolve('react'))
console.log('react-dom react=>', reactDomReq.resolve('react'))
NODE
```
If those paths are different physical installs, the setup is vulnerable.
### 3. Start vinext and load the page
```bash
vinext dev
```
Open `/`.
Expected: page SSRs and renders `ok`.
Actual: SSR fails with `Invalid hook call` / null hook dispatcher.
## Additional proof of root cause
Even outside the app graph, the problem can be reproduced by mixing React from one scope with `react-dom/server.edge` from another scope:
```bash
node - <<'NODE'
(async () => {
const React = await import(require('url').pathToFileURL(require.resolve('react')).href)
const { renderToReadableStream } = await import(require('url').pathToFileURL(require.resolve('react-dom/server.edge')).href)
function App() {
const ref = React.useRef('ok')
return React.createElement('div', null, ref.current)
}
const stream = await renderToReadableStream(React.createElement(App))
await stream.allReady
console.log(await new Response(stream).text())
})()
NODE
```
In a split-React install topology, this fails with the same null dispatcher error.
## Expected behavior
vinext runtime SSR should consistently use the application's React instance, not whatever happens to resolve from vinext's own package scope.
## Suggested fix
Make the SSR/runtime entrypoints resolve React packages from the project root, not from vinext's package scope.
That likely means normalizing all runtime-side imports for at least:
- `react`
- `react-dom`
- `react-dom/server.edge`
- `react/jsx-runtime`
- `react/jsx-dev-runtime`
- possibly `react-server-dom-webpack` as well, depending on the call path
Potential implementation directions:
1. Resolve these package entrypoints from the app root during config/build and inject absolute paths into the generated server/runtime entry modules.
2. Add a runtime alias layer for vinext's own server-side modules so they always bind to the project's React packages.
3. Reuse the same project-root dependency resolution logic already used elsewhere for optional dependency resolution, but apply it to SSR/runtime imports too.
## Suggested regression test
A useful regression test would simulate the bad install topology instead of relying on the repo's normal package manager layout.
For example:
1. Create a temp app fixture with a minimal App Router page plus one hookful `"use client"` child.
2. Create a temp install layout where:
- the app resolves `react` from one physical path
- vinext resolves `react` from a different physical path
- `react-dom/server.edge` also resolves its peer from a different physical path unless corrected
3. Start `vinext dev` (or invoke the App Router SSR entry directly).
4. Request `/` and assert:
- status `200`
- response contains `ok`
- no `Invalid hook call` is logged
A lower-level smoke test would also be useful: assert that vinext's SSR runtime does not end up with different module identities for app React and renderer React when running under a nested install layout.
## Notes
This is subtle because vinext's existing `resolve.dedupe` logic is correct but incomplete: it protects the app graph, not necessarily vinext's own Node-side SSR/runtime modules.
Contributor guide
Research direction
Read packages/vinext/src/server/app-ssr-entry.ts and packages/vinext/src/server/dev-server.ts, focusing on their React and react-dom/server.edge imports. Reproduce the split-install case with the provided hookful App Router fixture and resolution script, then start vinext dev and request /. Done means the page returns 200 with “ok” and no Invalid hook call; add a regression test using a temporary nested install layout.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- backend, testing-qa, web-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100