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:
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
import Counter from "./counter";
export default function Page() {
return <Counter />;
}
app/counter.tsx
"use client";
import { useRef } from "react";
export default function Counter() {
const ref = useRef("ok");
return <div>{ref.current}</div>;
}
2. Confirm that different package scopes resolve different React paths
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
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:
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:
- Resolve these package entrypoints from the app root during config/build and inject absolute paths into the generated server/runtime entry modules.
- Add a runtime alias layer for vinext's own server-side modules so they always bind to the project's React packages.
- 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:
- Create a temp app fixture with a minimal App Router page plus one hookful
"use client" child.
- 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
- Start
vinext dev (or invoke the App Router SSR entry directly).
- 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.
Summary
vinextalready addsresolve.dedupefor React packages, but App Router SSR can still fail withInvalid hook callif the runtime entrypoints resolvereactfrom 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.edgefrom 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:
Why this happens
vinextdoes the right thing at Vite config time by adding:reactreact-domreact/jsx-runtimereact/jsx-dev-runtimeto
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.tspackages/vinext/src/server/dev-server.tsThose files import
reactandreact-dom/server.edgedirectly. 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.tsxapp/counter.tsx2. Confirm that different package scopes resolve different React paths
If those paths are different physical installs, the setup is vulnerable.
3. Start vinext and load the page
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.edgefrom another scope: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:
reactreact-domreact-dom/server.edgereact/jsx-runtimereact/jsx-dev-runtimereact-server-dom-webpackas well, depending on the call pathPotential implementation directions:
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:
"use client"child.reactfrom one physical pathreactfrom a different physical pathreact-dom/server.edgealso resolves its peer from a different physical path unless correctedvinext dev(or invoke the App Router SSR entry directly)./and assert:200okInvalid hook callis loggedA 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.dedupelogic is correct but incomplete: it protects the app graph, not necessarily vinext's own Node-side SSR/runtime modules.