From ef149f75ba8bfda714731d95339f2ae8ba8da145 Mon Sep 17 00:00:00 2001 From: Benjamin Favre Date: Wed, 11 Mar 2026 09:01:09 +0000 Subject: [PATCH] fix: React 19 dev-mode compatibility with Vite module runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/vinext/src/index.ts | 6 + .../src/plugins/patch-react-server-dom.ts | 125 ++++++++++++++++++ .../src/plugins/strip-react-type-imports.ts | 67 ++++++++++ 3 files changed, 198 insertions(+) create mode 100644 packages/vinext/src/plugins/patch-react-server-dom.ts create mode 100644 packages/vinext/src/plugins/strip-react-type-imports.ts diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 215100e07..65cb09e8f 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -53,6 +53,8 @@ import { import { hasBasePath } from "./utils/base-path.js"; import { asyncHooksStubPlugin } from "./plugins/async-hooks-stub.js"; import { clientReferenceDedupPlugin } from "./plugins/client-reference-dedup.js"; +import { patchReactServerDom } from "./plugins/patch-react-server-dom.js"; +import { stripReactTypeImports } from "./plugins/strip-react-type-imports.js"; import { hasWranglerConfig, formatMissingCloudflarePluginError } from "./deploy.js"; import tsconfigPaths from "vite-tsconfig-paths"; import react, { Options as VitePluginReactOptions } from "@vitejs/plugin-react"; @@ -1504,6 +1506,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } }, }, + // Fix React 19 dev-mode crashes in Vite's module runner — see src/plugins/patch-react-server-dom.ts + patchReactServerDom(), + // Strip type-only React imports that esbuild can't detect — see src/plugins/strip-react-type-imports.ts + stripReactTypeImports(), // Stub node:async_hooks in client builds — see src/plugins/async-hooks-stub.ts asyncHooksStubPlugin, // Dedup client references from RSC proxy modules — see src/plugins/client-reference-dedup.ts diff --git a/packages/vinext/src/plugins/patch-react-server-dom.ts b/packages/vinext/src/plugins/patch-react-server-dom.ts new file mode 100644 index 000000000..12e4f7b2e --- /dev/null +++ b/packages/vinext/src/plugins/patch-react-server-dom.ts @@ -0,0 +1,125 @@ +import type { Plugin } from "vite"; + +/** + * Patch react-server-dom-webpack dev builds to avoid crashes in Vite's + * module runner. + * + * React 19's development builds include aggressive error/stack reconstruction + * logic (`resolveErrorDev`, `parseStackTrace`, `buildFakeCallStack`, + * `console.createTask`) that crashes in Vite's module runner because: + * + * 1. `task.debugStack` can be `undefined` (not `null`), but the code uses + * strict equality `null !== task.debugStack` which doesn't catch `undefined`. + * + * 2. `parseStackTrace(error, skip)` is called with `undefined` error objects, + * crashing on `error.stack`. + * + * 3. `console.createTask()` (V8 devtools API) behaves differently in Vite's + * module runner context and can throw. + * + * 4. `resolveErrorDev()` on the client side attempts full stack reconstruction + * using `buildFakeCallStack` and `console.createTask`, which crash without + * proper element ownership metadata. + * + * 5. Elements created by framework internals (vinext `__wrapper`, + * `@vitejs/plugin-rsc` Resources) lack `_debugStack`/`_debugTask`/ + * `_debugInfo` properties, triggering "without development properties" + * thrown errors. + * + * These patches are applied at transform time via string replacement, targeting + * both the standalone `react-server-dom-webpack` package and the vendor copy + * bundled in `@vitejs/plugin-rsc/dist/vendor/react-server-dom/`. + */ +export function patchReactServerDom(): Plugin { + return { + name: "vinext:patch-react-server-dom", + enforce: "pre", + transform(code, id) { + // Match both standalone and vendor copies of react-server-dom-webpack + if ( + !(id.includes("react-server-dom-webpack") || id.includes("react-server-dom/cjs")) || + !id.includes(".development.") + ) { + return; + } + + let patched = code; + let changed = false; + + // 1. Fix debugStack null checks (undefined vs null) + // `null !== undefined` is `true`, so `undefined` passes the guard and + // reaches `parseStackTrace(task.debugStack, 1)` with an undefined arg. + // Using `!=` (loose) catches both `null` and `undefined`. + if (patched.includes("task.debugStack")) { + patched = patched.replace(/null === task\.debugStack/g, "null == task.debugStack"); + patched = patched.replace(/null !== task\.debugStack/g, "null != task.debugStack"); + changed = true; + } + + // 2. Guard parseStackTrace against undefined/null error argument + if (patched.includes("function parseStackTrace(error,")) { + patched = patched.replace( + /function parseStackTrace\(error,\s*skipFrames\)\s*\{/g, + "function parseStackTrace(error, skipFrames) { if (error == null) return [];", + ); + changed = true; + } + + // 3. Disable console.createTask (crashes in Vite's module runner) + if (patched.includes("supportsCreateTask = !!console.createTask")) { + patched = patched.replace( + /supportsCreateTask = !!console\.createTask/g, + "supportsCreateTask = false", + ); + changed = true; + } + + // 4. Guard buildFakeCallStack against undefined stack + if ( + patched.includes( + "function buildFakeCallStack(response, stack, environmentName, innerCall) {", + ) + ) { + patched = patched.replace( + "function buildFakeCallStack(response, stack, environmentName, innerCall) {", + "function buildFakeCallStack(response, stack, environmentName, innerCall) { if (!stack) return innerCall;", + ); + changed = true; + } + + // 5. Neuter resolveErrorDev to avoid stack reconstruction crashes (client-side). + // Instead of the full stack reconstruction path (which crashes), return a + // simple Error with the relevant metadata attached. + if (patched.includes("function resolveErrorDev(response, errorInfo) {")) { + patched = patched.replace( + "function resolveErrorDev(response, errorInfo) {", + `function resolveErrorDev(response, errorInfo) { + var __err = new Error((errorInfo && errorInfo.message) || "RSC render error"); + __err.name = (errorInfo && errorInfo.name) || "Error"; + if (errorInfo) { __err.environmentName = errorInfo.env; __err.digest = errorInfo.digest; } + return __err;`, + ); + changed = true; + } + + // 6. Suppress "without development properties" thrown errors. + // 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( + /throw Error\(([^)]*without development properties[^)]*)\)/g, + "void 0", + ); + patched = patched.replace( + /console\.error\(\s*([^)]*without development properties[^)]*)\)/g, + "void 0", + ); + changed = true; + } + + if (changed) { + return patched; + } + }, + }; +} diff --git a/packages/vinext/src/plugins/strip-react-type-imports.ts b/packages/vinext/src/plugins/strip-react-type-imports.ts new file mode 100644 index 000000000..b33616974 --- /dev/null +++ b/packages/vinext/src/plugins/strip-react-type-imports.ts @@ -0,0 +1,67 @@ +import type { Plugin } from "vite"; + +/** + * Strip type-only React imports that lack the `type` keyword. + * + * esbuild (Vite's default TS transform) can't determine whether an import + * like `import { ReactNode } from "react"` is type-only without TS 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 has runtime exports. + * + * This plugin knows the complete set of React runtime exports and strips + * any import name that isn't in that set from `import { ... } from "react"` + * statements. + */ +export function stripReactTypeImports(): Plugin { + // React runtime exports (everything else is a TypeScript type) + 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 RE = /import\s+\{([^}]+)\}\s*from\s*['"]react['"]/g; + + return { + name: "vinext:strip-react-type-imports", + enforce: "pre", + transform(code, _id) { + if (!code.includes("from 'react'") && !code.includes('from "react"')) return; + if (!RE.test(code)) return; + RE.lastIndex = 0; + + 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; + + const entries = names.split(",").map((e) => e.trim()).filter(Boolean); + const runtimeEntries = entries.filter((e) => { + if (e.startsWith("type ")) return false; // inline type annotation + const name = e.includes(" as ") ? e.split(" as ")[0].trim() : e; + return REACT_RUNTIME_EXPORTS.has(name); + }); + + if (runtimeEntries.length === entries.length) return match; // all runtime, no change + if (runtimeEntries.length === 0) { + changed = true; + return `/* stripped type-only react import */`; + } + + changed = true; + return `import { ${runtimeEntries.join(", ")} } from 'react'`; + }); + + return changed ? result : undefined; + }, + }; +}