Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
125 changes: 125 additions & 0 deletions packages/vinext/src/plugins/patch-react-server-dom.ts
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;",
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.

);
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) {
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.

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(
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.

/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;
}
},
};
}
67 changes: 67 additions & 0 deletions packages/vinext/src/plugins/strip-react-type-imports.ts
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
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.


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;
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.


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'`;
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.

});

return changed ? result : undefined;
},
};
}
Loading