Skip to content

fix: React 19 dev-mode compatibility with Vite module runner#467

Draft
benfavre wants to merge 1 commit intocloudflare:mainfrom
benfavre:fix/react19-dev-mode-compat
Draft

fix: React 19 dev-mode compatibility with Vite module runner#467
benfavre wants to merge 1 commit intocloudflare:mainfrom
benfavre:fix/react19-dev-mode-compat

Conversation

@benfavre
Copy link
Contributor

Problem

React 19's react-server-dom-webpack development builds include aggressive error/stack reconstruction logic that crashes in Vite's module runner. Every vinext user running React 19 in dev mode hits these crashes — they block the dev server from serving any page.

Crash 1: debugStack null vs undefined

TypeError: Cannot read properties of undefined (reading 'stack')
    at parseStackTrace

React's code uses null !== task.debugStack (strict equality), but in Vite's module runner task.debugStack is undefined (not null). undefined !== null is true, so the guard passes and parseStackTrace(undefined, 1) crashes on undefined.stack.

Crash 2: parseStackTrace with undefined error

TypeError: Cannot read properties of undefined (reading 'stack')
    at parseStackTrace

Called with undefined error objects in certain code paths.

Crash 3: console.createTask in module runner

TypeError: console.createTask is not a function

V8's console.createTask() devtools API behaves differently in Vite's module runner context.

Crash 4: buildFakeCallStack with undefined stack

Called with undefined stack argument, crashes during stack frame reconstruction.

Crash 5: resolveErrorDev stack reconstruction

Full stack reconstruction using buildFakeCallStack + console.createTask crashes without proper element ownership metadata (vinext's __wrapper, @vitejs/plugin-rsc's Resources lack _debugStack/_debugTask).

Crash 6: "without development properties"

Error: An element was created without development properties

Framework internals create elements without dev metadata. Production builds ignore this; dev builds throw.

Crash 7: Named export 'ReactNode' not found

SyntaxError: Named export 'ReactNode' not found.
The requested module 'react' is a CommonJS module

