From 08ccb0a27e7efb61cb686ba4e536bdc5def03b5c Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Wed, 30 Jul 2025 17:37:17 +1000 Subject: [PATCH 01/14] Add RSC Framework Mode HMR/HDR support --- .../default-rsc-entries/entry.client.tsx | 4 + packages/react-router-dev/package.json | 3 +- packages/react-router-dev/tsup.config.ts | 16 +- packages/react-router-dev/vite/rsc/plugin.ts | 225 +++++++++++++++++- .../vite/rsc/virtual-route-config.ts | 18 +- .../vite/rsc/virtual-route-modules.ts | 4 +- .../vite/static/rsc-refresh-utils.mjs | 129 ++++++++++ packages/react-router/lib/rsc/browser.tsx | 146 +++++++++--- pnpm-lock.yaml | 21 +- 9 files changed, 502 insertions(+), 64 deletions(-) create mode 100644 packages/react-router-dev/vite/static/rsc-refresh-utils.mjs diff --git a/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx b/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx index be0fa76e80..3e904deeee 100644 --- a/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx +++ b/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx @@ -1,3 +1,7 @@ +if (import.meta.env.DEV) { + await import("virtual:react-router/unstable_rsc/inject-hmr-runtime"); +} + import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json index ee43e86487..02acb92fc9 100644 --- a/packages/react-router-dev/package.json +++ b/packages/react-router-dev/package.json @@ -78,7 +78,6 @@ "@babel/types": "^7.27.7", "@npmcli/package-json": "^4.0.1", "@react-router/node": "workspace:*", - "@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-rsc": "0.4.11", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", @@ -124,6 +123,8 @@ }, "peerDependencies": { "@react-router/serve": "workspace:^", + "react": ">=18", + "react-dom": ">=18", "react-router": "workspace:^", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", diff --git a/packages/react-router-dev/tsup.config.ts b/packages/react-router-dev/tsup.config.ts index e13ddaf79e..b5f291b4c3 100644 --- a/packages/react-router-dev/tsup.config.ts +++ b/packages/react-router-dev/tsup.config.ts @@ -16,7 +16,11 @@ const entry = [ "vite/cloudflare.ts", ]; -const external = ["./static/refresh-utils.mjs", /\.json$/]; +const external = [ + "./static/refresh-utils.mjs", + "./static/rsc-refresh-utils.mjs", + /\.json$/, +]; export default defineConfig([ { @@ -39,6 +43,16 @@ export default defineConfig([ "dist/static/refresh-utils.mjs", ); + await fsp.mkdir("dist/static", { recursive: true }); + await fsp.copyFile( + "vite/static/refresh-utils.mjs", + "dist/static/refresh-utils.mjs", + ); + await fsp.copyFile( + "vite/static/rsc-refresh-utils.mjs", + "dist/static/rsc-refresh-utils.mjs", + ); + await fsp.mkdir("dist/config/defaults", { recursive: true }); const files = await fsp.readdir("config/defaults"); for (const file of files) { diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts index d7f22fd8c3..6dd5d55ac0 100644 --- a/packages/react-router-dev/vite/rsc/plugin.ts +++ b/packages/react-router-dev/vite/rsc/plugin.ts @@ -1,30 +1,38 @@ import type * as Vite from "vite"; import rsc, { type RscPluginOptions } from "@vitejs/plugin-rsc"; -import react from "@vitejs/plugin-react"; import { init as initEsModuleLexer } from "es-module-lexer"; +import * as babel from "@babel/core"; import { create } from "../virtual-module"; import * as Typegen from "../../typegen"; import { readFileSync } from "fs"; -import { join, dirname } from "path"; +import { readFile } from "fs/promises"; +import path, { join, dirname } from "path"; import { type ConfigLoader, type ResolvedReactRouterConfig, createConfigLoader, } from "../../config/config"; -import { createVirtualRouteConfigCode } from "./virtual-route-config"; -import { transformVirtualRouteModules } from "./virtual-route-modules"; +import { createVirtualRouteConfig } from "./virtual-route-config"; +import { + transformVirtualRouteModules, + parseRouteExports, + CLIENT_NON_COMPONENT_EXPORTS, +} from "./virtual-route-modules"; export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { let configLoader: ConfigLoader; let config: ResolvedReactRouterConfig; let typegenWatcherPromise: Promise | undefined; + let viteCommand: Vite.ConfigEnv["command"]; + let routeIdByFile: Map | undefined; return [ { name: "react-router/rsc/config", async config(viteUserConfig, { command, mode }) { await initEsModuleLexer; + viteCommand = command; const rootDirectory = getRootDirectory(viteUserConfig); const watch = command === "serve"; @@ -35,6 +43,9 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { config = configResult.value; return { + esbuild: { + jsx: "automatic", + }, environments: { client: { build: { outDir: "build/client" } }, rsc: { build: { outDir: "build/server" } }, @@ -77,10 +88,12 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { }, load(id) { if (id === virtual.routeConfig.resolvedId) { - return createVirtualRouteConfigCode({ + const result = createVirtualRouteConfig({ appDirectory: config.appDirectory, routeConfig: config.unstable_routeConfig, }); + routeIdByFile = result.routeIdByFile; + return result.code; } }, }, @@ -90,13 +103,156 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { return transformVirtualRouteModules({ code, id }); }, }, - react(), + { + name: "react-router/rsc/hmr/inject-runtime", + enforce: "pre", + resolveId(id) { + if (id === virtual.injectHmrRuntime.id) { + return virtual.injectHmrRuntime.resolvedId; + } + }, + async load(id) { + if (id !== virtual.injectHmrRuntime.resolvedId) return; + + return [ + `import RefreshRuntime from "${virtual.hmrRuntime.id}"`, + "RefreshRuntime.injectIntoGlobalHook(window)", + "window.$RefreshReg$ = () => {}", + "window.$RefreshSig$ = () => (type) => type", + "window.__vite_plugin_react_preamble_installed__ = true", + ].join("\n"); + }, + }, + { + name: "react-router/rsc/hmr/runtime", + enforce: "pre", + resolveId(id) { + if (id === virtual.hmrRuntime.id) return virtual.hmrRuntime.resolvedId; + }, + async load(id) { + if (id !== virtual.hmrRuntime.resolvedId) return; + + const reactRefreshDir = path.dirname( + require.resolve("react-refresh/package.json"), + ); + const reactRefreshRuntimePath = path.join( + reactRefreshDir, + "cjs/react-refresh-runtime.development.js", + ); + + return [ + "const exports = {}", + await readFile(reactRefreshRuntimePath, "utf8"), + await readFile( + require.resolve("./static/rsc-refresh-utils.mjs"), + "utf8", + ), + "export default exports", + ].join("\n"); + }, + }, + { + name: "react-router/rsc/hmr/react-refresh", + async transform(code, id, options) { + if (viteCommand !== "serve") return; + if (id.includes("/node_modules/")) return; + + const filepath = id.split("?")[0]; + const extensionsRE = /\.(jsx?|tsx?|mdx?)$/; + if (!extensionsRE.test(filepath)) return; + + const devRuntime = "react/jsx-dev-runtime"; + const ssr = options?.ssr === true; + const isJSX = filepath.endsWith("x"); + const useFastRefresh = !ssr && (isJSX || code.includes(devRuntime)); + if (!useFastRefresh) return; + + const routeId = routeIdByFile?.get(filepath); + if (routeId !== undefined) { + return { code: addRefreshWrapper({ routeId, code, id }) }; + } + + const result = await babel.transformAsync(code, { + babelrc: false, + configFile: false, + filename: id, + sourceFileName: filepath, + parserOpts: { + sourceType: "module", + allowAwaitOutsideFunction: true, + }, + plugins: [[require("react-refresh/babel"), { skipEnvCheck: true }]], + sourceMaps: true, + }); + if (result === null) return; + + code = result.code!; + const refreshContentRE = /\$Refresh(?:Reg|Sig)\$\(/; + if (refreshContentRE.test(code)) { + code = addRefreshWrapper({ code, id }); + } + return { code, map: result.map }; + }, + }, + { + name: "react-router/rsc/hmr/updates", + async handleHotUpdate({ server, file, modules, read }) { + const routeId = routeIdByFile?.get(file); + + if (routeId !== undefined) { + const vite = await import("vite"); + + const source = await read(); + + const virtualRouteModuleCode = ( + await server.environments.rsc.pluginContainer.transform( + source, + `${vite.normalizePath(file)}?route-module`, + ) + ).code; + const { staticExports } = parseRouteExports(virtualRouteModuleCode); + + const virtualServerRouteModuleCode = ( + await server.environments.rsc.pluginContainer.transform( + source, + `${vite.normalizePath(file)}?server-route-module`, + ) + ).code; + const { isServerFirstRoute } = parseRouteExports( + virtualServerRouteModuleCode, + ); + + const hasAction = staticExports.includes("action"); + const hasComponent = staticExports.includes("default"); + const hasErrorBoundary = staticExports.includes("ErrorBoundary"); + const hasLoader = staticExports.includes("loader"); + + server.hot.send({ + type: "custom", + event: "react-router:hmr", + data: { + routeId, + isServerFirstRoute, + hasAction, + hasComponent, + hasErrorBoundary, + hasLoader, + }, + }); + } + + return modules; + }, + }, + // TODO: server-change-trigger-client-hmr? rsc({ entries: getRscEntries() }), ]; } const virtual = { routeConfig: create("unstable_rsc/routes"), + injectHmrRuntime: create("unstable_rsc/inject-hmr-runtime"), + hmrRuntime: create("unstable_rsc/runtime"), }; function getRootDirectory(viteUserConfig: Vite.UserConfig) { @@ -131,3 +287,60 @@ function getDevPackageRoot(): string { } throw new Error("Could not find package.json"); } + +function addRefreshWrapper({ + routeId, + code, + id, +}: { + routeId?: string; + code: string; + id: string; +}): string { + const acceptExports = + routeId !== undefined ? CLIENT_NON_COMPONENT_EXPORTS : []; + return ( + REACT_REFRESH_HEADER.replaceAll("__SOURCE__", JSON.stringify(id)) + + code + + REACT_REFRESH_FOOTER.replaceAll("__SOURCE__", JSON.stringify(id)) + .replaceAll("__ACCEPT_EXPORTS__", JSON.stringify(acceptExports)) + .replaceAll("__ROUTE_ID__", JSON.stringify(routeId)) + ); +} + +const REACT_REFRESH_HEADER = ` +import RefreshRuntime from "${virtual.hmrRuntime.id}"; + +const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; +let prevRefreshReg; +let prevRefreshSig; + +if (import.meta.hot && !inWebWorker) { + if (!window.__vite_plugin_react_preamble_installed__) { + throw new Error( + "React Router Vite plugin can't detect preamble. Something is wrong." + ); + } + + prevRefreshReg = window.$RefreshReg$; + prevRefreshSig = window.$RefreshSig$; + window.$RefreshReg$ = (type, id) => { + RefreshRuntime.register(type, __SOURCE__ + " " + id) + }; + window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform; +}`.replaceAll("\n", ""); // Header is all on one line so source maps aren't affected + +const REACT_REFRESH_FOOTER = ` +if (import.meta.hot && !inWebWorker) { + window.$RefreshReg$ = prevRefreshReg; + window.$RefreshSig$ = prevRefreshSig; + RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => { + RefreshRuntime.registerExportsForReactRefresh(__SOURCE__, currentExports); + import.meta.hot.accept((nextExports) => { + if (!nextExports) return; + __ROUTE_ID__ && window.__reactRouterRouteModuleUpdates.set(__ROUTE_ID__, nextExports); + const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(currentExports, nextExports, __ACCEPT_EXPORTS__); + if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage); + }); + }); +}`; diff --git a/packages/react-router-dev/vite/rsc/virtual-route-config.ts b/packages/react-router-dev/vite/rsc/virtual-route-config.ts index fd3687ac76..26e3e44446 100644 --- a/packages/react-router-dev/vite/rsc/virtual-route-config.ts +++ b/packages/react-router-dev/vite/rsc/virtual-route-config.ts @@ -1,13 +1,14 @@ import path from "node:path"; import type { RouteConfigEntry } from "../../routes"; -export function createVirtualRouteConfigCode({ +export function createVirtualRouteConfig({ appDirectory, routeConfig, }: { appDirectory: string; routeConfig: RouteConfigEntry[]; -}) { +}): { code: string; routeIdByFile: Map } { + let routeIdByFile = new Map(); let code = "export default ["; const closeRouteSymbol = Symbol("CLOSE_ROUTE"); @@ -23,15 +24,14 @@ export function createVirtualRouteConfigCode({ } code += "{"; + const routeFile = path.resolve(appDirectory, route.file); + const routeId = route.id || createRouteId(route.file, appDirectory); + routeIdByFile.set(routeFile, routeId); code += `lazy: () => import(${JSON.stringify( - `${path.resolve(appDirectory, route.file)}?route-module${ - route.id === "root" ? "&root-route=true" : "" - }`, + `${routeFile}?route-module${routeId === "root" ? "&root-route=true" : ""}`, )}),`; - code += `id: ${JSON.stringify( - route.id || createRouteId(route.file, appDirectory), - )},`; + code += `id: ${JSON.stringify(routeId)},`; if (typeof route.path === "string") { code += `path: ${JSON.stringify(route.path)},`; } @@ -52,7 +52,7 @@ export function createVirtualRouteConfigCode({ code += "];\n"; - return code; + return { code, routeIdByFile }; } function createRouteId(file: string, appDirectory: string) { diff --git a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts index be7b232557..59b7e2da3b 100644 --- a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts +++ b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts @@ -17,7 +17,7 @@ const COMPONENT_EXPORTS = [ "Layout", ] as const; -const CLIENT_NON_COMPONENT_EXPORTS = [ +export const CLIENT_NON_COMPONENT_EXPORTS = [ "clientAction", "clientLoader", "unstable_clientMiddleware", @@ -171,7 +171,7 @@ function createVirtualClientRouteModuleCode({ return generatorResult; } -function parseRouteExports(code: string) { +export function parseRouteExports(code: string) { const [, exportSpecifiers] = esModuleLexer(code); const staticExports = exportSpecifiers.map(({ n: name }) => name); return { diff --git a/packages/react-router-dev/vite/static/rsc-refresh-utils.mjs b/packages/react-router-dev/vite/static/rsc-refresh-utils.mjs new file mode 100644 index 0000000000..1955d46fbe --- /dev/null +++ b/packages/react-router-dev/vite/static/rsc-refresh-utils.mjs @@ -0,0 +1,129 @@ +// adapted from https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/src/refreshUtils.js +// This file gets injected into the browser as a part of the HMR runtime + +function debounce(fn, delay) { + let handle; + return () => { + clearTimeout(handle); + handle = setTimeout(fn, delay); + }; +} + +/* eslint-disable no-undef */ +const enqueueUpdate = debounce(async () => { + if (routeUpdates.size > 0) { + const routeUpdateByRouteId = new Map(); + for (const routeUpdate of routeUpdates) { + const routeId = routeUpdate.routeId; + const routeModule = window.__reactRouterRouteModuleUpdates.get(routeId); + if (!routeModule) { + throw Error( + `[react-router:hmr] No module update found for route ${routeId}`, + ); + } + routeUpdateByRouteId.set(routeId, { routeModule, ...routeUpdate }); + } + routeUpdates.clear(); + __reactRouterDataRouter._updateRoutesForHMR(routeUpdateByRouteId); + } + + try { + window.__reactRouterHdrActive = true; + await __reactRouterDataRouter.revalidate(); + } finally { + window.__reactRouterHdrActive = false; + } + + exports.performReactRefresh(); +}, 16); + +// Taken from https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/lib/runtime/RefreshUtils.js#L141 +// This allows to resister components not detected by SWC like styled component +function registerExportsForReactRefresh(filename, moduleExports) { + for (const key in moduleExports) { + if (key === "__esModule") continue; + const exportValue = moduleExports[key]; + if (exports.isLikelyComponentType(exportValue)) { + // 'export' is required to avoid key collision when renamed exports that + // shadow a local component name: https://github.com/vitejs/vite-plugin-react/issues/116 + // The register function has an identity check to not register twice the same component, + // so this is safe to not used the same key here. + exports.register(exportValue, filename + " export " + key); + } + } +} + +function validateRefreshBoundaryAndEnqueueUpdate( + prevExports, + nextExports, + // non-component exports that are handled by the framework (e.g. `meta` and `links` for route modules) + acceptExports = [], +) { + if ( + !predicateOnExport( + prevExports, + (key) => key in nextExports || acceptExports.includes(key), + ) + ) { + return "Could not Fast Refresh (export removed)"; + } + if ( + !predicateOnExport( + nextExports, + (key) => key in prevExports || acceptExports.includes(key), + ) + ) { + return "Could not Fast Refresh (new export)"; + } + + let hasExports = false; + const allExportsAreHandledOrUnchanged = predicateOnExport( + nextExports, + (key, value) => { + hasExports = true; + // Remix can handle Remix-specific exports (e.g. `meta` and `links`) + if (acceptExports.includes(key)) return true; + // React Fast Refresh can handle component exports + if (exports.isLikelyComponentType(value)) return true; + // Unchanged exports are implicitly handled + return prevExports[key] === nextExports[key]; + }, + ); + if (hasExports && allExportsAreHandledOrUnchanged) { + enqueueUpdate(); + } else { + return "Could not Fast Refresh. Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports"; + } +} + +function predicateOnExport(moduleExports, predicate) { + for (const key in moduleExports) { + if (key === "__esModule") continue; + const desc = Object.getOwnPropertyDescriptor(moduleExports, key); + if (desc && desc.get) return false; + if (!predicate(key, moduleExports[key])) return false; + } + return true; +} + +// Hides vite-ignored dynamic import so that Vite can skip analysis if no other +// dynamic import is present (https://github.com/vitejs/vite/pull/12732) +function __hmr_import(module) { + return import(/* @vite-ignore */ module); +} + +const routeUpdates = new Set(); +window.__reactRouterRouteModuleUpdates = new Map(); + +import.meta.hot.on("react-router:hmr", async (routeUpdate) => { + routeUpdates.add(routeUpdate); + if (routeUpdate.isServerFirstRoute) { + enqueueUpdate(); + } +}); + +exports.__hmr_import = __hmr_import; +exports.registerExportsForReactRefresh = registerExportsForReactRefresh; +exports.validateRefreshBoundaryAndEnqueueUpdate = + validateRefreshBoundaryAndEnqueueUpdate; +exports.enqueueUpdate = enqueueUpdate; diff --git a/packages/react-router/lib/rsc/browser.tsx b/packages/react-router/lib/rsc/browser.tsx index 0c68963c08..02279e57d6 100644 --- a/packages/react-router/lib/rsc/browser.tsx +++ b/packages/react-router/lib/rsc/browser.tsx @@ -56,7 +56,7 @@ export type EncodeReplyFunction = ( type WindowWithRouterGlobals = Window & typeof globalThis & { - __router: DataRouter; + __reactRouterDataRouter: DataRouter; __routerInitialized: boolean; __routerActionID: number; }; @@ -139,7 +139,7 @@ export function createCallServer({ return; } - globalVar.__router.navigate(payload.location, { + globalVar.__reactRouterDataRouter.navigate(payload.location, { replace: payload.replace, }); @@ -168,7 +168,7 @@ export function createCallServer({ window.location.href = rerender.location; return; } - globalVar.__router.navigate(rerender.location, { + globalVar.__reactRouterDataRouter.navigate(rerender.location, { replace: rerender.replace, }); return; @@ -176,7 +176,7 @@ export function createCallServer({ let lastMatch: RSCRouteManifest | undefined; for (const match of rerender.matches) { - globalVar.__router.patchRoutes( + globalVar.__reactRouterDataRouter.patchRoutes( lastMatch?.id ?? null, [createRouteFromServerManifest(match)], true, @@ -185,25 +185,29 @@ export function createCallServer({ } ( window as WindowWithRouterGlobals - ).__router._internalSetStateDoNotUseOrYouWillBreakYourApp({}); + ).__reactRouterDataRouter._internalSetStateDoNotUseOrYouWillBreakYourApp( + {}, + ); React.startTransition(() => { ( window as WindowWithRouterGlobals - ).__router._internalSetStateDoNotUseOrYouWillBreakYourApp({ - loaderData: Object.assign( - {}, - globalVar.__router.state.loaderData, - rerender.loaderData, - ), - errors: rerender.errors - ? Object.assign( - {}, - globalVar.__router.state.errors, - rerender.errors, - ) - : null, - }); + ).__reactRouterDataRouter._internalSetStateDoNotUseOrYouWillBreakYourApp( + { + loaderData: Object.assign( + {}, + globalVar.__reactRouterDataRouter.state.loaderData, + rerender.loaderData, + ), + errors: rerender.errors + ? Object.assign( + {}, + globalVar.__reactRouterDataRouter.state.errors, + rerender.errors, + ) + : null, + }, + ); }); } }, @@ -227,7 +231,8 @@ function createRouterFromPayload({ }) { const globalVar = window as WindowWithRouterGlobals; - if (globalVar.__router) return globalVar.__router; + if (globalVar.__reactRouterDataRouter) + return globalVar.__reactRouterDataRouter; if (payload.type !== "render") throw new Error("Invalid payload type"); @@ -256,7 +261,7 @@ function createRouterFromPayload({ return [route]; }, [] as DataRouteObject[]); - globalVar.__router = createRouter({ + globalVar.__reactRouterDataRouter = createRouter({ routes, unstable_getContext, basename: payload.basename, @@ -294,7 +299,7 @@ function createRouterFromPayload({ }, // FIXME: Pass `build.ssr` into this function dataStrategy: getRSCSingleFetchDataStrategy( - () => globalVar.__router, + () => globalVar.__reactRouterDataRouter, true, payload.basename, createFromReadableStream, @@ -304,21 +309,96 @@ function createRouterFromPayload({ // We can call initialize() immediately if the router doesn't have any // loaders to run on hydration - if (globalVar.__router.state.initialized) { + if (globalVar.__reactRouterDataRouter.state.initialized) { globalVar.__routerInitialized = true; - globalVar.__router.initialize(); + globalVar.__reactRouterDataRouter.initialize(); } else { globalVar.__routerInitialized = false; } let lastLoaderData: unknown = undefined; - globalVar.__router.subscribe(({ loaderData, actionData }) => { + globalVar.__reactRouterDataRouter.subscribe(({ loaderData, actionData }) => { if (lastLoaderData !== loaderData) { globalVar.__routerActionID = (globalVar.__routerActionID ??= 0) + 1; } }); - return globalVar.__router; + // @ts-expect-error + globalVar.__reactRouterDataRouter._updateRoutesForHMR = ( + routeUpdateByRouteId: Map< + string, + { + routeModule: any; + hasAction: boolean; + hasComponent: boolean; + hasErrorBoundary: boolean; + hasLoader: boolean; + } + >, + ) => { + const oldRoutes = (window as WindowWithRouterGlobals) + .__reactRouterDataRouter.routes; + const newRoutes: DataRouteObjectWithManifestInfo[] = []; + + function walkRoutes( + routes: DataRouteObjectWithManifestInfo[], + parentId?: string, + ): DataRouteObjectWithManifestInfo[] { + return routes.map((route) => { + const routeUpdate = routeUpdateByRouteId.get(route.id); + + if (routeUpdate) { + const { + routeModule, + hasAction, + hasComponent, + hasErrorBoundary, + hasLoader, + } = routeUpdate; + const newRoute = createRouteFromServerManifest({ + clientAction: routeModule.clientAction, + clientLoader: routeModule.clientLoader, + element: route.element as React.ReactElement, + errorElement: route.errorElement as React.ReactElement, + handle: route.handle, + hasAction, + hasComponent, + hasErrorBoundary, + hasLoader, + hydrateFallbackElement: + route.hydrateFallbackElement as React.ReactElement, + id: route.id, + index: route.index, + links: routeModule.links, + meta: routeModule.meta, + parentId: parentId, + path: route.path, + shouldRevalidate: routeModule.shouldRevalidate, + }); + if (route.children) { + newRoute.children = walkRoutes(route.children, route.id); + } + return newRoute; + } + + const updatedRoute = { ...route }; + if (route.children) { + updatedRoute.children = walkRoutes(route.children, route.id); + } + return updatedRoute; + }); + } + + newRoutes.push( + ...walkRoutes(oldRoutes as DataRouteObjectWithManifestInfo[], undefined), + ); + + ( + window as WindowWithRouterGlobals + ).__reactRouterDataRouter._internalSetRoutes(newRoutes); + }; + + return globalVar.__reactRouterDataRouter; } const renderedRoutesContext = unstable_createContext(); @@ -398,7 +478,9 @@ export function getRSCSingleFetchDataStrategy( const renderedRoutes = renderedRoutesById.get(match.route.id); if (renderedRoutes) { for (const rendered of renderedRoutes) { - (window as WindowWithRouterGlobals).__router.patchRoutes( + ( + window as WindowWithRouterGlobals + ).__reactRouterDataRouter.patchRoutes( rendered.parentId ?? null, [createRouteFromServerManifest(rendered)], true, @@ -600,7 +682,7 @@ export function RSCHydratedRouter({ const globalVar = window as WindowWithRouterGlobals; if (!globalVar.__routerInitialized) { globalVar.__routerInitialized = true; - globalVar.__router.initialize(); + globalVar.__reactRouterDataRouter.initialize(); } }, []); @@ -724,6 +806,7 @@ export function RSCHydratedRouter({ } type DataRouteObjectWithManifestInfo = DataRouteObject & { + children?: DataRouteObjectWithManifestInfo[]; hasLoader: boolean; hasClientLoader: boolean; hasAction: boolean; @@ -869,7 +952,10 @@ function getManifestUrl(paths: string[]): URL | null { } const globalVar = window as WindowWithRouterGlobals; - let basename = (globalVar.__router.basename ?? "").replace(/^\/|\/$/g, ""); + let basename = (globalVar.__reactRouterDataRouter.basename ?? "").replace( + /^\/|\/$/g, + "", + ); let url = new URL(`${basename}/.manifest`, window.location.origin); paths.sort().forEach((path) => url.searchParams.append("p", path)); @@ -913,7 +999,7 @@ async function fetchAndApplyManifestPatches( // Without the `allowElementMutations` flag, this will no-op if the route // already exists so we can just call it for all returned patches payload.patches.forEach((p) => { - (window as WindowWithRouterGlobals).__router.patchRoutes( + (window as WindowWithRouterGlobals).__reactRouterDataRouter.patchRoutes( p.parentId ?? null, [createRouteFromServerManifest(p)], ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 994b7704b9..5176cdb4f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1117,9 +1117,6 @@ importers: '@react-router/node': specifier: workspace:* version: link:../react-router-node - '@vitejs/plugin-react': - specifier: ^4.5.2 - version: 4.5.2(vite@6.2.5(@types/node@20.11.30)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0)) '@vitejs/plugin-rsc': specifier: 0.4.11 version: 0.4.11(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vite@6.2.5(@types/node@20.11.30)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0)) @@ -1159,6 +1156,12 @@ importers: prettier: specifier: ^3.6.2 version: 3.6.2 + react: + specifier: '>=18' + version: 19.1.0 + react-dom: + specifier: '>=18' + version: 19.1.0(react@19.1.0) react-refresh: specifier: ^0.14.0 version: 0.14.2 @@ -13788,18 +13791,6 @@ snapshots: - tsx - yaml - '@vitejs/plugin-react@4.5.2(vite@6.2.5(@types/node@20.11.30)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0))': - dependencies: - '@babel/core': 7.27.7 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.7) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.7) - '@rolldown/pluginutils': 1.0.0-beta.11 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 6.2.5(@types/node@20.11.30)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0) - transitivePeerDependencies: - - supports-color - '@vitejs/plugin-react@4.5.2(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.19.3)(yaml@2.8.0))': dependencies: '@babel/core': 7.27.7 From 368b6822d370d7fb0340298ea29ad3a35a7d10f9 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 31 Jul 2025 11:52:58 +1000 Subject: [PATCH 02/14] Convert top-level await to dynamic import --- .../config/default-rsc-entries/entry.client.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx b/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx index 3e904deeee..eb26de3008 100644 --- a/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx +++ b/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx @@ -1,7 +1,3 @@ -if (import.meta.env.DEV) { - await import("virtual:react-router/unstable_rsc/inject-hmr-runtime"); -} - import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { @@ -25,7 +21,14 @@ setServerCallback( }), ); -createFromReadableStream(getRSCStream()).then((payload) => { +const injectHmrPromise: Promise = import.meta.env.DEV + ? import("virtual:react-router/unstable_rsc/inject-hmr-runtime") + : Promise.resolve(); + +Promise.all([ + createFromReadableStream(getRSCStream()), + injectHmrPromise, +]).then(([payload]) => { startTransition(() => { hydrateRoot( document, From a3850dd7c69b982e7181bd76e7d3f552e967b994 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 31 Jul 2025 16:53:25 +1000 Subject: [PATCH 03/14] Handle changes to non-route files, fix server component HMR --- packages/react-router-dev/vite/rsc/plugin.ts | 103 +++++++++++------- .../vite/rsc/virtual-route-config.ts | 2 +- .../vite/static/rsc-refresh-utils.mjs | 9 +- 3 files changed, 65 insertions(+), 49 deletions(-) diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts index 6dd5d55ac0..50e73ce34e 100644 --- a/packages/react-router-dev/vite/rsc/plugin.ts +++ b/packages/react-router-dev/vite/rsc/plugin.ts @@ -7,7 +7,7 @@ import { create } from "../virtual-module"; import * as Typegen from "../../typegen"; import { readFileSync } from "fs"; import { readFile } from "fs/promises"; -import path, { join, dirname } from "path"; +import path, { join, dirname } from "pathe"; import { type ConfigLoader, type ResolvedReactRouterConfig, @@ -196,55 +196,50 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { }, { name: "react-router/rsc/hmr/updates", - async handleHotUpdate({ server, file, modules, read }) { - const routeId = routeIdByFile?.get(file); + async hotUpdate(this, { server, file, modules }) { + if (this.environment.name !== "rsc") return; - if (routeId !== undefined) { - const vite = await import("vite"); + const isServerOnlyChange = + (server.environments.client.moduleGraph.getModulesByFile(file) + ?.size ?? 0) === 0; - const source = await read(); - - const virtualRouteModuleCode = ( - await server.environments.rsc.pluginContainer.transform( - source, - `${vite.normalizePath(file)}?route-module`, - ) - ).code; - const { staticExports } = parseRouteExports(virtualRouteModuleCode); - - const virtualServerRouteModuleCode = ( - await server.environments.rsc.pluginContainer.transform( - source, - `${vite.normalizePath(file)}?server-route-module`, - ) - ).code; - const { isServerFirstRoute } = parseRouteExports( - virtualServerRouteModuleCode, - ); + for (const mod of getModulesWithImporters(modules)) { + if (!mod.file) continue; - const hasAction = staticExports.includes("action"); - const hasComponent = staticExports.includes("default"); - const hasErrorBoundary = staticExports.includes("ErrorBoundary"); - const hasLoader = staticExports.includes("loader"); - - server.hot.send({ - type: "custom", - event: "react-router:hmr", - data: { - routeId, - isServerFirstRoute, - hasAction, - hasComponent, - hasErrorBoundary, - hasLoader, - }, - }); + const normalizedPath = path.normalize(mod.file); + const routeId = routeIdByFile?.get(normalizedPath); + if (routeId !== undefined) { + const routeSource = await readFile(normalizedPath, "utf8"); + const virtualRouteModuleCode = ( + await server.environments.rsc.pluginContainer.transform( + routeSource, + `${normalizedPath}?route-module`, + ) + ).code; + const { staticExports } = parseRouteExports(virtualRouteModuleCode); + const hasAction = staticExports.includes("action"); + const hasComponent = staticExports.includes("default"); + const hasErrorBoundary = staticExports.includes("ErrorBoundary"); + const hasLoader = staticExports.includes("loader"); + + server.hot.send({ + type: "custom", + event: "react-router:hmr", + data: { + routeId, + isServerOnlyChange, + hasAction, + hasComponent, + hasErrorBoundary, + hasLoader, + }, + }); + } } return modules; }, }, - // TODO: server-change-trigger-client-hmr? rsc({ entries: getRscEntries() }), ]; } @@ -288,6 +283,30 @@ function getDevPackageRoot(): string { throw new Error("Could not find package.json"); } +function getModulesWithImporters( + modules: Vite.EnvironmentModuleNode[], +): Set { + const visited = new Set(); + const result = new Set(); + + function walk(module: Vite.EnvironmentModuleNode) { + if (visited.has(module)) return; + + visited.add(module); + result.add(module); + + for (const importer of module.importers) { + walk(importer); + } + } + + for (const module of modules) { + walk(module); + } + + return result; +} + function addRefreshWrapper({ routeId, code, diff --git a/packages/react-router-dev/vite/rsc/virtual-route-config.ts b/packages/react-router-dev/vite/rsc/virtual-route-config.ts index 26e3e44446..a9a4352b4a 100644 --- a/packages/react-router-dev/vite/rsc/virtual-route-config.ts +++ b/packages/react-router-dev/vite/rsc/virtual-route-config.ts @@ -1,4 +1,4 @@ -import path from "node:path"; +import path from "pathe"; import type { RouteConfigEntry } from "../../routes"; export function createVirtualRouteConfig({ diff --git a/packages/react-router-dev/vite/static/rsc-refresh-utils.mjs b/packages/react-router-dev/vite/static/rsc-refresh-utils.mjs index 1955d46fbe..7600d33795 100644 --- a/packages/react-router-dev/vite/static/rsc-refresh-utils.mjs +++ b/packages/react-router-dev/vite/static/rsc-refresh-utils.mjs @@ -16,12 +16,9 @@ const enqueueUpdate = debounce(async () => { for (const routeUpdate of routeUpdates) { const routeId = routeUpdate.routeId; const routeModule = window.__reactRouterRouteModuleUpdates.get(routeId); - if (!routeModule) { - throw Error( - `[react-router:hmr] No module update found for route ${routeId}`, - ); + if (routeModule) { + routeUpdateByRouteId.set(routeId, { routeModule, ...routeUpdate }); } - routeUpdateByRouteId.set(routeId, { routeModule, ...routeUpdate }); } routeUpdates.clear(); __reactRouterDataRouter._updateRoutesForHMR(routeUpdateByRouteId); @@ -117,7 +114,7 @@ window.__reactRouterRouteModuleUpdates = new Map(); import.meta.hot.on("react-router:hmr", async (routeUpdate) => { routeUpdates.add(routeUpdate); - if (routeUpdate.isServerFirstRoute) { + if (routeUpdate.isServerOnlyChange) { enqueueUpdate(); } }); From 6ad70c9b5926b71acedda770541b6d13e5512575 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 1 Aug 2025 09:37:15 +1000 Subject: [PATCH 04/14] Check `import.meta.hot` for HMR runtime injection --- .../config/default-rsc-entries/entry.client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx b/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx index eb26de3008..5e60fb2da2 100644 --- a/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx +++ b/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx @@ -21,7 +21,7 @@ setServerCallback( }), ); -const injectHmrPromise: Promise = import.meta.env.DEV +const injectHmrPromise: Promise = import.meta.hot ? import("virtual:react-router/unstable_rsc/inject-hmr-runtime") : Promise.resolve(); From 1c02ff1be9c6676314a3e6555a0aa5b9b7781bc5 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 1 Aug 2025 09:37:55 +1000 Subject: [PATCH 05/14] Set `jsxDev` esbuild option --- packages/react-router-dev/vite/rsc/plugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts index 50e73ce34e..a1b672466d 100644 --- a/packages/react-router-dev/vite/rsc/plugin.ts +++ b/packages/react-router-dev/vite/rsc/plugin.ts @@ -45,6 +45,7 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { return { esbuild: { jsx: "automatic", + jsxDev: viteCommand !== "build", }, environments: { client: { build: { outDir: "build/client" } }, From 3c9392d577726eb202d1b24b39888153f4e8daa4 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 1 Aug 2025 10:32:27 +1000 Subject: [PATCH 06/14] Suppress directive warnings --- packages/react-router-dev/vite/rsc/plugin.ts | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts index a1b672466d..f485f45189 100644 --- a/packages/react-router-dev/vite/rsc/plugin.ts +++ b/packages/react-router-dev/vite/rsc/plugin.ts @@ -52,6 +52,36 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { rsc: { build: { outDir: "build/server" } }, ssr: { build: { outDir: "build/server/__ssr_build" } }, }, + build: { + rollupOptions: { + // Copied from https://github.com/vitejs/vite-plugin-react/blob/c602225271d4acf462ba00f8d6d8a2e42492c5cd/packages/common/warning.ts + onwarn(warning, defaultHandler) { + if ( + warning.code === "MODULE_LEVEL_DIRECTIVE" && + (warning.message.includes("use client") || + warning.message.includes("use server")) + ) { + return; + } + // https://github.com/vitejs/vite/issues/15012 + if ( + warning.code === "SOURCEMAP_ERROR" && + warning.message.includes("resolve original location") && + warning.pos === 0 + ) { + return; + } + if (viteUserConfig.build?.rollupOptions?.onwarn) { + viteUserConfig.build.rollupOptions.onwarn( + warning, + defaultHandler, + ); + } else { + defaultHandler(warning); + } + }, + }, + }, }; }, async buildEnd() { From 92083f01bd5a6ba77286683b6ce29c5c54c50936 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 1 Aug 2025 11:31:19 +1000 Subject: [PATCH 07/14] Fix plugin config so react peers aren't required --- packages/react-router-dev/package.json | 2 -- packages/react-router-dev/vite/rsc/plugin.ts | 27 ++++++++++++++++++++ pnpm-lock.yaml | 6 ----- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json index 02acb92fc9..2d00ecc3d0 100644 --- a/packages/react-router-dev/package.json +++ b/packages/react-router-dev/package.json @@ -123,8 +123,6 @@ }, "peerDependencies": { "@react-router/serve": "workspace:^", - "react": ">=18", - "react-dom": ">=18", "react-router": "workspace:^", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts index f485f45189..d388333da5 100644 --- a/packages/react-router-dev/vite/rsc/plugin.ts +++ b/packages/react-router-dev/vite/rsc/plugin.ts @@ -43,6 +43,33 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { config = configResult.value; return { + resolve: { + dedupe: [ + // https://react.dev/warnings/invalid-hook-call-warning#duplicate-react + "react", + "react-dom", + // Avoid router duplicates since mismatching routers cause `Error: + // You must render this element inside a element`. + "react-router", + "react-router/dom", + "react-router-dom", + ], + }, + optimizeDeps: { + esbuildOptions: { + jsx: "automatic", + }, + include: [ + // Pre-bundle React dependencies to avoid React duplicates, + // even if React dependencies are not direct dependencies. + // https://react.dev/warnings/invalid-hook-call-warning#duplicate-react + "react", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "react-dom", + "react-dom/client", + ], + }, esbuild: { jsx: "automatic", jsxDev: viteCommand !== "build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5176cdb4f9..18a1fd2beb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1156,12 +1156,6 @@ importers: prettier: specifier: ^3.6.2 version: 3.6.2 - react: - specifier: '>=18' - version: 19.1.0 - react-dom: - specifier: '>=18' - version: 19.1.0(react@19.1.0) react-refresh: specifier: ^0.14.0 version: 0.14.2 From 32c333e02976d2322ed062c952b023ee6bfd1b2d Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 1 Aug 2025 11:48:41 +1000 Subject: [PATCH 08/14] Ensure HMR runtime is injected before getting RSC stream --- .../default-rsc-entries/entry.client.tsx | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx b/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx index 5e60fb2da2..78247e8609 100644 --- a/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx +++ b/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx @@ -25,19 +25,18 @@ const injectHmrPromise: Promise = import.meta.hot ? import("virtual:react-router/unstable_rsc/inject-hmr-runtime") : Promise.resolve(); -Promise.all([ - createFromReadableStream(getRSCStream()), - injectHmrPromise, -]).then(([payload]) => { - startTransition(() => { - hydrateRoot( - document, - - - , - ); +injectHmrPromise + .then(() => createFromReadableStream(getRSCStream())) + .then((payload) => { + startTransition(() => { + hydrateRoot( + document, + + + , + ); + }); }); -}); From 13c1f7c390ed1f7c7aae49452e4746070f004f93 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Fri, 1 Aug 2025 16:26:34 +1000 Subject: [PATCH 09/14] Run existing HMR tests in RSC mode, fix tests --- integration/vite-hmr-hdr-test.ts | 48 +++++++++++++++---- .../default-rsc-entries/entry.client.tsx | 32 ++++++------- packages/react-router-dev/vite/rsc/plugin.ts | 16 ++++--- 3 files changed, 63 insertions(+), 33 deletions(-) diff --git a/integration/vite-hmr-hdr-test.ts b/integration/vite-hmr-hdr-test.ts index d830894477..bd5b6bec49 100644 --- a/integration/vite-hmr-hdr-test.ts +++ b/integration/vite-hmr-hdr-test.ts @@ -3,15 +3,27 @@ import path from "node:path"; import type { Page, PlaywrightWorkerOptions } from "@playwright/test"; import { expect } from "@playwright/test"; -import type { Files } from "./helpers/vite.js"; +import type { Files, TemplateName } from "./helpers/vite.js"; import { test, createEditor, EXPRESS_SERVER, viteConfig, viteMajorTemplates, + reactRouterConfig, } from "./helpers/vite.js"; +const templates = [ + ...viteMajorTemplates, + { + templateName: "rsc-vite-framework", + templateDisplayName: "RSC Framework Mode", + }, +] as const satisfies ReadonlyArray<{ + templateName: TemplateName; + templateDisplayName: string; +}>; + const indexRoute = ` // imports import { useState, useEffect } from "react"; @@ -40,28 +52,36 @@ const indexRoute = ` `; test.describe("Vite HMR & HDR", () => { - viteMajorTemplates.forEach(({ templateName, templateDisplayName }) => { + templates.forEach(({ templateName, templateDisplayName }) => { test.describe(templateDisplayName, () => { test("vite dev", async ({ page, browserName, dev }) => { let files: Files = async ({ port }) => ({ - "vite.config.js": await viteConfig.basic({ port }), + "vite.config.js": await viteConfig.basic({ port, templateName }), + "react-router.config.ts": reactRouterConfig({ + viteEnvironmentApi: templateName.includes("rsc"), + }), "app/routes/_index.tsx": indexRoute, }); let { cwd, port } = await dev(files, templateName); - await workflow({ page, browserName, cwd, port }); + await workflow({ templateName, page, browserName, cwd, port }); }); test("express", async ({ page, browserName, customDev }) => { + test.skip(templateName.includes("rsc"), "RSC is not supported"); let files: Files = async ({ port }) => ({ - "vite.config.js": await viteConfig.basic({ port }), + "vite.config.js": await viteConfig.basic({ port, templateName }), + "react-router.config.ts": reactRouterConfig({ + viteEnvironmentApi: templateName.includes("rsc"), + }), "server.mjs": EXPRESS_SERVER({ port }), "app/routes/_index.tsx": indexRoute, }); let { cwd, port } = await customDev(files, templateName); - await workflow({ page, browserName, cwd, port }); + await workflow({ templateName, page, browserName, cwd, port }); }); test("mdx", async ({ page, dev }) => { + test.skip(templateName.includes("rsc"), "RSC is not supported"); let files: Files = async ({ port }) => ({ "vite.config.ts": ` import { defineConfig } from "vite"; @@ -120,11 +140,13 @@ test.describe("Vite HMR & HDR", () => { }); async function workflow({ + templateName, page, browserName, cwd, port, }: { + templateName: TemplateName; page: Page; browserName: PlaywrightWorkerOptions["browserName"]; cwd: string; @@ -145,7 +167,12 @@ async function workflow({ // setup: browser state let hmrStatus = page.locator("#index [data-hmr]"); - await expect(page).toHaveTitle("HMR updated title: 0"); + + // RSC doesn't support meta export? + if (!templateName.includes("rsc")) { + await expect(page).toHaveTitle("HMR updated title: 0"); + } + await expect(hmrStatus).toHaveText("HMR updated: 0"); let input = page.locator("#index input"); await expect(input).toBeVisible(); @@ -159,7 +186,12 @@ async function workflow({ .replace("HMR updated: 0", "HMR updated: 1"), ); await page.waitForLoadState("networkidle"); - await expect(page).toHaveTitle("HMR updated title: 1"); + + // RSC doesn't support meta export? + if (!templateName.includes("rsc")) { + await expect(page).toHaveTitle("HMR updated title: 1"); + } + await expect(hmrStatus).toHaveText("HMR updated: 1"); await expect(input).toHaveValue("stateful"); expect(page.errors).toEqual([]); diff --git a/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx b/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx index 78247e8609..740e2fc6bb 100644 --- a/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx +++ b/packages/react-router-dev/config/default-rsc-entries/entry.client.tsx @@ -1,3 +1,5 @@ +import "virtual:react-router/unstable_rsc/inject-hmr-runtime"; + import { startTransition, StrictMode } from "react"; import { hydrateRoot } from "react-dom/client"; import { @@ -21,22 +23,16 @@ setServerCallback( }), ); -const injectHmrPromise: Promise = import.meta.hot - ? import("virtual:react-router/unstable_rsc/inject-hmr-runtime") - : Promise.resolve(); - -injectHmrPromise - .then(() => createFromReadableStream(getRSCStream())) - .then((payload) => { - startTransition(() => { - hydrateRoot( - document, - - - , - ); - }); +createFromReadableStream(getRSCStream()).then((payload) => { + startTransition(() => { + hydrateRoot( + document, + + + , + ); }); +}); diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts index d388333da5..cdb8922846 100644 --- a/packages/react-router-dev/vite/rsc/plugin.ts +++ b/packages/react-router-dev/vite/rsc/plugin.ts @@ -172,13 +172,15 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { async load(id) { if (id !== virtual.injectHmrRuntime.resolvedId) return; - return [ - `import RefreshRuntime from "${virtual.hmrRuntime.id}"`, - "RefreshRuntime.injectIntoGlobalHook(window)", - "window.$RefreshReg$ = () => {}", - "window.$RefreshSig$ = () => (type) => type", - "window.__vite_plugin_react_preamble_installed__ = true", - ].join("\n"); + return viteCommand === "serve" + ? [ + `import RefreshRuntime from "${virtual.hmrRuntime.id}"`, + "RefreshRuntime.injectIntoGlobalHook(window)", + "window.$RefreshReg$ = () => {}", + "window.$RefreshSig$ = () => (type) => type", + "window.__vite_plugin_react_preamble_installed__ = true", + ].join("\n") + : ""; }, }, { From 1511adcc5f578d79dba614940fa291cba303ce53 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 4 Aug 2025 10:10:54 +1000 Subject: [PATCH 10/14] Add dedicated RSC HMR + HDR test --- integration/vite-hmr-hdr-rsc-test.ts | 383 +++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 integration/vite-hmr-hdr-rsc-test.ts diff --git a/integration/vite-hmr-hdr-rsc-test.ts b/integration/vite-hmr-hdr-rsc-test.ts new file mode 100644 index 0000000000..b5fba52cf8 --- /dev/null +++ b/integration/vite-hmr-hdr-rsc-test.ts @@ -0,0 +1,383 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { expect } from "@playwright/test"; + +import type { Files, TemplateName } from "./helpers/vite.js"; +import { + test, + createEditor, + viteConfig, + reactRouterConfig, +} from "./helpers/vite.js"; + +const templateName = "rsc-vite-framework" as const satisfies TemplateName; + +test.describe("Vite HMR & HDR (RSC)", () => { + test("vite dev", async ({ page, dev }) => { + let files: Files = async ({ port }) => ({ + "vite.config.js": await viteConfig.basic({ port, templateName }), + "react-router.config.ts": reactRouterConfig({ + viteEnvironmentApi: templateName.includes("rsc"), + }), + "app/routes/hmr/route.tsx": ` + // imports + import { Mounted } from "./route.client"; + + // loader + + export function ServerComponent() { + return ( +
+

Index

+ + +

HMR updated: 0

+ {/* elements */} +
+ ); + } + `, + "app/routes/hmr/route.client.tsx": ` + "use client"; + + import { useState, useEffect } from "react"; + + export function Mounted() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return

Mounted: {mounted ? "yes" : "no"}

; + } + `, + }); + let { cwd, port } = await dev(files, templateName); + let edit = createEditor(cwd); + + // setup: initial render + await page.goto(`http://localhost:${port}/hmr`, { + waitUntil: "networkidle", + }); + await expect(page.locator("#index [data-title]")).toHaveText("Index"); + + // setup: hydration + await expect(page.locator("#index [data-mounted]")).toHaveText( + "Mounted: yes", + ); + + // setup: browser state + let hmrStatus = page.locator("#index [data-hmr]"); + + await expect(hmrStatus).toHaveText("HMR updated: 0"); + let input = page.locator("#index input"); + await expect(input).toBeVisible(); + await input.type("stateful"); + expect(page.errors).toEqual([]); + + // route: HMR + await edit("app/routes/hmr/route.tsx", (contents) => + contents.replace("HMR updated: 0", "HMR updated: 1"), + ); + await page.waitForLoadState("networkidle"); + + await expect(hmrStatus).toHaveText("HMR updated: 1"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // route: add loader + await edit("app/routes/hmr/route.tsx", (contents) => + contents + .replace( + "// loader", + `// loader\nexport const loader = () => ({ message: "HDR updated: 0" });`, + ) + .replace( + "export function ServerComponent() {", + `export function ServerComponent({ loaderData }: { loaderData: { message: string } }) {`, + ) + .replace( + "{/* elements */}", + `{/* elements */}\n

{loaderData.message}

`, + ), + ); + await page.waitForLoadState("networkidle"); + let hdrStatus = page.locator("#index [data-hdr]"); + await expect(hdrStatus).toHaveText("HDR updated: 0"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // route: HDR + await edit("app/routes/hmr/route.tsx", (contents) => + contents.replace("HDR updated: 0", "HDR updated: 1"), + ); + await page.waitForLoadState("networkidle"); + await expect(hdrStatus).toHaveText("HDR updated: 1"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // route: HMR + HDR + await edit("app/routes/hmr/route.tsx", (contents) => + contents + .replace("HMR updated: 1", "HMR updated: 2") + .replace("HDR updated: 1", "HDR updated: 2"), + ); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("HMR updated: 2"); + await expect(hdrStatus).toHaveText("HDR updated: 2"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // create new non-route imported server component + await fs.writeFile( + path.join(cwd, "app/imported-server-component.tsx"), + ` + import { ImportedServerComponentClientMounted } from "./imported-server-component-client"; + + export function ImportedServerComponent() { + return ( +
+

Imported Server Component HMR: 0

+ +
+ ); + } + `, + "utf8", + ); + await fs.writeFile( + path.join(cwd, "app/imported-server-component-client.tsx"), + ` + "use client"; + + import { useState, useEffect } from "react"; + + export function ImportedServerComponentClientMounted() { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + + return ( +

+ Imported Server Component Client Mounted: {mounted ? "yes" : "no"} +

+ ); + } + `, + "utf8", + ); + await edit("app/routes/hmr/route.tsx", (contents) => + contents + .replace( + "// imports", + `// imports\nimport { ImportedServerComponent } from "../../imported-server-component";`, + ) + .replace( + "{/* elements */}", + "{/* elements */}\n", + ), + ); + await page.waitForLoadState("networkidle"); + let serverComponent = page.locator( + "#index [data-imported-server-component]", + ); + let importedServerComponentClientMounted = page.locator( + "#index [data-imported-server-component-client-mounted]", + ); + await expect(serverComponent).toBeVisible(); + await expect(serverComponent).toHaveText( + "Imported Server Component HMR: 0", + ); + await expect(importedServerComponentClientMounted).toBeVisible(); + await expect(importedServerComponentClientMounted).toHaveText( + "Imported Server Component Client Mounted: yes", + ); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // non-route imported server component: HMR + await edit("app/imported-server-component.tsx", (contents) => + contents.replace( + "Imported Server Component HMR: 0", + "Imported Server Component HMR: 1", + ), + ); + await page.waitForLoadState("networkidle"); + await expect(serverComponent).toHaveText( + "Imported Server Component HMR: 1", + ); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // create new non-route imported client component + await fs.writeFile( + path.join(cwd, "app/imported-client-component.tsx"), + ` + "use client"; + + import { useState } from "react"; + + export function ImportedClientComponent() { + const [count, setCount] = useState(0); + return ( +
+

Imported Client Component HMR: 0

+ +
+ ); + } + `, + "utf8", + ); + await edit("app/routes/hmr/route.tsx", (contents) => + contents + .replace( + "// imports", + `// imports\nimport { ImportedClientComponent } from "../../imported-client-component";`, + ) + .replace( + "{/* elements */}", + "{/* elements */}\n", + ), + ); + await page.waitForLoadState("networkidle"); + let clientComponent = page.locator( + "#index [data-imported-client-component]", + ); + let clientButton = page.locator( + "#index [data-imported-client-component-button]", + ); + await expect(clientComponent).toBeVisible(); + await expect(clientComponent).toHaveText( + "Imported Client Component HMR: 0", + ); + await expect(clientButton).toHaveText("Count: 0"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // non-route imported client component: HMR + await edit("app/imported-client-component.tsx", (contents) => + contents.replace( + "Imported Client Component HMR: 0", + "Imported Client Component HMR: 1", + ), + ); + await page.waitForLoadState("networkidle"); + await expect(clientComponent).toHaveText( + "Imported Client Component HMR: 1", + ); + await expect(clientButton).toHaveText("Count: 0"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // non-route imported client component: state preservation + await clientButton.click(); + await expect(clientButton).toHaveText("Count: 1"); + await edit("app/imported-client-component.tsx", (contents) => + contents.replace( + "Imported Client Component HMR: 1", + "Imported Client Component HMR: 2", + ), + ); + await page.waitForLoadState("networkidle"); + await expect(clientComponent).toHaveText( + "Imported Client Component HMR: 2", + ); + await expect(clientButton).toHaveText("Count: 1"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // create new non-route server module + await fs.writeFile( + path.join(cwd, "app/indirect-hdr-dep.ts"), + `export const indirect = "indirect 0"`, + "utf8", + ); + await fs.writeFile( + path.join(cwd, "app/direct-hdr-dep.ts"), + ` + import { indirect } from "./indirect-hdr-dep" + export const direct = "direct 0 & " + indirect + `, + "utf8", + ); + await edit("app/routes/hmr/route.tsx", (contents) => + contents + .replace( + "// imports", + `// imports\nimport { direct } from "../../direct-hdr-dep"`, + ) + .replace( + `{ message: "HDR updated: 2" }`, + `{ message: "HDR updated: " + direct }`, + ) + .replace(`HDR updated: 2`, `HDR updated: direct 0 & indirect 0`), + ); + await page.waitForLoadState("networkidle"); + await expect(hdrStatus).toHaveText("HDR updated: direct 0 & indirect 0"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // non-route: HDR for direct dependency + await edit("app/direct-hdr-dep.ts", (contents) => + contents.replace("direct 0 &", "direct 1 &"), + ); + await page.waitForLoadState("networkidle"); + await expect(hdrStatus).toHaveText("HDR updated: direct 1 & indirect 0"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // non-route: HDR for indirect dependency + await edit("app/indirect-hdr-dep.ts", (contents) => + contents.replace("indirect 0", "indirect 1"), + ); + await page.waitForLoadState("networkidle"); + await expect(hdrStatus).toHaveText("HDR updated: direct 1 & indirect 1"); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + + // everything everywhere all at once + await Promise.all([ + edit("app/routes/hmr/route.tsx", (contents) => + contents + .replace("HMR updated: 2", "HMR updated: 3") + .replace("HDR updated: ", "HDR updated: route & "), + ), + edit("app/imported-server-component.tsx", (contents) => + contents.replace( + "Imported Server Component HMR: 1", + "Imported Server Component HMR: 2", + ), + ), + edit("app/imported-client-component.tsx", (contents) => + contents.replace( + "Imported Client Component HMR: 2", + "Imported Client Component HMR: 3", + ), + ), + edit("app/direct-hdr-dep.ts", (contents) => + contents.replace("direct 1 &", "direct 2 &"), + ), + edit("app/indirect-hdr-dep.ts", (contents) => + contents.replace("indirect 1", "indirect 2"), + ), + ]); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("HMR updated: 3"); + await expect(serverComponent).toHaveText( + "Imported Server Component HMR: 2", + ); + await expect(clientComponent).toHaveText( + "Imported Client Component HMR: 3", + ); + await expect(clientButton).toHaveText("Count: 1"); + await expect(hdrStatus).toHaveText( + "HDR updated: route & direct 2 & indirect 2", + ); + await expect(input).toHaveValue("stateful"); + expect(page.errors).toEqual([]); + }); +}); From 2a84d6256d4ab7d632b74cd63e48d128bca32312 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 4 Aug 2025 16:21:39 +1000 Subject: [PATCH 11/14] support switching from server-first to client route --- integration/vite-hmr-hdr-rsc-test.ts | 46 +++++++++++++++++++ packages/react-router-dev/vite/rsc/plugin.ts | 2 +- .../vite/rsc/virtual-route-modules.ts | 36 ++++++++++++--- packages/react-router/lib/rsc/server.rsc.ts | 8 ++++ 4 files changed, 84 insertions(+), 8 deletions(-) diff --git a/integration/vite-hmr-hdr-rsc-test.ts b/integration/vite-hmr-hdr-rsc-test.ts index b5fba52cf8..c915328610 100644 --- a/integration/vite-hmr-hdr-rsc-test.ts +++ b/integration/vite-hmr-hdr-rsc-test.ts @@ -379,5 +379,51 @@ test.describe("Vite HMR & HDR (RSC)", () => { ); await expect(input).toHaveValue("stateful"); expect(page.errors).toEqual([]); + + // switch from server-first to client route + await edit("app/routes/hmr/route.tsx", (contents) => + contents + .replace( + "export function ServerComponent", + "export default function ClientComponent", + ) + .replace("HMR updated: 3", "Client Route HMR: 0"), + ); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("Client Route HMR: 0"); + // state is not preserved when switching from server to client route + await expect(input).toHaveValue(""); + await input.type("client stateful"); + expect(page.errors).toEqual([]); + await edit("app/routes/hmr/route.tsx", (contents) => + contents.replace("Client Route HMR: 0", "Client Route HMR: 1"), + ); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("Client Route HMR: 1"); + await expect(input).toHaveValue("client stateful"); + expect(page.errors).toEqual([]); + + // switch from client route back to server-first route + await edit("app/routes/hmr/route.tsx", (contents) => + contents + .replace( + "export default function ClientComponent", + "export function ServerComponent", + ) + .replace("Client Route HMR: 1", "Server Route HMR: 0"), + ); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("Server Route HMR: 0"); + // State is not preserved when switching from client to server route + await expect(input).toHaveValue(""); + await input.type("server stateful"); + expect(page.errors).toEqual([]); + await edit("app/routes/hmr/route.tsx", (contents) => + contents.replace("Server Route HMR: 0", "Server Route HMR: 1"), + ); + await page.waitForLoadState("networkidle"); + await expect(hmrStatus).toHaveText("Server Route HMR: 1"); + await expect(input).toHaveValue("server stateful"); + expect(page.errors).toEqual([]); }); }); diff --git a/packages/react-router-dev/vite/rsc/plugin.ts b/packages/react-router-dev/vite/rsc/plugin.ts index cdb8922846..f2854d12e5 100644 --- a/packages/react-router-dev/vite/rsc/plugin.ts +++ b/packages/react-router-dev/vite/rsc/plugin.ts @@ -158,7 +158,7 @@ export function reactRouterRSCVitePlugin(): Vite.PluginOption[] { { name: "react-router/rsc/virtual-route-modules", transform(code, id) { - return transformVirtualRouteModules({ code, id }); + return transformVirtualRouteModules({ code, id, viteCommand }); }, }, { diff --git a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts index 59b7e2da3b..a786bd38d8 100644 --- a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts +++ b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts @@ -1,3 +1,4 @@ +import type { ConfigEnv } from "vite"; import * as babel from "../babel"; import { parse as esModuleLexer } from "es-module-lexer"; import { removeExports } from "../remove-exports"; @@ -44,19 +45,23 @@ function isClientRouteExport(name: string): name is ClientRouteExport { return CLIENT_ROUTE_EXPORTS_SET.has(name as ClientRouteExport); } +type ViteCommand = ConfigEnv["command"]; + export function transformVirtualRouteModules({ id, code, + viteCommand, }: { id: string; code: string; + viteCommand: ViteCommand; }) { if (!id.includes("route-module")) { return; } if (isVirtualRouteModuleId(id)) { - return createVirtualRouteModuleCode({ id, code }); + return createVirtualRouteModuleCode({ id, code, viteCommand }); } if (isVirtualServerRouteModuleId(id)) { @@ -64,18 +69,21 @@ export function transformVirtualRouteModules({ } if (isVirtualClientRouteModuleId(id)) { - return createVirtualClientRouteModuleCode({ id, code }); + return createVirtualClientRouteModuleCode({ id, code, viteCommand }); } } async function createVirtualRouteModuleCode({ id, code: routeSource, + viteCommand, }: { id: string; code: string; + viteCommand: ViteCommand; }) { - const { staticExports, isServerFirstRoute } = parseRouteExports(routeSource); + const { staticExports, isServerFirstRoute, hasClientExports } = + parseRouteExports(routeSource); const clientModuleId = getVirtualClientModuleId(id); const serverModuleId = getVirtualServerModuleId(id); @@ -91,6 +99,9 @@ async function createVirtualRouteModuleCode({ code += `export { ${staticExport} } from "${serverModuleId}";\n`; } } + if (viteCommand === "serve" && !hasClientExports) { + code += `export { __ensureClientRouteModuleForHmr } from "${clientModuleId}";\n`; + } } else { for (const staticExport of staticExports) { if (isClientRouteExport(staticExport)) { @@ -142,11 +153,14 @@ function createVirtualServerRouteModuleCode({ function createVirtualClientRouteModuleCode({ id, code: routeSource, + viteCommand, }: { id: string; code: string; + viteCommand: ViteCommand; }) { - const { staticExports, isServerFirstRoute } = parseRouteExports(routeSource); + const { staticExports, isServerFirstRoute, hasClientExports } = + parseRouteExports(routeSource); const exportsToRemove = isServerFirstRoute ? [...SERVER_ONLY_ROUTE_EXPORTS, ...COMPONENT_EXPORTS] : SERVER_ONLY_ROUTE_EXPORTS; @@ -168,17 +182,25 @@ function createVirtualClientRouteModuleCode({ generatorResult.code += `}\n`; } + if (viteCommand === "serve" && isServerFirstRoute && !hasClientExports) { + generatorResult.code += `\nexport const __ensureClientRouteModuleForHmr = true;`; + } + return generatorResult; } export function parseRouteExports(code: string) { const [, exportSpecifiers] = esModuleLexer(code); const staticExports = exportSpecifiers.map(({ n: name }) => name); + const isServerFirstRoute = staticExports.some( + (staticExport) => staticExport === "ServerComponent", + ); return { staticExports, - isServerFirstRoute: staticExports.some( - (staticExport) => staticExport === "ServerComponent", - ), + isServerFirstRoute, + hasClientExports: isServerFirstRoute + ? staticExports.some(isClientNonComponentExport) + : staticExports.some(isClientRouteExport), }; } diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index 65a48ecfd3..1d51d4b38d 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -1110,6 +1110,14 @@ async function getRSCRouteMatch( pathname: match.pathname, pathnameBase: match.pathnameBase, shouldRevalidate: (match.route as any).shouldRevalidate, + // Add an unused client-only export (if present) so HMR can support + // switching from server-first to client-only routes during development + ...((match.route as any).__ensureClientRouteModuleForHmr + ? { + __ensureClientRouteModuleForHmr: (match.route as any) + .__ensureClientRouteModuleForHmr, + } + : {}), }; } From 7c185d976a1310cba9a68113d77961eb90516977 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 4 Aug 2025 17:19:29 +1000 Subject: [PATCH 12/14] fix test --- integration/vite-hmr-hdr-rsc-test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/vite-hmr-hdr-rsc-test.ts b/integration/vite-hmr-hdr-rsc-test.ts index c915328610..c4c4f5c016 100644 --- a/integration/vite-hmr-hdr-rsc-test.ts +++ b/integration/vite-hmr-hdr-rsc-test.ts @@ -392,7 +392,7 @@ test.describe("Vite HMR & HDR (RSC)", () => { await page.waitForLoadState("networkidle"); await expect(hmrStatus).toHaveText("Client Route HMR: 0"); // state is not preserved when switching from server to client route - await expect(input).toHaveValue(""); + await input.clear(); await input.type("client stateful"); expect(page.errors).toEqual([]); await edit("app/routes/hmr/route.tsx", (contents) => @@ -415,7 +415,7 @@ test.describe("Vite HMR & HDR (RSC)", () => { await page.waitForLoadState("networkidle"); await expect(hmrStatus).toHaveText("Server Route HMR: 0"); // State is not preserved when switching from client to server route - await expect(input).toHaveValue(""); + await input.clear(); await input.type("server stateful"); expect(page.errors).toEqual([]); await edit("app/routes/hmr/route.tsx", (contents) => From 5c520189ad32c7624e11412b3699fa45b3ac717f Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 4 Aug 2025 17:29:16 +1000 Subject: [PATCH 13/14] refactor --- .../react-router-dev/vite/rsc/virtual-route-modules.ts | 10 +++++----- .../react-router-dev/vite/static/rsc-refresh-utils.mjs | 2 +- packages/react-router/lib/rsc/server.rsc.ts | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts index a786bd38d8..e3785edf58 100644 --- a/packages/react-router-dev/vite/rsc/virtual-route-modules.ts +++ b/packages/react-router-dev/vite/rsc/virtual-route-modules.ts @@ -100,7 +100,7 @@ async function createVirtualRouteModuleCode({ } } if (viteCommand === "serve" && !hasClientExports) { - code += `export { __ensureClientRouteModuleForHmr } from "${clientModuleId}";\n`; + code += `export { __ensureClientRouteModuleForHMR } from "${clientModuleId}";\n`; } } else { for (const staticExport of staticExports) { @@ -183,7 +183,7 @@ function createVirtualClientRouteModuleCode({ } if (viteCommand === "serve" && isServerFirstRoute && !hasClientExports) { - generatorResult.code += `\nexport const __ensureClientRouteModuleForHmr = true;`; + generatorResult.code += `\nexport const __ensureClientRouteModuleForHMR = true;`; } return generatorResult; @@ -198,9 +198,9 @@ export function parseRouteExports(code: string) { return { staticExports, isServerFirstRoute, - hasClientExports: isServerFirstRoute - ? staticExports.some(isClientNonComponentExport) - : staticExports.some(isClientRouteExport), + hasClientExports: staticExports.some( + isServerFirstRoute ? isClientNonComponentExport : isClientRouteExport, + ), }; } diff --git a/packages/react-router-dev/vite/static/rsc-refresh-utils.mjs b/packages/react-router-dev/vite/static/rsc-refresh-utils.mjs index 7600d33795..6772b8cbaa 100644 --- a/packages/react-router-dev/vite/static/rsc-refresh-utils.mjs +++ b/packages/react-router-dev/vite/static/rsc-refresh-utils.mjs @@ -78,7 +78,7 @@ function validateRefreshBoundaryAndEnqueueUpdate( nextExports, (key, value) => { hasExports = true; - // Remix can handle Remix-specific exports (e.g. `meta` and `links`) + // React Router can handle additional exports (e.g. `meta` and `links`) if (acceptExports.includes(key)) return true; // React Fast Refresh can handle component exports if (exports.isLikelyComponentType(value)) return true; diff --git a/packages/react-router/lib/rsc/server.rsc.ts b/packages/react-router/lib/rsc/server.rsc.ts index 1d51d4b38d..375a02bb14 100644 --- a/packages/react-router/lib/rsc/server.rsc.ts +++ b/packages/react-router/lib/rsc/server.rsc.ts @@ -1111,11 +1111,11 @@ async function getRSCRouteMatch( pathnameBase: match.pathnameBase, shouldRevalidate: (match.route as any).shouldRevalidate, // Add an unused client-only export (if present) so HMR can support - // switching from server-first to client-only routes during development - ...((match.route as any).__ensureClientRouteModuleForHmr + // switching between server-first and client-only routes during development + ...((match.route as any).__ensureClientRouteModuleForHMR ? { - __ensureClientRouteModuleForHmr: (match.route as any) - .__ensureClientRouteModuleForHmr, + __ensureClientRouteModuleForHMR: (match.route as any) + .__ensureClientRouteModuleForHMR, } : {}), }; From 8cd58b17f5dc705d11c6077f7527cf974af615a0 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 4 Aug 2025 17:41:09 +1000 Subject: [PATCH 14/14] update comment --- integration/vite-hmr-hdr-rsc-test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/integration/vite-hmr-hdr-rsc-test.ts b/integration/vite-hmr-hdr-rsc-test.ts index c4c4f5c016..0fd031af35 100644 --- a/integration/vite-hmr-hdr-rsc-test.ts +++ b/integration/vite-hmr-hdr-rsc-test.ts @@ -391,7 +391,8 @@ test.describe("Vite HMR & HDR (RSC)", () => { ); await page.waitForLoadState("networkidle"); await expect(hmrStatus).toHaveText("Client Route HMR: 0"); - // state is not preserved when switching from server to client route + // adding/removing client component exports causes an HMR invalidation and a + // page reload. some browsers maintain input state, so we forcibly clear await input.clear(); await input.type("client stateful"); expect(page.errors).toEqual([]); @@ -414,7 +415,8 @@ test.describe("Vite HMR & HDR (RSC)", () => { ); await page.waitForLoadState("networkidle"); await expect(hmrStatus).toHaveText("Server Route HMR: 0"); - // State is not preserved when switching from client to server route + // adding/removing client component exports causes an HMR invalidation and a + // page reload. some browsers maintain input state, so we forcibly clear await input.clear(); await input.type("server stateful"); expect(page.errors).toEqual([]);