fix: React 19 dev-mode compatibility with Vite module runner#467
fix: React 19 dev-mode compatibility with Vite module runner#467benfavre wants to merge 1 commit intocloudflare:mainfrom
Conversation
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
|
/bigbonk review |
commit: |
There was a problem hiding this comment.
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_RUNTIMEcacheSignalcaptureOwnerStackuseEffectEvent
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
- 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.
- Add the 5 missing React runtime exports.
- Add unit tests for both plugins with representative source snippets from the actual React files.
- Run
pnpm run fmtto fix formatting. - Remove the dead
import typeguard 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;", |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
| 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", | ||
| ]); |
There was a problem hiding this comment.
This set is missing 5 runtime exports present in React 19.2.4:
| 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; |
There was a problem hiding this comment.
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'`; |
There was a problem hiding this comment.
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.
|
Review submitted. Here's a summary of what I found: Review SummaryI requested changes on PR #467. The approach of patching Critical Issues
Minor Issues
|
Problem
React 19's
react-server-dom-webpackdevelopment builds include aggressive error/stack reconstruction logic that crashes in Vite's module runner. Everyvinextuser running React 19 in dev mode hits these crashes — they block the dev server from serving any page.Crash 1:
debugStacknull vs undefinedReact's code uses
null !== task.debugStack(strict equality), but in Vite's module runnertask.debugStackisundefined(notnull).undefined !== nullistrue, so the guard passes andparseStackTrace(undefined, 1)crashes onundefined.stack.Crash 2:
parseStackTracewith undefined errorCalled with
undefinederror objects in certain code paths.Crash 3:
console.createTaskin module runnerV8's
console.createTask()devtools API behaves differently in Vite's module runner context.Crash 4:
buildFakeCallStackwith undefined stackCalled with
undefinedstack argument, crashes during stack frame reconstruction.Crash 5:
resolveErrorDevstack reconstructionFull stack reconstruction using
buildFakeCallStack+console.createTaskcrashes without proper element ownership metadata (vinext's__wrapper,@vitejs/plugin-rsc's Resources lack_debugStack/_debugTask).Crash 6: "without development properties"
Framework internals create elements without dev metadata. Production builds ignore this; dev builds throw.
Crash 7:
Named export 'ReactNode' not foundesbuild (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 CJSreactmodule only has runtime exports.Solution
Two new Vite plugins, registered in vinext's plugin array:
Plugin 1:
vinext:patch-react-server-domApplies 6 targeted string-replacement patches to
react-server-dom-webpackdev builds at transform time:null !== task.debugStack→null != task.debugStacknullandundefinedparseStackTrace(error, skipFrames)[]whenerror == nullsupportsCreateTask = !!console.createTaskfalsebuildFakeCallStack(response, stack, ...)innerCallwhen!stackresolveErrorDev(response, errorInfo)throw Error("...without development properties...")void 0Targets both standalone
react-server-dom-webpackand the vendor copy in@vitejs/plugin-rsc/dist/vendor/react-server-dom/.Plugin 2:
vinext:strip-react-type-importsMaintains the complete set of React 19 runtime exports. For
import { ... } from "react"statements, strips any specifier not in the runtime set:Handles edge cases:
import type { ... }→ skipped entirelyimport { type ReactNode, useState }→ inlinetypeannotation respectedimport { ReactNode as RN }→ aliases resolved correctlyimport { ReactNode }→ replaced with comment placeholderChanges
packages/vinext/src/plugins/patch-react-server-dom.tspackages/vinext/src/plugins/strip-react-type-imports.tspackages/vinext/src/index.tsReproduction
Any app using React 19 with vinext in development mode:
Risk
Low. Both plugins:
.development.in filename)enforce: "pre"so they run before other transformsThe
strip-react-type-importsplugin 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.