esbuild (Vite's default TS transform) cannot determine that import { ReactNode } from "react" is type-only without full TypeScript type information. It preserves the import, and Vite's module runner fails because the pre-bundled CJS react module only has runtime exports.

Solution

Two new Vite plugins, registered in vinext's plugin array:

Plugin 1: vinext:patch-react-server-dom

Applies 6 targeted string-replacement patches to react-server-dom-webpack dev builds at transform time:

# Patch Technique
1 null !== task.debugStacknull != task.debugStack Loose inequality catches both null and undefined
2 parseStackTrace(error, skipFrames) Early return [] when error == null
3 supportsCreateTask = !!console.createTask Set to false
4 buildFakeCallStack(response, stack, ...) Early return innerCall when !stack
5 resolveErrorDev(response, errorInfo) Replace with simple Error constructor preserving metadata
6 throw Error("...without development properties...") Replace with void 0

Targets both standalone react-server-dom-webpack and the vendor copy in @vitejs/plugin-rsc/dist/vendor/react-server-dom/.

Plugin 2: vinext:strip-react-type-imports

Maintains the complete set of React 19 runtime exports. For import { ... } from "react" statements, strips any specifier not in the runtime set:

// Before (esbuild preserves this):
import { useState, ReactNode, useEffect } from "react"

// After (plugin strips ReactNode):
import { useState, useEffect } from "react"

Handles edge cases:

  • import type { ... } → skipped entirely
  • import { type ReactNode, useState } → inline type annotation respected
  • import { ReactNode as RN } → aliases resolved correctly
  • import { ReactNode } → replaced with comment placeholder

Changes

File Change
packages/vinext/src/plugins/patch-react-server-dom.ts New plugin (125 lines)
packages/vinext/src/plugins/strip-react-type-imports.ts New plugin (67 lines)
packages/vinext/src/index.ts Import + register both plugins

Reproduction

Any app using React 19 with vinext in development mode:

npx create-next-app@latest my-app
cd my-app
npm install vinext
# Add vinext to vite.config.ts
npm run dev
# → Multiple crashes from react-server-dom-webpack dev builds

Risk

Low. Both plugins:

  • Only match development builds (.development. in filename)
  • Use enforce: "pre" so they run before other transforms
  • Only apply when the target strings are found (no-op on production builds)
  • Are idempotent (safe to re-run)

The strip-react-type-imports plugin is conservative — it only strips names that are not in the React 19 runtime export list. If React adds new runtime exports in the future, the allowlist needs to be updated.

Add two Vite plugins that fix crashes when using React 19 development
builds with Vite's module runner.

## Plugin 1: patch-react-server-dom

React 19's `react-server-dom-webpack` development builds include
aggressive error/stack reconstruction logic that crashes in Vite's
module runner. This plugin applies 6 targeted patches at transform
time:

1. **debugStack null vs undefined** — `null !== task.debugStack` uses
   strict equality, but `task.debugStack` can be `undefined` in Vite's
   module runner. Changed to loose inequality (`!=`) to catch both.

2. **parseStackTrace guard** — `parseStackTrace(error, skip)` is called
   with `undefined` error objects, crashing on `error.stack`. Added
   null guard that returns `[]`.

3. **console.createTask disabled** — V8's `console.createTask()` API
   behaves differently in Vite's module runner and can throw. Disabled
   by setting `supportsCreateTask = false`.

4. **buildFakeCallStack guard** — Called with `undefined` stack argument.
   Added guard that returns `innerCall` when stack is falsy.

5. **resolveErrorDev neutered** — Client-side full stack reconstruction
   via `buildFakeCallStack` + `console.createTask` crashes without
   proper element ownership metadata. Replaced with a simple Error
   constructor that preserves metadata (name, message, env, digest).

6. **"without development properties" suppressed** — Framework internals
   (vinext `__wrapper`, `@vitejs/plugin-rsc` Resources) create elements
   without `_debugStack`/`_debugTask`/`_debugInfo`. Dev builds throw;
   converted to no-ops.

Targets both standalone `react-server-dom-webpack` and the vendor copy
bundled in `@vitejs/plugin-rsc/dist/vendor/react-server-dom/`.

## Plugin 2: strip-react-type-imports

esbuild (Vite's default TypeScript transform) cannot determine whether
`import { ReactNode } from "react"` is type-only without full TypeScript
type information. It preserves the import, and Vite's module runner then
fails with `Named export 'ReactNode' not found` because `react` is CJS
and the pre-bundled ESM version only exposes runtime exports.

This plugin maintains the complete set of React 19 runtime exports and
strips any import specifier not in that set from
`import { ... } from "react"` statements. It correctly handles:
- `import type { ... }` (skipped entirely)
- Inline `type` annotations: `import { type ReactNode, useState }`
- `as` aliases: `import { ReactNode as RN }`
- Mixed imports: `import { useState, ReactNode }` → `import { useState }`
- Pure type imports: `import { ReactNode }` → comment placeholder
@james-elicx
Copy link
Collaborator

/bigbonk review

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 11, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@467

commit: 805d595

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: React 19 dev-mode compatibility patches

The problem space is real and the approach of patching react-server-dom-webpack at transform time is reasonable. However, I found several issues that would make the patches silently ineffective against the current React 19.2.4 builds, plus missing tests and a format failure.

Critical: Several patches don't match the actual source

I checked the actual react-server-dom-webpack@19.2.4 files installed in this repo. The patched function signatures assume single-line formatting and specific parameter lists, but the actual files use multi-line formatting and different arities:

Patch 4 (buildFakeCallStack): The plugin matches:

function buildFakeCallStack(response, stack, environmentName, innerCall) {

But the actual source (client files) has 5 parameters across multiple lines:

function buildFakeCallStack(
  response,
  stack,
  environmentName,
  useEnclosingLine,
  innerCall
) {

This patch will never match.

Patch 5 (resolveErrorDev): The plugin matches:

function resolveErrorDev(response, errorInfo) {

But the actual source has this on one line with 4-space indent. The match works, but it inserts a full replacement body with a return __err statement — this means the original function body is still present after the inserted code (the original code after { is never removed). The injected return __err does prevent execution of the original body, but it leaves dead code and the replacement doesn't remove the closing }, which works by accident.

Missing from REACT_RUNTIME_EXPORTS

I checked the actual React 19.2.4 runtime and found these exports missing from the allowlist:

  • Activity
  • __COMPILER_RUNTIME
  • cacheSignal
  • captureOwnerStack
  • useEffectEvent

If any user code imports these without type, the plugin will incorrectly strip them.

No tests

There are no tests for either plugin. The AGENTS.md guidelines say "Add tests first". For transform plugins like these, unit tests are straightforward — pass source code strings through the transform function and assert the output. This is especially important here because the patches are brittle (they depend on exact string patterns in React's compiled output).

CI: Format check fails

strip-react-type-imports.ts fails oxfmt formatting. This needs to be fixed.

Minor: import type guard is dead code

In strip-react-type-imports.ts line 45, the check if (/import\s+type\s*\{/.test(match)) is dead code. The outer regex /import\s+\{/ requires { immediately after import\s+, so import type { ... } never matches the outer regex in the first place (because type sits between import and {). Harmless but confusing.

Suggestions

  1. Verify patches against the actual React source (not an older version). Use regex patterns that are resilient to multi-line formatting and parameter count changes.
  2. Add the 5 missing React runtime exports.
  3. Add unit tests for both plugins with representative source snippets from the actual React files.
  4. Run pnpm run fmt to fix formatting.
  5. Remove the dead import type guard or add a comment explaining it's defensive.

) {
patched = patched.replace(
"function buildFakeCallStack(response, stack, environmentName, innerCall) {",
"function buildFakeCallStack(response, stack, environmentName, innerCall) { if (!stack) return innerCall;",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This string match will never match react-server-dom-webpack@19.2.4. The actual signature is:

function buildFakeCallStack(
  response,
  stack,
  environmentName,
  useEnclosingLine,
  innerCall
) {

Note: 5 parameters across multiple lines, not 4 on one line. The patch needs a regex that handles multi-line formatting and the additional useEnclosingLine parameter.

if (patched.includes("function resolveErrorDev(response, errorInfo) {")) {
patched = patched.replace(
"function resolveErrorDev(response, errorInfo) {",
`function resolveErrorDev(response, errorInfo) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This match does work against the actual source (the function signature happens to be on one line in the current build). However, the replacement technique inserts code after { without removing the original body. It works because return __err short-circuits, but leaves the entire original function body as dead code. This is fragile — if React ever adds early-return logic before the crash points, the patch won't prevent the crash.

Consider replacing the entire function body instead, or use a regex that captures up to the closing } at the correct nesting depth.

// Framework internals create elements without dev metadata; in production
// this is fine, but dev builds throw. Convert to no-ops.
if (patched.includes("without development properties")) {
patched = patched.replace(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In React 19.2.4, the actual source uses console.error(...) (not throw Error(...)) for the "without development properties" message. The throw Error regex will never match. The console.error regex on line 113 does match.

The throw Error branch should either be removed (if only targeting 19.2.x) or kept with a comment explaining which React version it targets.

Comment on lines +18 to +30
const REACT_RUNTIME_EXPORTS = new Set([
"Children", "Component", "Fragment", "Profiler", "PureComponent",
"StrictMode", "Suspense", "act", "cache", "cloneElement", "createContext",
"createElement", "createRef", "forwardRef", "isValidElement", "lazy",
"memo", "startTransition", "use", "useActionState", "useCallback",
"useContext", "useDebugValue", "useDeferredValue", "useEffect", "useId",
"useImperativeHandle", "useInsertionEffect", "useLayoutEffect", "useMemo",
"useOptimistic", "useReducer", "useRef", "useState", "useSyncExternalStore",
"useTransition", "version",
// Internal (used by some libraries)
"__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE",
"unstable_useCacheRefresh",
]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This set is missing 5 runtime exports present in React 19.2.4:

Suggested change
const REACT_RUNTIME_EXPORTS = new Set([
"Children", "Component", "Fragment", "Profiler", "PureComponent",
"StrictMode", "Suspense", "act", "cache", "cloneElement", "createContext",
"createElement", "createRef", "forwardRef", "isValidElement", "lazy",
"memo", "startTransition", "use", "useActionState", "useCallback",
"useContext", "useDebugValue", "useDeferredValue", "useEffect", "useId",
"useImperativeHandle", "useInsertionEffect", "useLayoutEffect", "useMemo",
"useOptimistic", "useReducer", "useRef", "useState", "useSyncExternalStore",
"useTransition", "version",
// Internal (used by some libraries)
"__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE",
"unstable_useCacheRefresh",
]);
const REACT_RUNTIME_EXPORTS = new Set([
"Activity", "Children", "Component", "Fragment", "Profiler", "PureComponent",
"StrictMode", "Suspense", "act", "cache", "cacheSignal", "captureOwnerStack",
"cloneElement", "createContext", "createElement", "createRef", "forwardRef",
"isValidElement", "lazy", "memo", "startTransition", "use", "useActionState",
"useCallback", "useContext", "useDebugValue", "useDeferredValue", "useEffect",
"useEffectEvent", "useId", "useImperativeHandle", "useInsertionEffect",
"useLayoutEffect", "useMemo", "useOptimistic", "useReducer", "useRef",
"useState", "useSyncExternalStore", "useTransition", "version",
// Internal (used by some libraries)
"__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE",
"__COMPILER_RUNTIME",
"unstable_useCacheRefresh",
]);

Missing: Activity, cacheSignal, captureOwnerStack, useEffectEvent, __COMPILER_RUNTIME. If user code imports any of these without type, the plugin will incorrectly strip them as type-only imports.

let changed = false;
const result = code.replace(RE, (match, names: string) => {
// Skip `import type { ... } from "react"`
if (/import\s+type\s*\{/.test(match)) return match;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is dead code. The outer regex on line 32 (/import\s+\{/) requires { directly after import\s+, so import type { ... } can never match (because type sits between import and {). The guard here will never be true.

Either remove it or add a comment like // Defensive: import type should never match the outer RE, but guard just in case.

}

changed = true;
return `import { ${runtimeEntries.join(", ")} } from 'react'`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the plugin rewrites the import, it normalizes the quote style to single quotes (from 'react') regardless of the original. This is cosmetic but could cause churn in source maps or confuse other plugins that do exact string matching. Consider preserving the original quote style by capturing it from the match.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 11, 2026

Review submitted. Here's a summary of what I found:

Review Summary

I requested changes on PR #467. The approach of patching react-server-dom-webpack at transform time is sound, but the implementation has several issues:

Critical Issues

  1. Patch 4 (buildFakeCallStack) will never match — the plugin expects a 4-parameter single-line signature, but React 19.2.4 has 5 parameters across multiple lines.

  2. Missing React runtime exports — the allowlist in strip-react-type-imports.ts is missing 5 exports (Activity, __COMPILER_RUNTIME, cacheSignal, captureOwnerStack, useEffectEvent), which would be incorrectly stripped if imported without type.

  3. No tests for either plugin.

  4. CI Format check fails on strip-react-type-imports.ts.

Minor Issues

  • Patch 5 (resolveErrorDev) works by accident — inserts a return before the original body but doesn't remove it
  • Patch 6's throw Error regex targets a pattern that doesn't exist in React 19.2.4 (it uses console.error instead)
  • Dead code: the import type guard can never trigger
  • Quote style normalization from double to single quotes

github run

@james-elicx james-elicx marked this pull request as draft March 12, 2026 18:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants