-
Notifications
You must be signed in to change notification settings - Fork 253
fix: React 19 dev-mode compatibility with Vite module runner #467
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Consider replacing the entire function body instead, or use a regex that captures up to the closing |
||
| 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In React 19.2.4, the actual source uses The |
||
| /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; | ||
| } | ||
| }, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+18
to
+30
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Missing: |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is dead code. The outer regex on line 32 ( Either remove it or add a comment like |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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'`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return changed ? result : undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
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:Note: 5 parameters across multiple lines, not 4 on one line. The patch needs a regex that handles multi-line formatting and the additional
useEnclosingLineparameter.