Skip to content

App Router SSR can hit invalid hook call when runtime resolves a different React instance than the project #848

@southpolesteve

Description

@southpolesteve

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

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:

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions