cloudflare / cloudflare/vinext

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

Open
#848 0 comments 0 reactions 0 assignees View on GitHub
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

{ref.current}
;
}
```

### 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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.