From 8b9a4a476a5cc8c73e68040ec5687e23a42c41a3 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:44:40 +0000 Subject: [PATCH 1/7] feat: state-preserving HMR and hot data revalidation in classic dev Ports the classic-mode (non-RSC) dev HMR feature from the RR8-compat branch onto main: route module updates in development now apply React Refresh registration and in-place route patching instead of forcing a full reload, and server code changes bump an HDR revision file that signals hot data revalidation to the client. Degrades to the previous full-reload behavior when @rsbuild/plugin-react is not present. Co-Authored-By: Claude Fable 5 --- .../align-refresh-registration-backfill.md | 8 + .changeset/classic-dev-hmr.md | 5 + src/build-output-transforms.ts | 5 + src/dev-generation.ts | 61 ++- src/dev-hmr.ts | 381 ++++++++++++++++++ src/dev-runtime-controller.ts | 46 ++- src/index.ts | 36 ++ src/route-artifacts.ts | 133 +++++- src/route-transform-tasks.ts | 196 ++++++++- tests/dev-hmr.test.ts | 56 +++ tests/dev-runtime-controller.test.ts | 98 +++++ tests/parallel-route-transforms.test.ts | 156 +++++++ 12 files changed, 1155 insertions(+), 26 deletions(-) create mode 100644 .changeset/align-refresh-registration-backfill.md create mode 100644 .changeset/classic-dev-hmr.md create mode 100644 src/dev-hmr.ts create mode 100644 tests/dev-hmr.test.ts diff --git a/.changeset/align-refresh-registration-backfill.md b/.changeset/align-refresh-registration-backfill.md new file mode 100644 index 00000000..bf9cba17 --- /dev/null +++ b/.changeset/align-refresh-registration-backfill.md @@ -0,0 +1,8 @@ +--- +'rsbuild-plugin-react-router': patch +--- + +Align the Fast Refresh registration backfill with react-refresh's own +component-detection rules so `memo`/`forwardRef` components in pre-lowered +(MDX) routes register for HMR. Multi-declarator lists, curried arrows, and +require/import interop callees no longer produce false registrations. diff --git a/.changeset/classic-dev-hmr.md b/.changeset/classic-dev-hmr.md new file mode 100644 index 00000000..f40ce6b3 --- /dev/null +++ b/.changeset/classic-dev-hmr.md @@ -0,0 +1,5 @@ +--- +'rsbuild-plugin-react-router': minor +--- + +Add state-preserving Hot Module Replacement for route modules in development: route updates now apply React Refresh registration and in-place route patching instead of triggering a full page reload. Server code changes also trigger hot data revalidation, so loader data refreshes without a reload. This degrades gracefully to the previous full-reload behavior when `@rsbuild/plugin-react` isn't present. diff --git a/src/build-output-transforms.ts b/src/build-output-transforms.ts index c06bae2e..735209b2 100644 --- a/src/build-output-transforms.ts +++ b/src/build-output-transforms.ts @@ -39,6 +39,7 @@ type RegisterBuildOutputTransformsOptions = { ssr: boolean; isSpaMode: boolean; rootRoutePath: string; + devHmrEnabled?: boolean; }; export const registerBuildOutputTransforms = ({ @@ -61,6 +62,7 @@ export const registerBuildOutputTransforms = ({ ssr, isSpaMode, rootRoutePath, + devHmrEnabled, }: RegisterBuildOutputTransformsOptions): void => { const transformRouteModule = async (args: Parameters[0]) => performanceProfiler.record( @@ -81,6 +83,7 @@ export const registerBuildOutputTransforms = ({ isBuild, isSpaMode, rootRoutePath, + devHmr: devHmrEnabled, }) ); @@ -158,6 +161,8 @@ export const registerBuildOutputTransforms = ({ environmentName: args.environment?.name, isBuild, routeChunkConfig, + routeId: routeByFilePath.get(args.resourcePath)?.id, + devHmr: devHmrEnabled, }) ) ); diff --git a/src/dev-generation.ts b/src/dev-generation.ts index d62cabe6..83ad1567 100644 --- a/src/dev-generation.ts +++ b/src/dev-generation.ts @@ -2,6 +2,7 @@ import type { RsbuildDevServer, Rspack } from '@rsbuild/core'; import * as EffectDeferred from 'effect/Deferred'; import * as Effect from 'effect/Effect'; import type { ServerBuild } from 'react-router'; +import { HMR_PATCHABLE_ROUTE_FLAGS } from './route-artifacts.js'; import { evaluateServerBuilds, getEnvironmentStats, @@ -75,6 +76,12 @@ type CreateReactRouterDevRuntimeOptions = { onCssAssetOwnershipChanged?: (change: 'removed' | 'restored') => void; onRouteManifestChanged?: () => void; onWarning?: (message: string) => void; + /** + * When the browser HMR runtime patches route metadata flags (hasLoader and + * friends) in place, flag-only manifest changes no longer require the + * full-reload notification from `onRouteManifestChanged`. + */ + clientPatchesRouteMetadata?: boolean; }; const collectManifestCssAssetOwnership = ( @@ -199,28 +206,35 @@ type DevRouteManifestEntry = NonNullable< const hasSameRouteMetadata = ( previous: DevRouteManifestEntry, - next: DevRouteManifestEntry -): boolean => - previous.caseSensitive === next.caseSensitive && - previous.clientActionModule === next.clientActionModule && - previous.clientLoaderModule === next.clientLoaderModule && - previous.clientMiddlewareModule === next.clientMiddlewareModule && - previous.hasErrorBoundary === next.hasErrorBoundary && - previous.hasAction === next.hasAction && - previous.hasClientAction === next.hasClientAction && - previous.hasClientLoader === next.hasClientLoader && - previous.hasClientMiddleware === next.hasClientMiddleware && - previous.hasDefaultExport === next.hasDefaultExport && - previous.hasLoader === next.hasLoader && - previous.hydrateFallbackModule === next.hydrateFallbackModule && - previous.id === next.id && - previous.index === next.index && - previous.parentId === next.parentId && - previous.path === next.path; + next: DevRouteManifestEntry, + includeHmrPatchableFlags: boolean +): boolean => { + const structuralEqual = + previous.caseSensitive === next.caseSensitive && + previous.clientActionModule === next.clientActionModule && + previous.clientLoaderModule === next.clientLoaderModule && + previous.clientMiddlewareModule === next.clientMiddlewareModule && + previous.hasDefaultExport === next.hasDefaultExport && + previous.hydrateFallbackModule === next.hydrateFallbackModule && + previous.id === next.id && + previous.index === next.index && + previous.parentId === next.parentId && + previous.path === next.path; + if (!structuralEqual) { + return false; + } + if (!includeHmrPatchableFlags) { + return true; + } + // Without a client-side HMR runtime these flags can only reach the browser + // through a full reload. + return HMR_PATCHABLE_ROUTE_FLAGS.every(flag => previous[flag] === next[flag]); +}; const hasRouteManifestMetadataChanges = ( previous: ReactRouterDevManifestSet, - next: ReactRouterDevManifestSet + next: ReactRouterDevManifestSet, + includeHmrPatchableFlags: boolean ): boolean => { const previousEntryNames = Object.keys(previous); if (previousEntryNames.length !== Object.keys(next).length) { @@ -240,7 +254,10 @@ const hasRouteManifestMetadataChanges = ( for (const routeId of previousRouteIds) { const previousRoute = previousRoutes[routeId]; const nextRoute = nextRoutes[routeId]; - if (!nextRoute || !hasSameRouteMetadata(previousRoute, nextRoute)) { + if ( + !nextRoute || + !hasSameRouteMetadata(previousRoute, nextRoute, includeHmrPatchableFlags) + ) { return true; } } @@ -260,6 +277,7 @@ export const createReactRouterDevRuntime = ({ onCssAssetOwnershipChanged = () => undefined, onRouteManifestChanged = () => undefined, onWarning = () => undefined, + clientPatchesRouteMetadata = false, }: CreateReactRouterDevRuntimeOptions): ReactRouterDevRuntime => { let nextAttemptId = 1; let reloadAfterCssRemoval = false; @@ -513,7 +531,8 @@ export const createReactRouterDevRuntime = ({ webChanged && hasRouteManifestMetadataChanges( previous.web.manifestsByEntryName, - manifestsByEntryName + manifestsByEntryName, + !clientPatchesRouteMetadata ); const reusePreviousNodeBuild = !!previous && diff --git a/src/dev-hmr.ts b/src/dev-hmr.ts new file mode 100644 index 00000000..c8c3f0a1 --- /dev/null +++ b/src/dev-hmr.ts @@ -0,0 +1,381 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'pathe'; + +export const DEV_HMR_RUNTIME_MODULE_ID = 'virtual/react-router/hmr-runtime'; + +export type DevHmrPlanOptions = { + enabled: true; + runtimeModule: string; + onNodeRebuildCommitted: () => void; +}; + +/** + * Resolves the `react-refresh/runtime` module that + * `@rspack/plugin-react-refresh` injects into the web bundle. The resolution + * walks the same dependency chain the refresh plugin uses so the returned file + * is the exact runtime instance already present in the browser module graph. + * Returns `undefined` when React Fast Refresh is unavailable, in which case + * dev HMR falls back to full reloads. + */ +export const resolveReactRefreshRuntimePath = ( + rootPath: string +): string | undefined => { + const resolveFrom = (base: string, request: string): string => + createRequire(base).resolve(request); + const rootPackageJson = join(rootPath, 'package.json'); + try { + const pluginReactEntry = resolveFrom( + rootPackageJson, + '@rsbuild/plugin-react' + ); + const refreshPluginEntry = resolveFrom( + pluginReactEntry, + '@rspack/plugin-react-refresh' + ); + return resolveFrom(refreshPluginEntry, 'react-refresh/runtime'); + } catch { + return undefined; + } +}; + +const hdrRevisionModuleContent = (revision: number): string => + `export default ${revision};\n`; + +/** + * The HDR revision module is a real file (not a virtual module) because it + * must wake the web compiler through the regular file watcher: the browser + * HMR runtime imports it, so bumping the revision produces a web hot update + * whenever server code changes, which the client answers by revalidating + * React Router loader data. + */ +export const getDevHdrRevisionFilePath = (rootPath: string): string => + join(rootPath, '.react-router', 'hdr-revision.mjs'); + +export type DevHdrRevisionSignal = { + filePath: string; + /** Writes the initial revision module so the first compile can resolve it. */ + ensure: () => void; + /** Increments the revision, signaling hot data revalidation to the client. */ + bump: () => void; +}; + +export const createDevHdrRevisionSignal = ({ + filePath, + onError, +}: { + filePath: string; + onError?: (error: Error) => void; +}): DevHdrRevisionSignal => { + let revision = 0; + let dirEnsured = false; + const write = (): void => { + try { + if (!dirEnsured) { + mkdirSync(dirname(filePath), { recursive: true }); + dirEnsured = true; + } + writeFileSync(filePath, hdrRevisionModuleContent(revision)); + } catch (error) { + onError?.(error instanceof Error ? error : new Error(String(error))); + } + }; + return { + filePath, + ensure: write, + bump() { + revision += 1; + write(); + }, + }; +}; + +/** + * Browser-side HMR runtime shared by all route client entries in development. + * + * This mirrors React Router's Vite HMR contract (see `refresh-utils.mjs` in + * `@react-router/dev`): route module updates are applied by patching + * `window.__reactRouterRouteModules` while preserving the previous component + * identities (React Fast Refresh swaps their implementations in place), + * recreating the client routes with revalidation opt-out, revalidating loader + * data, and finally performing a React refresh. + */ +export const generateDevHmrRuntimeModule = ({ + reactRefreshRuntimePath, + hdrRevisionFilePath, +}: { + reactRefreshRuntimePath: string; + hdrRevisionFilePath: string; +}): string => ` +import * as __refreshRuntimeModule from ${JSON.stringify(reactRefreshRuntimePath)}; +// Read revision so the import survives sideEffects: false tree-shaking. +import __hdrRevision from ${JSON.stringify(hdrRevisionFilePath)}; + +let latestHdrRevision = __hdrRevision; + +export function getReactRouterHdrRevision() { + return latestHdrRevision; +} + +const RefreshRuntime = + __refreshRuntimeModule && __refreshRuntimeModule.performReactRefresh + ? __refreshRuntimeModule + : __refreshRuntimeModule.default; + +const pendingRouteUpdates = new Map(); +let flushTimeout; +let pendingRevalidation = false; + +function getCurrentRouterPath(router) { + const basename = router.basename || '/'; + let pathname = window.location.pathname; + if (basename !== '/' && pathname.startsWith(basename)) { + pathname = pathname.slice(basename.length) || '/'; + // A trailing-slash basename (e.g. "/mybase/") consumes the leading slash, + // leaving a relative path that react-router resolves against the current + // location and doubles. Force it back to absolute. + if (pathname[0] !== '/') pathname = '/' + pathname; + } + return pathname + window.location.search + window.location.hash; +} + +export function registerReactRouterRouteExports(routeId, moduleExports) { + if ( + typeof window === 'undefined' || + !RefreshRuntime || + typeof RefreshRuntime.register !== 'function' + ) { + return; + } + for (const key in moduleExports) { + if (key === '__esModule') continue; + const exportValue = moduleExports[key]; + if (RefreshRuntime.isLikelyComponentType(exportValue)) { + RefreshRuntime.register(exportValue, routeId + ' export ' + key); + } + } +} + +export function scheduleReactRouterRouteUpdate( + routeId, + routeMetadata, + getRouteModuleExports +) { + pendingRouteUpdates.set(routeId, { routeMetadata, getRouteModuleExports }); + scheduleFlush(); +} + +export function scheduleReactRouterRevalidation() { + pendingRevalidation = true; + scheduleFlush(); +} + +function scheduleFlush() { + if (typeof window === 'undefined') { + return; + } + clearTimeout(flushTimeout); + flushTimeout = setTimeout(flush, 16); +} + +function takePendingRouteUpdates() { + const updates = Array.from(pendingRouteUpdates, ([routeId, update]) => ({ + routeId, + update, + })); + pendingRouteUpdates.clear(); + return updates; +} + +function applyRouteModuleUpdate(routeId, update, routeEntry, routeModules) { + Object.assign(routeEntry, update.routeMetadata); + const imported = update.getRouteModuleExports(); + registerReactRouterRouteExports(routeId, imported); + const current = routeModules[routeId]; + const preserveIdentity = key => + imported[key] ? (current && current[key]) || imported[key] : imported[key]; + routeModules[routeId] = { + ...imported, + default: preserveIdentity('default'), + ErrorBoundary: preserveIdentity('ErrorBoundary'), + HydrateFallback: preserveIdentity('HydrateFallback'), + }; +} + +function getRouteById(routes, routeId) { + for (const route of routes) { + if (route.id === routeId) { + return route; + } + if (route.children) { + const child = getRouteById(route.children, routeId); + if (child) { + return child; + } + } + } +} + +// Deliberate coupling to React Router's private dev API: patching the live +// match objects is the only way to swap route implementations without a +// navigation. The typeof guard below degrades to a no-op if RR removes it. +function patchCurrentRouteMatches(router, routes) { + if ( + !router.state || + !Array.isArray(router.state.matches) || + typeof router._internalSetStateDoNotUseOrYouWillBreakYourApp !== 'function' + ) { + return; + } + + let changed = false; + const matches = router.state.matches.map(match => { + const route = getRouteById(routes, match.route.id); + if (!route || route === match.route) { + return match; + } + changed = true; + return { ...match, route }; + }); + + if (changed) { + router._internalSetStateDoNotUseOrYouWillBreakYourApp({ matches }); + } +} + +function applyPendingRouteUpdates(router, routeModules, manifest, context) { + if (pendingRouteUpdates.size === 0) { + return { + nextManifest: undefined, + shouldRefreshRouteState: false, + routesToRevalidate: new Set(), + }; + } + + // Clone only entries mutated before the manifest is committed in flush(). + const nextManifest = { ...manifest, routes: { ...manifest.routes } }; + const routesToRevalidate = new Set(); + let shouldRefreshRouteState = false; + for (const { routeId, update } of takePendingRouteUpdates()) { + const existingEntry = nextManifest.routes[routeId]; + if (!existingEntry) continue; + + const routeEntry = JSON.parse(JSON.stringify(existingEntry)); + nextManifest.routes[routeId] = routeEntry; + applyRouteModuleUpdate(routeId, update, routeEntry, routeModules); + if ( + routeEntry.hasLoader || + routeEntry.hasClientLoader || + routeEntry.hasClientMiddleware + ) { + routesToRevalidate.add(routeId); + } + if ( + existingEntry.hasLoader || + existingEntry.hasClientLoader || + existingEntry.hasClientMiddleware || + routeEntry.hasLoader || + routeEntry.hasClientLoader || + routeEntry.hasClientMiddleware + ) { + shouldRefreshRouteState = true; + } + } + + if ( + typeof router.createRoutesForHMR === 'function' && + typeof router._internalSetRoutes === 'function' + ) { + const routes = router.createRoutesForHMR( + routesToRevalidate, + nextManifest.routes, + routeModules, + context.ssr, + context.isSpaMode + ); + router._internalSetRoutes(routes); + patchCurrentRouteMatches(router, routes); + } + + return { nextManifest, shouldRefreshRouteState, routesToRevalidate }; +} + +async function revalidateRouter(router) { + try { + window.__reactRouterHdrActive = true; + if (typeof router.revalidate === 'function') { + await router.revalidate(); + return; + } + if (typeof router.navigate === 'function') { + await router.navigate(getCurrentRouterPath(router), { + replace: true, + preventScrollReset: true, + }); + } + } finally { + window.__reactRouterHdrActive = false; + } +} + +async function refreshRouteState(router) { + if (typeof router.revalidate !== 'function') { + return; + } + try { + window.__reactRouterHdrActive = true; + await router.revalidate(); + } finally { + window.__reactRouterHdrActive = false; + } +} + +function performReactRefresh() { + if ( + RefreshRuntime && + typeof RefreshRuntime.performReactRefresh === 'function' + ) { + RefreshRuntime.performReactRefresh(); + } +} + +async function flush() { + const router = window.__reactRouterDataRouter; + const routeModules = window.__reactRouterRouteModules; + const manifest = window.__reactRouterManifest; + const context = window.__reactRouterContext; + if (!router || !routeModules || !manifest || !context) { + return; + } + + let shouldRevalidate = pendingRevalidation; + pendingRevalidation = false; + const { nextManifest, shouldRefreshRouteState, routesToRevalidate } = + applyPendingRouteUpdates(router, routeModules, manifest, context); + if (nextManifest) { + Object.assign(manifest, nextManifest); + } + // Component-only updates do not need a full loader revalidation. + if ( + shouldRefreshRouteState && + (routesToRevalidate.size > 0 || shouldRevalidate) + ) { + await refreshRouteState(router); + shouldRevalidate = false; + } + if (shouldRevalidate) { + await revalidateRouter(router); + } + performReactRefresh(); +} + +if (typeof window !== 'undefined' && import.meta.webpackHot) { + import.meta.webpackHot.accept( + ${JSON.stringify(hdrRevisionFilePath)}, + () => { + latestHdrRevision = __hdrRevision; + scheduleReactRouterRevalidation(); + } + ); +} +`; diff --git a/src/dev-runtime-controller.ts b/src/dev-runtime-controller.ts index 1ead3f0e..204e72a1 100644 --- a/src/dev-runtime-controller.ts +++ b/src/dev-runtime-controller.ts @@ -55,6 +55,16 @@ type CreateControllerOptions = { api: RsbuildPluginAPI; isBuild: boolean; buildPlan: ReactRouterDevBuildPlan; + /** + * The browser HMR runtime patches route manifest metadata (loader/action + * flags) in place, so metadata-only changes no longer need a full reload. + */ + clientPatchesRouteMetadata?: boolean; + /** + * Invoked after a development attempt commits a re-evaluated node build for + * changed server files. Used to signal hot data revalidation to the client. + */ + onNodeRebuildCommitted?: () => void; }; const escapeHtml = (value: string): string => @@ -65,10 +75,18 @@ const escapeHtml = (value: string): string => const CSS_SOURCE_RELOAD_DELAY_MS = 1000; +const isHdrRevisionFile = (file: string): boolean => + file.includes('.react-router/hdr-revision.mjs'); + +const isCssSourceFile = (file: string): boolean => + /\.css(?:\.[cm]?[jt]s)?$/.test(file); + export const createReactRouterDevRuntimeController = ({ api, isBuild, buildPlan, + clientPatchesRouteMetadata, + onNodeRebuildCommitted, }: CreateControllerOptions): ReactRouterDevRuntimeController => { if (isBuild) { return { @@ -132,6 +150,14 @@ export const createReactRouterDevRuntimeController = ({ const compilationIdentities = createCompilationIdentityTracker(); const { getCompilationIdentity } = compilationIdentities; + // Web-only commits reuse the node compiler's stale `modifiedFiles` + // snapshot, and every HDR bump itself triggers a web rebuild — so signal + // once per node compilation identity or the bump loop self-sustains. + const hdrSignaledNodeIdentity = new WeakMap< + DevCompilerPair, + NonNullable + >(); + const finishRuntimeAttemptEffect = ( binding: RuntimeBinding, pair: DevCompilerPair, @@ -144,11 +170,24 @@ export const createReactRouterDevRuntimeController = ({ ).pipe( Effect.flatMap(result => tryPluginSync(() => { + if (sessions.getActiveBinding()?.id !== binding.id) { + return; + } + if (result === 'retry-node') { + pair.node.watching?.invalidate(); + return; + } if ( - result === 'retry-node' && - sessions.getActiveBinding()?.id === binding.id + result === 'committed' && + changes.node.known && + identity.node !== undefined && + hdrSignaledNodeIdentity.get(pair) !== identity.node && + Array.from(changes.node.files).some( + file => !isHdrRevisionFile(file) && !isCssSourceFile(file) + ) ) { - pair.node.watching?.invalidate(); + hdrSignaledNodeIdentity.set(pair, identity.node); + onNodeRebuildCommitted?.(); } }) ), @@ -245,6 +284,7 @@ export const createReactRouterDevRuntimeController = ({ const runtime = createReactRouterDevRuntime({ server, buildPlan, + clientPatchesRouteMetadata, onEvaluationError(error) { if (sessions.getActiveBinding()?.runtime !== runtime) { return; diff --git a/src/index.ts b/src/index.ts index d312804d..e3b1a015 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,13 @@ import { createReactRouterRouteWatchFiles, registerReactRouterDevBackgroundResources, } from './dev-background-resources.js'; +import { + createDevHdrRevisionSignal, + generateDevHmrRuntimeModule, + getDevHdrRevisionFilePath, + resolveReactRefreshRuntimePath, + DEV_HMR_RUNTIME_MODULE_ID, +} from './dev-hmr.js'; export type { Config as ReactRouterRsbuildConfig } from './react-router-config.js'; export { loadReactRouterServerBuild } from './dev-generation.js'; @@ -576,10 +583,28 @@ export const pluginReactRouter = ( defaultEntryName: devServerBuildEntryName, }); const { serverBundleEntries } = serverBuildPlan; + + const devHmrRefreshRuntimePath = isBuild + ? undefined + : resolveReactRefreshRuntimePath(api.context.rootPath); + const devHdrSignal = devHmrRefreshRuntimePath + ? createDevHdrRevisionSignal({ + filePath: getDevHdrRevisionFilePath(api.context.rootPath), + onError: error => + api.logger.debug( + `[${PLUGIN_NAME}] Failed to signal hot data revalidation: ${error.message}` + ), + }) + : undefined; + devHdrSignal?.ensure(); + const devHmrEnabled = devHmrRefreshRuntimePath !== undefined; + const devRuntime = createReactRouterDevRuntimeController({ api, isBuild, buildPlan: serverBuildPlan, + clientPatchesRouteMetadata: devHmrEnabled, + onNodeRebuildCommitted: () => devHdrSignal?.bump(), }); let clientStats: ReactRouterManifestStats | undefined; @@ -703,6 +728,16 @@ export const pluginReactRouter = ( ...bundleVirtualModules, ...bundleManifestModules, 'virtual/react-router/with-props': generateWithProps(), + ...(devHmrEnabled && devHmrRefreshRuntimePath + ? { + [DEV_HMR_RUNTIME_MODULE_ID]: generateDevHmrRuntimeModule({ + reactRefreshRuntimePath: devHmrRefreshRuntimePath, + hdrRevisionFilePath: getDevHdrRevisionFilePath( + api.context.rootPath + ), + }), + } + : {}), }) ); }; @@ -986,6 +1021,7 @@ export const pluginReactRouter = ( ssr, isSpaMode, rootRoutePath, + devHmrEnabled, }); }, }); diff --git a/src/route-artifacts.ts b/src/route-artifacts.ts index 97483760..c7c782da 100644 --- a/src/route-artifacts.ts +++ b/src/route-artifacts.ts @@ -1,5 +1,9 @@ +import { basename } from 'pathe'; + import { + CLIENT_EXPORTS, CLIENT_ROUTE_EXPORTS_SET, + SERVER_EXPORTS, SERVER_ONLY_ROUTE_EXPORTS_SET, } from './constants.js'; import { getExportNames } from './export-utils.js'; @@ -22,6 +26,10 @@ export type RouteClientEntryArtifactOptions = { isBuild: boolean; routeChunkCache?: RouteChunkCache; routeChunkConfig: RouteChunkConfig; + /** React Router route id for this route module, when known. */ + routeId?: string; + /** Emit development HMR/HDR glue into web route client entries. */ + devHmr?: boolean; }; type RouteClientEntryArtifact = { @@ -42,16 +50,123 @@ type RouteChunkArtifact = { map: null; }; +// Shared with the dev manifest differ: exactly the route flags the client HMR +// runtime can patch in place without a full reload. +export const HMR_PATCHABLE_ROUTE_FLAGS = [ + 'hasAction', + 'hasClientAction', + 'hasClientLoader', + 'hasClientMiddleware', + 'hasErrorBoundary', + 'hasLoader', +] as const; + +type RouteHmrMetadata = Record< + (typeof HMR_PATCHABLE_ROUTE_FLAGS)[number], + boolean +>; + +export const buildRouteHmrMetadata = ( + exportNames: readonly string[] +): RouteHmrMetadata => { + const exports = new Set(exportNames); + return { + hasAction: exports.has(SERVER_EXPORTS.action), + hasClientAction: exports.has(CLIENT_EXPORTS.clientAction), + hasClientLoader: exports.has(CLIENT_EXPORTS.clientLoader), + hasClientMiddleware: exports.has(CLIENT_EXPORTS.clientMiddleware), + hasErrorBoundary: exports.has(CLIENT_EXPORTS.ErrorBoundary), + hasLoader: exports.has(SERVER_EXPORTS.loader), + }; +}; + +/** + * Development-only HMR glue for a web route client entry. + * + * The route client entry is the webpack entry for a route, so it must + * self-accept hot updates to stop them from bubbling into a full reload. It + * also accepts updates of the underlying route module and forwards fresh + * exports plus route metadata (loader/action flags derived from the current + * export names) to the shared HMR runtime, which applies the React Router + * route-module update contract and revalidates loader data. + */ +const buildRouteClientEntryHmrCode = ({ + routeId, + target, + acceptTarget, + metadata, +}: { + routeId: string; + target: string; + acceptTarget: string; + metadata: RouteHmrMetadata; +}): string => { + const targetJson = JSON.stringify(target); + const acceptTargetJson = JSON.stringify(acceptTarget); + return ` +import * as __reactRouterRouteModule from ${targetJson}; +import { + registerReactRouterRouteExports as __reactRouterRegisterRouteExports, + scheduleReactRouterRouteUpdate as __reactRouterScheduleRouteUpdate, +} from "virtual/react-router/hmr-runtime"; + +const __reactRouterRouteId = ${JSON.stringify(routeId)}; +const __reactRouterRouteMetadata = ${JSON.stringify(metadata)}; +const __reactRouterGetRouteModule = () => __reactRouterRouteModule; + +__reactRouterRegisterRouteExports( + __reactRouterRouteId, + __reactRouterRouteModule +); + +if (import.meta.webpackHot) { + import.meta.webpackHot.accept(${acceptTargetJson}, () => { + __reactRouterRegisterRouteExports( + __reactRouterRouteId, + __reactRouterRouteModule + ); + __reactRouterScheduleRouteUpdate( + __reactRouterRouteId, + __reactRouterRouteMetadata, + __reactRouterGetRouteModule + ); + }); + import.meta.webpackHot.accept(); + import.meta.webpackHot.dispose(data => { + data.__reactRouterRouteShim = true; + }); + if ( + import.meta.webpackHot.data && + import.meta.webpackHot.data.__reactRouterRouteShim + ) { + __reactRouterScheduleRouteUpdate( + __reactRouterRouteId, + __reactRouterRouteMetadata, + __reactRouterGetRouteModule + ); + } +} +`; +}; + +const createRouteHmrAcceptTarget = (resourcePath: string): string => { + return `./${basename(resourcePath)}?react-router-route`; +}; + export const buildRouteClientEntryCode = ({ exportNames, chunkedExports, isServer, resourcePath, + routeId, + devHmr, }: { exportNames: readonly string[]; chunkedExports: readonly string[]; isServer: boolean; resourcePath: string; + routeId?: string; + devHmr?: boolean; }): string => { const chunkedExportSet = chunkedExports.length > 0 ? new Set(chunkedExports) : undefined; @@ -67,7 +182,19 @@ export const buildRouteClientEntryCode = ({ }) .sort(); const target = `${resourcePath}?react-router-route`; - return `export { ${reexports.join(', ')} } from ${JSON.stringify(target)};`; + const reexportCode = `export { ${reexports.join(', ')} } from ${JSON.stringify(target)};`; + if (!devHmr || isServer || routeId === undefined) { + return reexportCode; + } + return ( + reexportCode + + buildRouteClientEntryHmrCode({ + routeId, + target, + acceptTarget: createRouteHmrAcceptTarget(resourcePath), + metadata: buildRouteHmrMetadata(exportNames), + }) + ); }; export const createRouteClientEntryArtifact = async ({ @@ -77,6 +204,8 @@ export const createRouteClientEntryArtifact = async ({ isBuild, routeChunkCache, routeChunkConfig, + routeId, + devHmr, }: RouteClientEntryArtifactOptions): Promise => { const isServer = environmentName === 'node'; const mightHaveRouteChunks = @@ -100,6 +229,8 @@ export const createRouteClientEntryArtifact = async ({ chunkedExports, isServer, resourcePath, + routeId, + devHmr: devHmr && !isBuild, }), }; }; diff --git a/src/route-transform-tasks.ts b/src/route-transform-tasks.ts index 05a47598..cb4251fd 100644 --- a/src/route-transform-tasks.ts +++ b/src/route-transform-tasks.ts @@ -41,6 +41,8 @@ export type RouteClientEntryTransformTask = BaseRouteTransformTask & { environmentName?: string; isBuild: boolean; routeChunkConfig: RouteChunkConfig; + routeId?: string; + devHmr?: boolean; }; export type RouteChunkTransformTask = BaseRouteTransformTask & { @@ -69,6 +71,7 @@ export type RouteModuleTransformTask = BaseRouteTransformTask & { isBuild: boolean; isSpaMode: boolean; rootRoutePath: string | null; + devHmr?: boolean; }; export type RouteTransformTask = @@ -151,6 +154,184 @@ const createClientOnlyStub = async ( }; }; +const isComponentishName = (name: string): boolean => /^[A-Z]/.test(name); + +type AnyNode = { + type?: string; + name?: string; + body?: { type?: string }; + callee?: { type?: string; name?: string }; + arguments?: Array; +}; + +/** + * Whether an expression that appears as the first argument of a wrapping call + * resolves to a component, mirroring react-refresh/babel's + * `findInnerComponents` recursion for the node kinds it accepts as arguments. + */ +const argumentResolvesToComponent = (node: AnyNode | undefined): boolean => { + switch (node?.type) { + case 'FunctionExpression': + return true; + case 'ArrowFunctionExpression': + // Babel bails on a curried arrow (an arrow whose body is another arrow): + // that is a component factory, not a component. + return node.body?.type !== 'ArrowFunctionExpression'; + case 'Identifier': + return !!node.name && isComponentishName(node.name); + case 'CallExpression': + return callResolvesToComponent(node); + default: + return false; + } +}; + +/** + * Whether a `CallExpression` resolves to a component: it must have at least one + * argument, a callee that is not `Import`/`require*`/`import*`, and a first + * argument that itself resolves to a component. + */ +const callResolvesToComponent = (node: AnyNode): boolean => { + const args = node.arguments ?? []; + if (args.length === 0) { + return false; + } + const callee = node.callee; + if (!callee || callee.type === 'Import') { + return false; + } + if (callee.type === 'Identifier') { + const calleeName = callee.name ?? ''; + if (calleeName.startsWith('require') || calleeName.startsWith('import')) { + return false; + } + } else if (callee.type !== 'MemberExpression') { + return false; + } + return argumentResolvesToComponent(args[0]); +}; + +/** + * Whether a `VariableDeclarator` initializer resolves to a component, matching + * react-refresh/babel's accepted init kinds: a non-curried arrow, a function + * expression, a tagged template, or a qualifying call expression. + */ +const initResolvesToComponent = (init: AnyNode): boolean => { + switch (init.type) { + case 'FunctionExpression': + case 'TaggedTemplateExpression': + return true; + case 'ArrowFunctionExpression': + return init.body?.type !== 'ArrowFunctionExpression'; + case 'CallExpression': + return callResolvesToComponent(init); + default: + return false; + } +}; + +const collectDeclaredComponentNames = ( + declaration: { + type?: string; + id?: { name?: string }; + declarations?: Array<{ + id?: { type?: string; name?: string }; + init?: AnyNode; + }>; + }, + names: Set +): void => { + if ( + declaration.type === 'FunctionDeclaration' && + declaration.id?.name && + isComponentishName(declaration.id.name) + ) { + names.add(declaration.id.name); + return; + } + if (declaration.type !== 'VariableDeclaration') { + return; + } + const declarators = declaration.declarations ?? []; + // Babel's react-refresh visitor only registers a `VariableDeclaration` with + // exactly one declarator; multi-declarator declarations are skipped whole. + if (declarators.length !== 1) { + return; + } + const [declarator] = declarators; + if ( + declarator?.id?.type === 'Identifier' && + declarator.id.name && + isComponentishName(declarator.id.name) && + declarator.init && + initResolvesToComponent(declarator.init) + ) { + names.add(declarator.id.name); + } +}; + +/** + * Names of top-level components that still need a React Fast Refresh + * registration, following react-refresh/babel's name-based detection. + * + * SWC's refresh transform registers components in JSX/TSX sources, but + * compiled route modules whose JSX was already lowered by an earlier loader + * (e.g. MDX routes) reach it without JSX syntax and end up unregistered. An + * unregistered component has no refresh family, so hot updates remount its + * subtree instead of updating it in place. + */ +const collectUnregisteredComponentNames = (program: { + body?: Array<{ + type?: string; + declaration?: Parameters[0]; + expression?: { + type?: string; + callee?: { type?: string; name?: string }; + arguments?: Array<{ type?: string; value?: unknown }>; + }; + }>; +}): string[] => { + const declared = new Set(); + const registered = new Set(); + for (const statement of program.body ?? []) { + if (statement.type === 'ExportNamedDeclaration' && statement.declaration) { + collectDeclaredComponentNames(statement.declaration, declared); + continue; + } + if ( + statement.type === 'ExpressionStatement' && + statement.expression?.type === 'CallExpression' && + statement.expression.callee?.type === 'Identifier' && + statement.expression.callee.name === '$RefreshReg$' + ) { + const nameArgument = statement.expression.arguments?.[1]; + if (typeof nameArgument?.value === 'string') { + registered.add(nameArgument.value); + } + continue; + } + collectDeclaredComponentNames( + statement as Parameters[0], + declared + ); + } + return [...declared].filter(name => !registered.has(name)); +}; + +const buildComponentRefreshRegistrations = (names: string[]): string => { + const registrations = names + .map( + name => + // react-refresh's runtime `register()` tags plain functions *and* + // non-null exotic objects (memo/forwardRef `$$typeof` wrappers), + // ignoring everything else safely -- so mirror that guard here. The + // `typeof` short-circuit keeps this safe even for undeclared names. + ` if (typeof ${name} === 'function' || (typeof ${name} === 'object' && ${name} !== null)) $RefreshReg$(${name}, ${JSON.stringify(name)});` + ) + .join('\n'); + return `\nif (typeof $RefreshReg$ === 'function') {\n${registrations}\n}\n`; +}; + const transformRouteModule = async ( task: RouteModuleTransformTask ): Promise => { @@ -207,13 +388,24 @@ const transformRouteModule = async ( removeUnusedImports(ast); } - return generate(ast, { + const result = generate(ast, { // Rsbuild merges this map with its downstream SWC transform. Only pay the // code-generation cost when this environment actually emits JS maps. sourceMaps: task.sourceMaps, filename: task.resource, sourceFileName: task.resourcePath, }); + + if (task.devHmr && task.environmentName === 'web' && !task.isBuild) { + const unregisteredComponents = collectUnregisteredComponentNames( + getProgram(ast) + ); + if (unregisteredComponents.length > 0) { + result.code += buildComponentRefreshRegistrations(unregisteredComponents); + } + } + + return result; }; export const executeRouteTransformTask = async ( @@ -229,6 +421,8 @@ export const executeRouteTransformTask = async ( isBuild: task.isBuild, routeChunkCache: getRouteChunkCache(options), routeChunkConfig: task.routeChunkConfig, + routeId: task.routeId, + devHmr: task.devHmr, }); case 'routeChunk': return createRouteChunkArtifact({ diff --git a/tests/dev-hmr.test.ts b/tests/dev-hmr.test.ts new file mode 100644 index 00000000..558e1d0c --- /dev/null +++ b/tests/dev-hmr.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from '@rstest/core'; +import { + createDevHdrRevisionSignal, + generateDevHmrRuntimeModule, + resolveReactRefreshRuntimePath, +} from '../src/dev-hmr'; + +describe('createDevHdrRevisionSignal', () => { + it('writes the revision file on ensure() and increments on bump()', () => { + const root = mkdtempSync(join(tmpdir(), 'rr-dev-hmr-')); + try { + const filePath = join(root, '.react-router', 'hdr-revision.mjs'); + const signal = createDevHdrRevisionSignal({ filePath }); + + signal.ensure(); + expect(readFileSync(filePath, 'utf8')).toBe('export default 0;\n'); + + signal.bump(); + expect(readFileSync(filePath, 'utf8')).toBe('export default 1;\n'); + + signal.bump(); + expect(readFileSync(filePath, 'utf8')).toBe('export default 2;\n'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('generateDevHmrRuntimeModule', () => { + it('accepts hot updates for the hdr revision path and exposes the runtime contract', () => { + const code = generateDevHmrRuntimeModule({ + reactRefreshRuntimePath: '/node_modules/react-refresh/runtime.js', + hdrRevisionFilePath: '/app/.react-router/hdr-revision.mjs', + }); + + expect(code).toContain( + 'import.meta.webpackHot.accept(\n "/app/.react-router/hdr-revision.mjs"' + ); + expect(code).toContain('export function registerReactRouterRouteExports'); + expect(code).toContain('export function scheduleReactRouterRouteUpdate'); + }); +}); + +describe('resolveReactRefreshRuntimePath', () => { + it('returns undefined for a directory with no @rsbuild/plugin-react', () => { + const root = mkdtempSync(join(tmpdir(), 'rr-dev-hmr-no-plugin-')); + try { + expect(resolveReactRefreshRuntimePath(root)).toBeUndefined(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/dev-runtime-controller.test.ts b/tests/dev-runtime-controller.test.ts index 76f59510..4f52fcb2 100644 --- a/tests/dev-runtime-controller.test.ts +++ b/tests/dev-runtime-controller.test.ts @@ -130,6 +130,7 @@ const createHarness = (userSetup?: TestServerSetup) => { let after!: OnAfterDevCompileFn; const closeRecords = new WeakMap(); const warn = rstest.fn(); + const onNodeRebuildCommitted = rstest.fn(); let serverSetups = userSetup ? [userSetup] : []; const api = { logger: { error: rstest.fn(), warn }, @@ -208,6 +209,7 @@ const createHarness = (userSetup?: TestServerSetup) => { defaultEntryName: 'static/js/app', entryNames: ['static/js/app'], }, + onNodeRebuildCommitted, }); const createServer = ( loadBundle: (entryName: string) => Promise | unknown, @@ -292,6 +294,7 @@ const createHarness = (userSetup?: TestServerSetup) => { getCloseCount: (server: RsbuildDevServer) => closeRecords.get(server)?.count ?? 0, loadBundle, + onNodeRebuildCommitted, server, startOrder, warn, @@ -573,6 +576,101 @@ describe('React Router development runtime controller', () => { expect(loadBundle).toHaveBeenCalledTimes(2); }); + it('signals a node rebuild for paired web and node route changes', async () => { + const { + callbacks, + controller, + loadBundle, + onNodeRebuildCommitted, + server, + } = createHarness(); + let build = createBuild('base'); + loadBundle.mockImplementation(() => build); + const routePath = '/app/routes/route-0001.tsx'; + const web = createCompiler('web'); + const node = createCompiler('node'); + await callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [web.compiler, node.compiler] }, + }); + + callbacks.before(); + const baseWeb = web.compile(); + controller.captureWeb(baseWeb, createManifestSet('web-base')); + web.complete(baseWeb); + const baseNode = node.compile(); + await callbacks.after({ stats: createGraphStats(baseWeb, baseNode) }); + + build = createBuild('route-next'); + web.setChanges([routePath]); + node.setChanges([routePath]); + callbacks.before(); + const nextWeb = web.compile(); + controller.captureWeb(nextWeb, createManifestSet('web-next')); + web.complete(nextWeb); + const nextNode = node.compile(); + await callbacks.after({ stats: createGraphStats(nextWeb, nextNode) }); + + await expect(controller.createBuildLoader()()).resolves.toMatchObject({ + marker: 'route-next', + assets: { version: 'web-next' }, + }); + expect(onNodeRebuildCommitted).toHaveBeenCalledOnce(); + + // A web-only rebuild (e.g. the HDR revision bump itself) commits with the + // node compiler's stale modifiedFiles snapshot; it must not re-signal or + // the bump feeds back into an infinite rebuild loop. + web.setChanges(['/app/web-only.ts']); + callbacks.before(); + const webOnly = web.compile(); + controller.captureWeb(webOnly, createManifestSet('web-only')); + web.complete(webOnly); + await callbacks.after({ stats: createGraphStats(webOnly, nextNode) }); + expect(onNodeRebuildCommitted).toHaveBeenCalledOnce(); + }); + + it('does not signal a node rebuild for paired CSS source changes', async () => { + const { + callbacks, + controller, + loadBundle, + onNodeRebuildCommitted, + server, + } = createHarness(); + let build = createBuild('base'); + loadBundle.mockImplementation(() => build); + const cssSourcePath = '/app/routes/page/styles.css.ts'; + const web = createCompiler('web'); + const node = createCompiler('node'); + await callbacks.start({ server }); + callbacks.created({ + compiler: { compilers: [web.compiler, node.compiler] }, + }); + + callbacks.before(); + const baseWeb = web.compile(); + controller.captureWeb(baseWeb, createManifestSet('web-base')); + web.complete(baseWeb); + const baseNode = node.compile(); + await callbacks.after({ stats: createGraphStats(baseWeb, baseNode) }); + + build = createBuild('css-next'); + web.setChanges([cssSourcePath]); + node.setChanges([cssSourcePath]); + callbacks.before(); + const nextWeb = web.compile(); + controller.captureWeb(nextWeb, createManifestSet('web-next')); + web.complete(nextWeb); + const nextNode = node.compile(); + await callbacks.after({ stats: createGraphStats(nextWeb, nextNode) }); + + await expect(controller.createBuildLoader()()).resolves.toMatchObject({ + marker: 'css-next', + assets: { version: 'web-next' }, + }); + expect(onNodeRebuildCommitted).not.toHaveBeenCalled(); + }); + it('keeps last-good output when a one-sided node compile cannot evaluate', async () => { const { callbacks, controller, loadBundle, server } = createHarness(); let build: unknown = createBuild('base'); diff --git a/tests/parallel-route-transforms.test.ts b/tests/parallel-route-transforms.test.ts index cdd029aa..4ddbb724 100644 --- a/tests/parallel-route-transforms.test.ts +++ b/tests/parallel-route-transforms.test.ts @@ -563,4 +563,160 @@ describe('parallel route transforms', () => { }); expect(withoutSourceMaps.map).toBeNull(); }); + + // These pin `collectUnregisteredComponentNames` (route-transform-tasks.ts, + // the dev-HMR $RefreshReg$ backfill for pre-lowered JSX, e.g. MDX routes) + // against react-refresh/babel's own component-detection ruleset + // (react-refresh/cjs/react-refresh-babel.development.js). The heuristic is + // now aligned with that ruleset: single-declarator gate, curried-arrow bail, + // require*/import*/Import callee rejection, first-argument recursion for + // wrapping calls, and a runtime guard that matches `register()`'s own + // function-or-exotic-object acceptance. + describe('dev HMR $RefreshReg$ backfill for pre-lowered routes', () => { + const devHmrTask = ( + code: string, + overrides: Partial> = {} + ) => + createRouteModuleTask({ + code, + devHmr: true, + environmentName: 'web', + isBuild: false, + ...overrides, + }); + + it('registers a top-level function declaration component (matches babel)', async () => { + const result = await executeRouteTransformTask( + devHmrTask(`export function App(){return null}`) + ); + + expect(result.code).toContain('$RefreshReg$(App, "App")'); + }); + + it('registers a single-declarator arrow-fn const referenced as default export (matches babel)', async () => { + const result = await executeRouteTransformTask( + devHmrTask(`const Foo = () => null; export default Foo;`) + ); + + expect(result.code).toContain('$RefreshReg$(Foo, "Foo")'); + }); + + it('skips a multi-declarator export entirely (aligned with react-refresh as of this change)', async () => { + // Babel's react-refresh visitor only registers a VariableDeclaration + // when it has exactly one declarator. We now bail on the whole + // declaration too, so neither `A` (componentish) nor `B` is registered. + const result = await executeRouteTransformTask( + devHmrTask(`export const A = ()=>null, B = 2;`) + ); + + expect(result.code).not.toContain('$RefreshReg$(A, "A")'); + expect(result.code).not.toContain('$RefreshReg$(B, "B")'); + }); + + it('rejects a CallExpression init whose argument is not a component (aligned with react-refresh as of this change)', async () => { + // Babel recurses into the call's first argument and only registers + // when it resolves to a component (fn expr / componentish identifier / + // nested call). We now recurse the first argument too, so an HOC-like + // call with a plain string argument such as `createBox('div')` is no + // longer a false positive. + const result = await executeRouteTransformTask( + devHmrTask(`const Layout = createBox('div'); export default Layout;`) + ); + + expect(result.code).not.toContain('$RefreshReg$(Layout, "Layout")'); + }); + + it('emits a memo()-wrapped component registration that fires at runtime (aligned with react-refresh as of this change)', async () => { + // We collect `Card` and emit a guarded `$RefreshReg$` call. The guard + // now also accepts non-null objects, so the `memo`/`forwardRef` exotic + // object registers -- matching react-refresh's runtime `register()`, + // which tags functions and `$$typeof` exotic objects alike. + const result = await executeRouteTransformTask( + devHmrTask( + `import { memo } from 'react'; const Card = memo(()=>null); export default Card;` + ) + ); + + expect(result.code).toContain( + "if (typeof Card === 'function' || (typeof Card === 'object' && Card !== null)) $RefreshReg$(Card, \"Card\");" + ); + }); + + it('rejects a require/interop-wrapped init (aligned with react-refresh as of this change)', async () => { + // Babel excludes CallExpression inits whose callee is + // `require*`/`import*`/`Import`, and otherwise only registers when the + // first argument resolves to a component. We now do both, so + // `_interopRequireDefault(x)` -- whose lowercase `x` argument is not + // componentish -- is rejected. + const result = await executeRouteTransformTask( + devHmrTask( + `const Fragment = _interopRequireDefault(x); export default Fragment;` + ) + ); + + expect(result.code).not.toContain('$RefreshReg$(Fragment, "Fragment")'); + }); + + it('registers an inline `export default function Route(){}` (matches babel, not via the collector\'s own export-default handling)', async () => { + // NOTE: collectUnregisteredComponentNames never inspects + // ExportDefaultDeclaration itself, so in isolation it would miss this + // case. In the real pipeline it still matches babel here because + // transformRoute() runs *before* the refresh backfill and rewrites + // `export default function Route(){}` into a bare `function Route(){}` + // declaration plus a separate `export default + // _withComponentProps(Route)` statement -- so the bare function + // declaration is picked up by the FunctionDeclaration branch instead. + const result = await executeRouteTransformTask( + devHmrTask(`export default function Route(){return null}`) + ); + + expect(result.code).toContain('$RefreshReg$(Route, "Route")'); + }); + + it('skips class components and non-function capitalized constants (matches babel)', async () => { + const classDecl = await executeRouteTransformTask( + devHmrTask(`class App{ render(){ return null } } export default App;`) + ); + expect(classDecl.code).not.toContain('$RefreshReg$'); + + const classExpr = await executeRouteTransformTask( + devHmrTask(`const App = class{}; export default App;`) + ); + expect(classExpr.code).not.toContain('$RefreshReg$'); + + const nonFnConst = await executeRouteTransformTask( + devHmrTask( + `export const N = 5; export default function Other(){return null}` + ) + ); + expect(nonFnConst.code).not.toContain('$RefreshReg$(N, "N")'); + expect(nonFnConst.code).toContain('$RefreshReg$(Other, "Other")'); + }); + + it('subtracts components already registered via an existing $RefreshReg$ call (matches babel/SWC dedupe)', async () => { + const result = await executeRouteTransformTask( + devHmrTask( + `function App(){return null} $RefreshReg$(_c, "App"); export default App;` + ) + ); + + // Only the pre-existing call should be present; no backfill block + // should be appended for a name that is already registered. + expect(result.code).not.toContain( + "if (typeof $RefreshReg$ === 'function')" + ); + }); + + it('bails on a curried arrow (aligned with react-refresh as of this change)', async () => { + // Babel breaks out of registration when the arrow's body is itself an + // arrow function (a curried component factory, not a component). We now + // apply the same bail, so the outer arrow's identifier is not + // registered. + const result = await executeRouteTransformTask( + devHmrTask(`const A = () => () => null; export default A;`) + ); + + expect(result.code).not.toContain('$RefreshReg$(A, "A")'); + }); + }); }); From 77d92a55a961ddde927fd076ffde40d5b13062bb Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:02:56 +0000 Subject: [PATCH 2/7] fix: gate classic dev hmr on fast refresh --- .changeset/classic-dev-hmr.md | 2 +- src/build-output-transforms.ts | 8 ++-- src/dev-generation.ts | 61 +++++++++------------------ src/dev-runtime-controller.ts | 7 --- src/index.ts | 29 ++++++++++--- tests/build-output-transforms.test.ts | 29 +++++++++++++ 6 files changed, 79 insertions(+), 57 deletions(-) diff --git a/.changeset/classic-dev-hmr.md b/.changeset/classic-dev-hmr.md index f40ce6b3..ae7ed986 100644 --- a/.changeset/classic-dev-hmr.md +++ b/.changeset/classic-dev-hmr.md @@ -2,4 +2,4 @@ 'rsbuild-plugin-react-router': minor --- -Add state-preserving Hot Module Replacement for route modules in development: route updates now apply React Refresh registration and in-place route patching instead of triggering a full page reload. Server code changes also trigger hot data revalidation, so loader data refreshes without a reload. This degrades gracefully to the previous full-reload behavior when `@rsbuild/plugin-react` isn't present. +Add state-preserving Hot Module Replacement for route modules in development: route updates now apply React Refresh registration and in-place route patching instead of triggering a full page reload. Server code changes also trigger hot data revalidation, so loader data refreshes without a reload. This degrades gracefully to the previous full-reload behavior when `@rsbuild/plugin-react` isn't present or Fast Refresh is disabled. diff --git a/src/build-output-transforms.ts b/src/build-output-transforms.ts index 735209b2..9b14d0ec 100644 --- a/src/build-output-transforms.ts +++ b/src/build-output-transforms.ts @@ -39,7 +39,7 @@ type RegisterBuildOutputTransformsOptions = { ssr: boolean; isSpaMode: boolean; rootRoutePath: string; - devHmrEnabled?: boolean; + isDevHmrEnabled?: () => boolean; }; export const registerBuildOutputTransforms = ({ @@ -62,7 +62,7 @@ export const registerBuildOutputTransforms = ({ ssr, isSpaMode, rootRoutePath, - devHmrEnabled, + isDevHmrEnabled = () => false, }: RegisterBuildOutputTransformsOptions): void => { const transformRouteModule = async (args: Parameters[0]) => performanceProfiler.record( @@ -83,7 +83,7 @@ export const registerBuildOutputTransforms = ({ isBuild, isSpaMode, rootRoutePath, - devHmr: devHmrEnabled, + devHmr: isDevHmrEnabled(), }) ); @@ -162,7 +162,7 @@ export const registerBuildOutputTransforms = ({ isBuild, routeChunkConfig, routeId: routeByFilePath.get(args.resourcePath)?.id, - devHmr: devHmrEnabled, + devHmr: isDevHmrEnabled(), }) ) ); diff --git a/src/dev-generation.ts b/src/dev-generation.ts index 83ad1567..d62cabe6 100644 --- a/src/dev-generation.ts +++ b/src/dev-generation.ts @@ -2,7 +2,6 @@ import type { RsbuildDevServer, Rspack } from '@rsbuild/core'; import * as EffectDeferred from 'effect/Deferred'; import * as Effect from 'effect/Effect'; import type { ServerBuild } from 'react-router'; -import { HMR_PATCHABLE_ROUTE_FLAGS } from './route-artifacts.js'; import { evaluateServerBuilds, getEnvironmentStats, @@ -76,12 +75,6 @@ type CreateReactRouterDevRuntimeOptions = { onCssAssetOwnershipChanged?: (change: 'removed' | 'restored') => void; onRouteManifestChanged?: () => void; onWarning?: (message: string) => void; - /** - * When the browser HMR runtime patches route metadata flags (hasLoader and - * friends) in place, flag-only manifest changes no longer require the - * full-reload notification from `onRouteManifestChanged`. - */ - clientPatchesRouteMetadata?: boolean; }; const collectManifestCssAssetOwnership = ( @@ -206,35 +199,28 @@ type DevRouteManifestEntry = NonNullable< const hasSameRouteMetadata = ( previous: DevRouteManifestEntry, - next: DevRouteManifestEntry, - includeHmrPatchableFlags: boolean -): boolean => { - const structuralEqual = - previous.caseSensitive === next.caseSensitive && - previous.clientActionModule === next.clientActionModule && - previous.clientLoaderModule === next.clientLoaderModule && - previous.clientMiddlewareModule === next.clientMiddlewareModule && - previous.hasDefaultExport === next.hasDefaultExport && - previous.hydrateFallbackModule === next.hydrateFallbackModule && - previous.id === next.id && - previous.index === next.index && - previous.parentId === next.parentId && - previous.path === next.path; - if (!structuralEqual) { - return false; - } - if (!includeHmrPatchableFlags) { - return true; - } - // Without a client-side HMR runtime these flags can only reach the browser - // through a full reload. - return HMR_PATCHABLE_ROUTE_FLAGS.every(flag => previous[flag] === next[flag]); -}; + next: DevRouteManifestEntry +): boolean => + previous.caseSensitive === next.caseSensitive && + previous.clientActionModule === next.clientActionModule && + previous.clientLoaderModule === next.clientLoaderModule && + previous.clientMiddlewareModule === next.clientMiddlewareModule && + previous.hasErrorBoundary === next.hasErrorBoundary && + previous.hasAction === next.hasAction && + previous.hasClientAction === next.hasClientAction && + previous.hasClientLoader === next.hasClientLoader && + previous.hasClientMiddleware === next.hasClientMiddleware && + previous.hasDefaultExport === next.hasDefaultExport && + previous.hasLoader === next.hasLoader && + previous.hydrateFallbackModule === next.hydrateFallbackModule && + previous.id === next.id && + previous.index === next.index && + previous.parentId === next.parentId && + previous.path === next.path; const hasRouteManifestMetadataChanges = ( previous: ReactRouterDevManifestSet, - next: ReactRouterDevManifestSet, - includeHmrPatchableFlags: boolean + next: ReactRouterDevManifestSet ): boolean => { const previousEntryNames = Object.keys(previous); if (previousEntryNames.length !== Object.keys(next).length) { @@ -254,10 +240,7 @@ const hasRouteManifestMetadataChanges = ( for (const routeId of previousRouteIds) { const previousRoute = previousRoutes[routeId]; const nextRoute = nextRoutes[routeId]; - if ( - !nextRoute || - !hasSameRouteMetadata(previousRoute, nextRoute, includeHmrPatchableFlags) - ) { + if (!nextRoute || !hasSameRouteMetadata(previousRoute, nextRoute)) { return true; } } @@ -277,7 +260,6 @@ export const createReactRouterDevRuntime = ({ onCssAssetOwnershipChanged = () => undefined, onRouteManifestChanged = () => undefined, onWarning = () => undefined, - clientPatchesRouteMetadata = false, }: CreateReactRouterDevRuntimeOptions): ReactRouterDevRuntime => { let nextAttemptId = 1; let reloadAfterCssRemoval = false; @@ -531,8 +513,7 @@ export const createReactRouterDevRuntime = ({ webChanged && hasRouteManifestMetadataChanges( previous.web.manifestsByEntryName, - manifestsByEntryName, - !clientPatchesRouteMetadata + manifestsByEntryName ); const reusePreviousNodeBuild = !!previous && diff --git a/src/dev-runtime-controller.ts b/src/dev-runtime-controller.ts index 204e72a1..3ff70314 100644 --- a/src/dev-runtime-controller.ts +++ b/src/dev-runtime-controller.ts @@ -55,11 +55,6 @@ type CreateControllerOptions = { api: RsbuildPluginAPI; isBuild: boolean; buildPlan: ReactRouterDevBuildPlan; - /** - * The browser HMR runtime patches route manifest metadata (loader/action - * flags) in place, so metadata-only changes no longer need a full reload. - */ - clientPatchesRouteMetadata?: boolean; /** * Invoked after a development attempt commits a re-evaluated node build for * changed server files. Used to signal hot data revalidation to the client. @@ -85,7 +80,6 @@ export const createReactRouterDevRuntimeController = ({ api, isBuild, buildPlan, - clientPatchesRouteMetadata, onNodeRebuildCommitted, }: CreateControllerOptions): ReactRouterDevRuntimeController => { if (isBuild) { @@ -284,7 +278,6 @@ export const createReactRouterDevRuntimeController = ({ const runtime = createReactRouterDevRuntime({ server, buildPlan, - clientPatchesRouteMetadata, onEvaluationError(error) { if (sessions.getActiveBinding()?.runtime !== runtime) { return; diff --git a/src/index.ts b/src/index.ts index e3b1a015..f50c0d44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -596,14 +596,12 @@ export const pluginReactRouter = ( ), }) : undefined; - devHdrSignal?.ensure(); - const devHmrEnabled = devHmrRefreshRuntimePath !== undefined; + let devHmrEnabled = false; const devRuntime = createReactRouterDevRuntimeController({ api, isBuild, buildPlan: serverBuildPlan, - clientPatchesRouteMetadata: devHmrEnabled, onNodeRebuildCommitted: () => devHdrSignal?.bump(), }); @@ -728,7 +726,7 @@ export const pluginReactRouter = ( ...bundleVirtualModules, ...bundleManifestModules, 'virtual/react-router/with-props': generateWithProps(), - ...(devHmrEnabled && devHmrRefreshRuntimePath + ...(devHmrRefreshRuntimePath ? { [DEV_HMR_RUNTIME_MODULE_ID]: generateDevHmrRuntimeModule({ reactRefreshRuntimePath: devHmrRefreshRuntimePath, @@ -941,6 +939,27 @@ export const pluginReactRouter = ( if (name !== 'web' && name !== 'node') { return config; } + if (name === 'web') { + const swcOptions = + typeof config.tools?.swc === 'object' && + config.tools.swc !== null && + !Array.isArray(config.tools.swc) + ? (config.tools.swc as { + jsc?: { + transform?: { react?: { refresh?: boolean } }; + }; + }) + : undefined; + devHmrEnabled = + !isBuild && + devHmrRefreshRuntimePath !== undefined && + config.mode === 'development' && + config.dev?.hmr !== false && + swcOptions?.jsc?.transform?.react?.refresh === true; + if (devHmrEnabled) { + devHdrSignal?.ensure(); + } + } return mergeEnvironmentConfig(config, { tools: { @@ -1021,7 +1040,7 @@ export const pluginReactRouter = ( ssr, isSpaMode, rootRoutePath, - devHmrEnabled, + isDevHmrEnabled: () => devHmrEnabled, }); }, }); diff --git a/tests/build-output-transforms.test.ts b/tests/build-output-transforms.test.ts index b080e5b4..efefeaab 100644 --- a/tests/build-output-transforms.test.ts +++ b/tests/build-output-transforms.test.ts @@ -119,6 +119,35 @@ describe('build output transforms', () => { expect(run).toHaveBeenCalledTimes(2); }); + it('reads dev HMR enablement when route transforms run', async () => { + const harness = createTransformHarness(); + const options = createBaseOptions(harness); + let devHmrEnabled = false; + + registerBuildOutputTransforms({ + ...options, + isBuild: false, + isDevHmrEnabled: () => devHmrEnabled, + }); + + const routeModuleTransform = harness.transforms.find( + transform => transform.descriptor.order === 'post' + ); + + await routeModuleTransform!.handler(createTransformArgs(options.routePath)); + devHmrEnabled = true; + await routeModuleTransform!.handler(createTransformArgs(options.routePath)); + + expect(options.routeTransformExecutor.run).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ devHmr: false }) + ); + expect(options.routeTransformExecutor.run).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ devHmr: true }) + ); + }); + it('does not match queryless route-module transforms for internal route requests', () => { const harness = createTransformHarness(); const options = createBaseOptions(harness); From efe137db7a7ea5a09b34256ca1045d0b07f00ac6 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:07:20 +0000 Subject: [PATCH 3/7] chore: ignore generated local artifacts --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 047db0b8..a029637d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,11 @@ build .unpack-cache/ .codex/ .tracedecay/ +.cursor/ .benchmark/ task/upstream/ task/output/ +tests/react-router-framework/.tmp/ # Example build outputs **/build/ From 59d44f8daccc6a4ddd0108055c77c4d0d4b2242a Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:41:29 +0000 Subject: [PATCH 4/7] perf: compact dev route hmr shims --- src/dev-hmr.ts | 78 ++++++++++++++-------------- src/dev-runtime-controller.ts | 19 +++++-- src/route-artifacts.ts | 96 +++++++++++++++-------------------- src/route-transform-tasks.ts | 37 +++----------- tests/route-artifacts.test.ts | 33 ++++++++++++ 5 files changed, 135 insertions(+), 128 deletions(-) diff --git a/src/dev-hmr.ts b/src/dev-hmr.ts index c8c3f0a1..83db4946 100644 --- a/src/dev-hmr.ts +++ b/src/dev-hmr.ts @@ -2,13 +2,9 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join } from 'pathe'; -export const DEV_HMR_RUNTIME_MODULE_ID = 'virtual/react-router/hmr-runtime'; +import { HMR_PATCHABLE_ROUTE_FLAGS } from './route-artifacts.js'; -export type DevHmrPlanOptions = { - enabled: true; - runtimeModule: string; - onNodeRebuildCommitted: () => void; -}; +export const DEV_HMR_RUNTIME_MODULE_ID = 'virtual/react-router/hmr-runtime'; /** * Resolves the `react-refresh/runtime` module that @@ -49,11 +45,12 @@ const hdrRevisionModuleContent = (revision: number): string => * whenever server code changes, which the client answers by revalidating * React Router loader data. */ +export const DEV_HDR_REVISION_RELATIVE_PATH = '.react-router/hdr-revision.mjs'; + export const getDevHdrRevisionFilePath = (rootPath: string): string => - join(rootPath, '.react-router', 'hdr-revision.mjs'); + join(rootPath, DEV_HDR_REVISION_RELATIVE_PATH); export type DevHdrRevisionSignal = { - filePath: string; /** Writes the initial revision module so the first compile can resolve it. */ ensure: () => void; /** Increments the revision, signaling hot data revalidation to the client. */ @@ -81,7 +78,6 @@ export const createDevHdrRevisionSignal = ({ } }; return { - filePath, ensure: write, bump() { revision += 1; @@ -111,11 +107,7 @@ import * as __refreshRuntimeModule from ${JSON.stringify(reactRefreshRuntimePath // Read revision so the import survives sideEffects: false tree-shaking. import __hdrRevision from ${JSON.stringify(hdrRevisionFilePath)}; -let latestHdrRevision = __hdrRevision; - -export function getReactRouterHdrRevision() { - return latestHdrRevision; -} +void __hdrRevision; const RefreshRuntime = __refreshRuntimeModule && __refreshRuntimeModule.performReactRefresh @@ -158,10 +150,10 @@ export function registerReactRouterRouteExports(routeId, moduleExports) { export function scheduleReactRouterRouteUpdate( routeId, - routeMetadata, + routeFlags, getRouteModuleExports ) { - pendingRouteUpdates.set(routeId, { routeMetadata, getRouteModuleExports }); + pendingRouteUpdates.set(routeId, { routeFlags, getRouteModuleExports }); scheduleFlush(); } @@ -187,8 +179,16 @@ function takePendingRouteUpdates() { return updates; } +function getRouteMetadata(routeFlags) { + return { +${HMR_PATCHABLE_ROUTE_FLAGS.map( + (flag, index) => ` ${flag}: Boolean(routeFlags & ${1 << index}),` +).join('\n')} + }; +} + function applyRouteModuleUpdate(routeId, update, routeEntry, routeModules) { - Object.assign(routeEntry, update.routeMetadata); + Object.assign(routeEntry, getRouteMetadata(update.routeFlags)); const imported = update.getRouteModuleExports(); registerReactRouterRouteExports(routeId, imported); const current = routeModules[routeId]; @@ -260,7 +260,8 @@ function applyPendingRouteUpdates(router, routeModules, manifest, context) { const existingEntry = nextManifest.routes[routeId]; if (!existingEntry) continue; - const routeEntry = JSON.parse(JSON.stringify(existingEntry)); + // Shallow clone is enough: only top-level flags are mutated below. + const routeEntry = { ...existingEntry }; nextManifest.routes[routeId] = routeEntry; applyRouteModuleUpdate(routeId, update, routeEntry, routeModules); if ( @@ -300,33 +301,33 @@ function applyPendingRouteUpdates(router, routeModules, manifest, context) { return { nextManifest, shouldRefreshRouteState, routesToRevalidate }; } -async function revalidateRouter(router) { +async function withHdrActive(fn) { try { window.__reactRouterHdrActive = true; - if (typeof router.revalidate === 'function') { - await router.revalidate(); - return; - } - if (typeof router.navigate === 'function') { - await router.navigate(getCurrentRouterPath(router), { - replace: true, - preventScrollReset: true, - }); - } + await fn(); } finally { window.__reactRouterHdrActive = false; } } -async function refreshRouteState(router) { - if (typeof router.revalidate !== 'function') { +async function revalidateRouter(router) { + if (typeof router.revalidate === 'function') { + await withHdrActive(() => router.revalidate()); return; } - try { - window.__reactRouterHdrActive = true; - await router.revalidate(); - } finally { - window.__reactRouterHdrActive = false; + if (typeof router.navigate === 'function') { + await withHdrActive(() => + router.navigate(getCurrentRouterPath(router), { + replace: true, + preventScrollReset: true, + }) + ); + } +} + +async function refreshRouteState(router) { + if (typeof router.revalidate === 'function') { + await withHdrActive(() => router.revalidate()); } } @@ -372,10 +373,7 @@ async function flush() { if (typeof window !== 'undefined' && import.meta.webpackHot) { import.meta.webpackHot.accept( ${JSON.stringify(hdrRevisionFilePath)}, - () => { - latestHdrRevision = __hdrRevision; - scheduleReactRouterRevalidation(); - } + scheduleReactRouterRevalidation ); } `; diff --git a/src/dev-runtime-controller.ts b/src/dev-runtime-controller.ts index 3ff70314..9202aa26 100644 --- a/src/dev-runtime-controller.ts +++ b/src/dev-runtime-controller.ts @@ -27,6 +27,7 @@ import { type ReactRouterDevBuildPlan, type ReactRouterDevManifestSet, } from './dev-runtime-artifacts.js'; +import { DEV_HDR_REVISION_RELATIVE_PATH } from './dev-hmr.js'; import { createDevRuntimeSessionManager, type RuntimeBinding, @@ -71,11 +72,23 @@ const escapeHtml = (value: string): string => const CSS_SOURCE_RELOAD_DELAY_MS = 1000; const isHdrRevisionFile = (file: string): boolean => - file.includes('.react-router/hdr-revision.mjs'); + file.includes(DEV_HDR_REVISION_RELATIVE_PATH); const isCssSourceFile = (file: string): boolean => /\.css(?:\.[cm]?[jt]s)?$/.test(file); +// A change that should bump the HDR revision: anything except the revision +// file itself (the bump's own echo) and CSS sources (styling never changes +// loader data). +const hasHdrTriggeringChange = (files: Iterable): boolean => { + for (const file of files) { + if (!isHdrRevisionFile(file) && !isCssSourceFile(file)) { + return true; + } + } + return false; +}; + export const createReactRouterDevRuntimeController = ({ api, isBuild, @@ -176,9 +189,7 @@ export const createReactRouterDevRuntimeController = ({ changes.node.known && identity.node !== undefined && hdrSignaledNodeIdentity.get(pair) !== identity.node && - Array.from(changes.node.files).some( - file => !isHdrRevisionFile(file) && !isCssSourceFile(file) - ) + hasHdrTriggeringChange(changes.node.files) ) { hdrSignaledNodeIdentity.set(pair, identity.node); onNodeRebuildCommitted?.(); diff --git a/src/route-artifacts.ts b/src/route-artifacts.ts index c7c782da..2c186dd8 100644 --- a/src/route-artifacts.ts +++ b/src/route-artifacts.ts @@ -50,8 +50,9 @@ type RouteChunkArtifact = { map: null; }; -// Shared with the dev manifest differ: exactly the route flags the client HMR -// runtime can patch in place without a full reload. +// Exactly the route-manifest flags the client HMR runtime can patch in place +// without a full reload. Array order defines the bit layout shared by the +// encoder below and the decoder emitted in `generateDevHmrRuntimeModule`. export const HMR_PATCHABLE_ROUTE_FLAGS = [ 'hasAction', 'hasClientAction', @@ -61,23 +62,25 @@ export const HMR_PATCHABLE_ROUTE_FLAGS = [ 'hasLoader', ] as const; -type RouteHmrMetadata = Record< +const HMR_FLAG_EXPORT_NAME: Record< (typeof HMR_PATCHABLE_ROUTE_FLAGS)[number], - boolean ->; + string +> = { + hasAction: SERVER_EXPORTS.action, + hasClientAction: CLIENT_EXPORTS.clientAction, + hasClientLoader: CLIENT_EXPORTS.clientLoader, + hasClientMiddleware: CLIENT_EXPORTS.clientMiddleware, + hasErrorBoundary: CLIENT_EXPORTS.ErrorBoundary, + hasLoader: SERVER_EXPORTS.loader, +}; -export const buildRouteHmrMetadata = ( - exportNames: readonly string[] -): RouteHmrMetadata => { +export const buildRouteHmrFlags = (exportNames: readonly string[]): number => { const exports = new Set(exportNames); - return { - hasAction: exports.has(SERVER_EXPORTS.action), - hasClientAction: exports.has(CLIENT_EXPORTS.clientAction), - hasClientLoader: exports.has(CLIENT_EXPORTS.clientLoader), - hasClientMiddleware: exports.has(CLIENT_EXPORTS.clientMiddleware), - hasErrorBoundary: exports.has(CLIENT_EXPORTS.ErrorBoundary), - hasLoader: exports.has(SERVER_EXPORTS.loader), - }; + let flags = 0; + HMR_PATCHABLE_ROUTE_FLAGS.forEach((flag, index) => { + if (exports.has(HMR_FLAG_EXPORT_NAME[flag])) flags |= 1 << index; + }); + return flags; }; /** @@ -94,61 +97,46 @@ const buildRouteClientEntryHmrCode = ({ routeId, target, acceptTarget, - metadata, + flags, }: { routeId: string; target: string; acceptTarget: string; - metadata: RouteHmrMetadata; + flags: number; }): string => { const targetJson = JSON.stringify(target); const acceptTargetJson = JSON.stringify(acceptTarget); return ` -import * as __reactRouterRouteModule from ${targetJson}; +import * as __rrm from ${targetJson}; import { - registerReactRouterRouteExports as __reactRouterRegisterRouteExports, - scheduleReactRouterRouteUpdate as __reactRouterScheduleRouteUpdate, + registerReactRouterRouteExports as __rrr, + scheduleReactRouterRouteUpdate as __rru, } from "virtual/react-router/hmr-runtime"; -const __reactRouterRouteId = ${JSON.stringify(routeId)}; -const __reactRouterRouteMetadata = ${JSON.stringify(metadata)}; -const __reactRouterGetRouteModule = () => __reactRouterRouteModule; +const __rrid = ${JSON.stringify(routeId)}; +const __rrf = ${flags}; +const __rrg = () => __rrm; +const __rru0 = () => { + __rrr(__rrid, __rrm); + __rru(__rrid, __rrf, __rrg); +}; -__reactRouterRegisterRouteExports( - __reactRouterRouteId, - __reactRouterRouteModule -); +__rrr(__rrid, __rrm); if (import.meta.webpackHot) { - import.meta.webpackHot.accept(${acceptTargetJson}, () => { - __reactRouterRegisterRouteExports( - __reactRouterRouteId, - __reactRouterRouteModule - ); - __reactRouterScheduleRouteUpdate( - __reactRouterRouteId, - __reactRouterRouteMetadata, - __reactRouterGetRouteModule - ); - }); - import.meta.webpackHot.accept(); - import.meta.webpackHot.dispose(data => { - data.__reactRouterRouteShim = true; - }); - if ( - import.meta.webpackHot.data && - import.meta.webpackHot.data.__reactRouterRouteShim - ) { - __reactRouterScheduleRouteUpdate( - __reactRouterRouteId, - __reactRouterRouteMetadata, - __reactRouterGetRouteModule - ); - } + const __rrh = import.meta.webpackHot; + __rrh.accept(${acceptTargetJson}, __rru0); + __rrh.accept(); + __rrh.dispose(data => { data.__rr = true; }); + if (__rrh.data && __rrh.data.__rr) __rru0(); } `; }; +// The accept target is spelled relative (`./name?react-router-route`) while +// the import above uses the absolute resource path; both resolve to the same +// module because the client entry replaces the route file in place, so its +// resolution context is the route's own directory. const createRouteHmrAcceptTarget = (resourcePath: string): string => { return `./${basename(resourcePath)}?react-router-route`; }; @@ -192,7 +180,7 @@ export const buildRouteClientEntryCode = ({ routeId, target, acceptTarget: createRouteHmrAcceptTarget(resourcePath), - metadata: buildRouteHmrMetadata(exportNames), + flags: buildRouteHmrFlags(exportNames), }) ); }; diff --git a/src/route-transform-tasks.ts b/src/route-transform-tasks.ts index cb4251fd..8deb9b7f 100644 --- a/src/route-transform-tasks.ts +++ b/src/route-transform-tasks.ts @@ -24,7 +24,10 @@ import { type RouteChunkCache, type RouteChunkConfig, } from './route-chunks.js'; -import { getProgram } from './route-ast.js'; +import { + getProgram, + type AnyNode, +} from './route-ast.js'; export type RouteTransformResult = { code: string; @@ -156,14 +159,6 @@ const createClientOnlyStub = async ( const isComponentishName = (name: string): boolean => /^[A-Z]/.test(name); -type AnyNode = { - type?: string; - name?: string; - body?: { type?: string }; - callee?: { type?: string; name?: string }; - arguments?: Array; -}; - /** * Whether an expression that appears as the first argument of a wrapping call * resolves to a component, mirroring react-refresh/babel's @@ -231,14 +226,7 @@ const initResolvesToComponent = (init: AnyNode): boolean => { }; const collectDeclaredComponentNames = ( - declaration: { - type?: string; - id?: { name?: string }; - declarations?: Array<{ - id?: { type?: string; name?: string }; - init?: AnyNode; - }>; - }, + declaration: AnyNode, names: Set ): void => { if ( @@ -281,15 +269,7 @@ const collectDeclaredComponentNames = ( * subtree instead of updating it in place. */ const collectUnregisteredComponentNames = (program: { - body?: Array<{ - type?: string; - declaration?: Parameters[0]; - expression?: { - type?: string; - callee?: { type?: string; name?: string }; - arguments?: Array<{ type?: string; value?: unknown }>; - }; - }>; + body?: AnyNode[]; }): string[] => { const declared = new Set(); const registered = new Set(); @@ -310,10 +290,7 @@ const collectUnregisteredComponentNames = (program: { } continue; } - collectDeclaredComponentNames( - statement as Parameters[0], - declared - ); + collectDeclaredComponentNames(statement, declared); } return [...declared].filter(name => !registered.has(name)); }; diff --git a/tests/route-artifacts.test.ts b/tests/route-artifacts.test.ts index cefc2793..dbf32829 100644 --- a/tests/route-artifacts.test.ts +++ b/tests/route-artifacts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from '@rstest/core'; import { + buildRouteHmrFlags, createRouteChunkArtifact, createRouteClientEntryArtifact, } from '../src/route-artifacts'; @@ -151,6 +152,38 @@ describe('route artifact helpers', () => { )};`, }); }); + + it('uses compact dev HMR route flag metadata', async () => { + expect( + buildRouteHmrFlags([ + 'action', + 'clientLoader', + 'default', + 'ErrorBoundary', + 'loader', + ]) + ).toBe((1 << 0) | (1 << 2) | (1 << 4) | (1 << 5)); + + const result = await createRouteClientEntryArtifact({ + code: ` + export async function loader() { return null; } + export async function clientLoader() { return null; } + export default function Route() { return null; } + `, + resourcePath, + routeId: 'routes/demo', + environmentName: 'web', + isBuild: false, + devHmr: true, + routeChunkConfig: disabledRouteChunkConfig, + }); + + expect(result.code).toContain('const __rrf = 36;'); + expect(result.code).toContain( + 'scheduleReactRouterRouteUpdate as __rru' + ); + expect(result.code).not.toContain('hasClientLoader'); + }); }); describe('createRouteChunkArtifact', () => { From d5177b6a1bb664989838612843eb46a65d3739c1 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:35:51 +0000 Subject: [PATCH 5/7] fix: detect resolved fast refresh config --- src/dev-hmr.ts | 23 +++++ src/index.ts | 45 ++++----- src/prerender-build.ts | 37 +++----- tests/dev-hmr-fast-refresh-detection.test.ts | 98 ++++++++++++++++++++ tests/setup.ts | 1 + 5 files changed, 157 insertions(+), 47 deletions(-) create mode 100644 tests/dev-hmr-fast-refresh-detection.test.ts diff --git a/src/dev-hmr.ts b/src/dev-hmr.ts index 83db4946..1dd7881f 100644 --- a/src/dev-hmr.ts +++ b/src/dev-hmr.ts @@ -1,11 +1,34 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; +import type { ChainIdentifier, RspackChain } from '@rsbuild/core'; import { dirname, join } from 'pathe'; import { HMR_PATCHABLE_ROUTE_FLAGS } from './route-artifacts.js'; export const DEV_HMR_RUNTIME_MODULE_ID = 'virtual/react-router/hmr-runtime'; +/** + * Reads the resolved `builtin:swc-loader` React Refresh flag from the bundler + * chain. Rsbuild reduces every `tools.swc` form (object, function, array of + * fragments) into the concrete options object it sets on the main JS rule, so + * reading it here reflects the final merged value no matter how Fast Refresh + * was configured. Call from a `post`-ordered `modifyBundlerChain` hook so the + * reduction (a `pre`-ordered hook in Rsbuild core) has already run. + */ +export const isSwcReactRefreshEnabled = ( + chain: RspackChain, + CHAIN_ID: ChainIdentifier +): boolean => { + const swcUse = chain.module.rules + .get(CHAIN_ID.RULE.JS) + ?.oneOfs.get(CHAIN_ID.ONE_OF.JS_MAIN) + ?.uses.get(CHAIN_ID.USE.SWC); + const options = swcUse?.get('options') as + | { jsc?: { transform?: { react?: { refresh?: boolean } } } } + | undefined; + return options?.jsc?.transform?.react?.refresh === true; +}; + /** * Resolves the `react-refresh/runtime` module that * `@rspack/plugin-react-refresh` injects into the web bundle. The resolution diff --git a/src/index.ts b/src/index.ts index f50c0d44..e27ef63e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,6 +86,7 @@ import { createDevHdrRevisionSignal, generateDevHmrRuntimeModule, getDevHdrRevisionFilePath, + isSwcReactRefreshEnabled, resolveReactRefreshRuntimePath, DEV_HMR_RUNTIME_MODULE_ID, } from './dev-hmr.js'; @@ -934,32 +935,34 @@ export const pluginReactRouter = ( }); }); + // Gate classic dev HMR on the resolved swc-loader options rather than the + // raw `tools.swc` value: users can layer function/array forms on top of + // `@rsbuild/plugin-react`, and only the reduced chain options reflect the + // final Fast Refresh state. `post` order runs after Rsbuild core's swc + // plugin (`pre`) has merged them. + api.modifyBundlerChain({ + order: 'post', + handler: (chain, { CHAIN_ID, isDev, environment }) => { + if (environment.name !== 'web') { + return; + } + devHmrEnabled = + !isBuild && + devHmrRefreshRuntimePath !== undefined && + isDev && + environment.config.dev?.hmr !== false && + isSwcReactRefreshEnabled(chain, CHAIN_ID); + if (devHmrEnabled) { + devHdrSignal?.ensure(); + } + }, + }); + api.modifyEnvironmentConfig( async (config, { name, mergeEnvironmentConfig }) => { if (name !== 'web' && name !== 'node') { return config; } - if (name === 'web') { - const swcOptions = - typeof config.tools?.swc === 'object' && - config.tools.swc !== null && - !Array.isArray(config.tools.swc) - ? (config.tools.swc as { - jsc?: { - transform?: { react?: { refresh?: boolean } }; - }; - }) - : undefined; - devHmrEnabled = - !isBuild && - devHmrRefreshRuntimePath !== undefined && - config.mode === 'development' && - config.dev?.hmr !== false && - swcOptions?.jsc?.transform?.react?.refresh === true; - if (devHmrEnabled) { - devHdrSignal?.ensure(); - } - } return mergeEnvironmentConfig(config, { tools: { diff --git a/src/prerender-build.ts b/src/prerender-build.ts index eb7742ae..d6845998 100644 --- a/src/prerender-build.ts +++ b/src/prerender-build.ts @@ -132,23 +132,6 @@ const createDataRequestPath = ( : `${prerenderPath.replace(/\/$/, '')}.data`; }; -const createDataOutputPath = ( - prerenderPath: string, - trailingSlashAwareDataRequests: boolean -): string => - createDataRequestPath(prerenderPath, trailingSlashAwareDataRequests); - -const createDataHandlerRequestPath = ( - prerenderPath: string, - trailingSlashAwareDataRequests: boolean -): string => { - if (trailingSlashAwareDataRequests && prerenderPath === '/') { - return '/_root.data'; - } - - return createDataRequestPath(prerenderPath, trailingSlashAwareDataRequests); -}; - export const createBuildRequestEffect = ( input: string | URL, init: RequestInit | undefined, @@ -196,19 +179,21 @@ const prerenderData = async ({ api: PrerenderBuildApi; requestInit?: RequestInit; }): Promise => { - const dataRequestPath = createDataHandlerRequestPath( - prerenderPath, - trailingSlashAwareDataRequests - ); - const dataOutputPath = createDataOutputPath( + const dataOutputPath = createDataRequestPath( prerenderPath, trailingSlashAwareDataRequests ); + // The handler always serves the root route's data at /_root.data, even when + // trailing-slash-aware naming writes the root output to /_.data. + const dataRequestPath = + trailingSlashAwareDataRequests && prerenderPath === '/' + ? '/_root.data' + : dataOutputPath; const normalizedPath = `${basename}${dataRequestPath}`.replace(/\/\/+/g, '/'); - const outputNormalizedPath = `${basename}${dataOutputPath}`.replace( - /\/\/+/g, - '/' - ); + const outputNormalizedPath = + dataOutputPath === dataRequestPath + ? normalizedPath + : `${basename}${dataOutputPath}`.replace(/\/\/+/g, '/'); const url = new URL(`http://localhost${normalizedPath}`); if (onlyRoutes?.length) { url.searchParams.set('_routes', onlyRoutes.join(',')); diff --git a/tests/dev-hmr-fast-refresh-detection.test.ts b/tests/dev-hmr-fast-refresh-detection.test.ts new file mode 100644 index 00000000..3a4a38cf --- /dev/null +++ b/tests/dev-hmr-fast-refresh-detection.test.ts @@ -0,0 +1,98 @@ +import { createRsbuild, type RsbuildConfig } from '@rsbuild/core'; +import { pluginReact } from '@rsbuild/plugin-react'; +import { describe, expect, it } from '@rstest/core'; +import { isSwcReactRefreshEnabled } from '../src/dev-hmr'; + +type ToolsSwc = NonNullable['swc']>; + +// Runs real Rsbuild config resolution so every `tools.swc` form goes through +// core's reduceConfigs merge, then reads the resolved chain the same way the +// plugin's `post`-ordered modifyBundlerChain hook does. +const detectRefresh = async ({ + swc, + plugins = [], +}: { + swc?: ToolsSwc; + plugins?: NonNullable; +}): Promise => { + let detected: boolean | undefined; + const rsbuild = await createRsbuild({ + rsbuildConfig: { + mode: 'development', + ...(swc ? { tools: { swc } } : {}), + plugins: [ + ...plugins, + { + name: 'test:refresh-probe', + setup(api) { + api.modifyBundlerChain({ + order: 'post', + handler: (chain, { CHAIN_ID }) => { + detected = isSwcReactRefreshEnabled(chain, CHAIN_ID); + }, + }); + }, + }, + ], + }, + }); + await rsbuild.initConfigs(); + if (detected === undefined) { + throw new Error('refresh probe hook did not run'); + } + return detected; +}; + +const refreshFragment = { + jsc: { transform: { react: { refresh: true } } }, +}; + +describe('isSwcReactRefreshEnabled', () => { + it('detects the plain-object tools.swc form', async () => { + await expect(detectRefresh({ swc: refreshFragment })).resolves.toBe(true); + }); + + it('detects the array tools.swc form', async () => { + await expect(detectRefresh({ swc: [refreshFragment] })).resolves.toBe( + true + ); + }); + + it('detects the function tools.swc form', async () => { + await expect( + detectRefresh({ + swc: config => { + config.jsc ??= {}; + config.jsc.transform = { + ...config.jsc.transform, + react: { ...config.jsc.transform?.react, refresh: true }, + }; + }, + }) + ).resolves.toBe(true); + }); + + it('detects plugin-react refresh layered under a user tools.swc function', async () => { + // plugin-react contributes an object fragment; the user function turns + // tools.swc into a mixed array — the case the raw-config probe missed. + await expect( + detectRefresh({ + plugins: [pluginReact()], + swc: config => { + config.jsc ??= {}; + config.jsc.parser = { ...config.jsc.parser, decorators: true }; + }, + }) + ).resolves.toBe(true); + }); + + it('returns false when Fast Refresh is not configured', async () => { + await expect(detectRefresh({})).resolves.toBe(false); + }); + + it('returns false when plugin-react disables fastRefresh', async () => { + await expect( + detectRefresh({ plugins: [pluginReact({ fastRefresh: false })] }) + ).resolves.toBe(false); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 4b5f77df..dfdb907f 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -170,6 +170,7 @@ rstest.mock('@scripts/test-helper', () => ({ // Rsbuild config (modifyRsbuildConfig), not post-normalization environment // mutations. modifyEnvironmentConfig: rstest.fn(), + modifyBundlerChain: rstest.fn(), transform: rstest.fn(), logger: { warn: rstest.fn(), From ad478ad71231250f45a4437cc75e3215d2ee25cb Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:41:48 +0000 Subject: [PATCH 6/7] fix: read fast refresh from rspack config --- src/dev-hmr.ts | 68 ++++++++++++++------ src/index.ts | 37 ++++------- tests/dev-hmr-fast-refresh-detection.test.ts | 29 ++++----- tests/setup.ts | 1 - 4 files changed, 72 insertions(+), 63 deletions(-) diff --git a/src/dev-hmr.ts b/src/dev-hmr.ts index 1dd7881f..9d277a11 100644 --- a/src/dev-hmr.ts +++ b/src/dev-hmr.ts @@ -1,34 +1,60 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; -import type { ChainIdentifier, RspackChain } from '@rsbuild/core'; +import type { Rspack } from '@rsbuild/core'; import { dirname, join } from 'pathe'; import { HMR_PATCHABLE_ROUTE_FLAGS } from './route-artifacts.js'; export const DEV_HMR_RUNTIME_MODULE_ID = 'virtual/react-router/hmr-runtime'; -/** - * Reads the resolved `builtin:swc-loader` React Refresh flag from the bundler - * chain. Rsbuild reduces every `tools.swc` form (object, function, array of - * fragments) into the concrete options object it sets on the main JS rule, so - * reading it here reflects the final merged value no matter how Fast Refresh - * was configured. Call from a `post`-ordered `modifyBundlerChain` hook so the - * reduction (a `pre`-ordered hook in Rsbuild core) has already run. - */ -export const isSwcReactRefreshEnabled = ( - chain: RspackChain, - CHAIN_ID: ChainIdentifier -): boolean => { - const swcUse = chain.module.rules - .get(CHAIN_ID.RULE.JS) - ?.oneOfs.get(CHAIN_ID.ONE_OF.JS_MAIN) - ?.uses.get(CHAIN_ID.USE.SWC); - const options = swcUse?.get('options') as - | { jsc?: { transform?: { react?: { refresh?: boolean } } } } - | undefined; - return options?.jsc?.transform?.react?.refresh === true; +type SwcLoaderOptions = { + jsc?: { transform?: { react?: { refresh?: boolean } } }; }; +const isObject = (value: unknown): value is Record => + value !== null && typeof value === 'object'; + +const isSwcLoader = (loader: unknown): boolean => + typeof loader === 'string' && loader.includes('builtin:swc-loader'); + +const hasReactRefresh = (options: unknown): boolean => + (options as SwcLoaderOptions | undefined)?.jsc?.transform?.react?.refresh === + true; + +const readSwcLoaderRefresh = (value: unknown): boolean => { + if (Array.isArray(value)) { + return value.some(readSwcLoaderRefresh); + } + if (!isObject(value)) { + return false; + } + if (isSwcLoader(value.loader)) { + return hasReactRefresh(value.options); + } + return Object.values(value).some(readSwcLoaderRefresh); +}; + +const readRuleSwcRefresh = (rule: unknown): boolean => { + if (!isObject(rule)) { + return false; + } + if (isSwcLoader(rule.loader)) { + return hasReactRefresh(rule.options); + } + return ( + readSwcLoaderRefresh(rule.use) || + readRuleSetSwcRefresh(rule.oneOf) || + readRuleSetSwcRefresh(rule.rules) + ); +}; + +const readRuleSetSwcRefresh = (rules: unknown): boolean => + Array.isArray(rules) && rules.some(readRuleSwcRefresh); + +export const isRspackSwcReactRefreshEnabled = ( + rspackConfig: Rspack.Configuration +): boolean => readRuleSetSwcRefresh(rspackConfig.module?.rules); + /** * Resolves the `react-refresh/runtime` module that * `@rspack/plugin-react-refresh` injects into the web bundle. The resolution diff --git a/src/index.ts b/src/index.ts index e27ef63e..7144c910 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,7 +86,7 @@ import { createDevHdrRevisionSignal, generateDevHmrRuntimeModule, getDevHdrRevisionFilePath, - isSwcReactRefreshEnabled, + isRspackSwcReactRefreshEnabled, resolveReactRefreshRuntimePath, DEV_HMR_RUNTIME_MODULE_ID, } from './dev-hmr.js'; @@ -935,29 +935,6 @@ export const pluginReactRouter = ( }); }); - // Gate classic dev HMR on the resolved swc-loader options rather than the - // raw `tools.swc` value: users can layer function/array forms on top of - // `@rsbuild/plugin-react`, and only the reduced chain options reflect the - // final Fast Refresh state. `post` order runs after Rsbuild core's swc - // plugin (`pre`) has merged them. - api.modifyBundlerChain({ - order: 'post', - handler: (chain, { CHAIN_ID, isDev, environment }) => { - if (environment.name !== 'web') { - return; - } - devHmrEnabled = - !isBuild && - devHmrRefreshRuntimePath !== undefined && - isDev && - environment.config.dev?.hmr !== false && - isSwcReactRefreshEnabled(chain, CHAIN_ID); - if (devHmrEnabled) { - devHdrSignal?.ensure(); - } - }, - }); - api.modifyEnvironmentConfig( async (config, { name, mergeEnvironmentConfig }) => { if (name !== 'web' && name !== 'node') { @@ -971,6 +948,18 @@ export const pluginReactRouter = ( ensureFederationAsyncStartup(rspackConfig); } + if (name === 'web') { + devHmrEnabled = + !isBuild && + devHmrRefreshRuntimePath !== undefined && + config.mode === 'development' && + config.dev?.hmr !== false && + isRspackSwcReactRefreshEnabled(rspackConfig); + if (devHmrEnabled) { + devHdrSignal?.ensure(); + } + } + if (name === 'node') { const output = rspackConfig.output; if (output) { diff --git a/tests/dev-hmr-fast-refresh-detection.test.ts b/tests/dev-hmr-fast-refresh-detection.test.ts index 3a4a38cf..d6c48d66 100644 --- a/tests/dev-hmr-fast-refresh-detection.test.ts +++ b/tests/dev-hmr-fast-refresh-detection.test.ts @@ -1,13 +1,13 @@ import { createRsbuild, type RsbuildConfig } from '@rsbuild/core'; import { pluginReact } from '@rsbuild/plugin-react'; import { describe, expect, it } from '@rstest/core'; -import { isSwcReactRefreshEnabled } from '../src/dev-hmr'; +import { isRspackSwcReactRefreshEnabled } from '../src/dev-hmr'; type ToolsSwc = NonNullable['swc']>; // Runs real Rsbuild config resolution so every `tools.swc` form goes through -// core's reduceConfigs merge, then reads the resolved chain the same way the -// plugin's `post`-ordered modifyBundlerChain hook does. +// core's reduceConfigs merge, then reads the resolved Rspack config the same +// way the plugin's `tools.rspack` hook does. const detectRefresh = async ({ swc, plugins = [], @@ -19,26 +19,21 @@ const detectRefresh = async ({ const rsbuild = await createRsbuild({ rsbuildConfig: { mode: 'development', - ...(swc ? { tools: { swc } } : {}), + tools: { + ...(swc ? { swc } : {}), + rspack: config => { + detected = isRspackSwcReactRefreshEnabled(config); + return config; + }, + }, plugins: [ ...plugins, - { - name: 'test:refresh-probe', - setup(api) { - api.modifyBundlerChain({ - order: 'post', - handler: (chain, { CHAIN_ID }) => { - detected = isSwcReactRefreshEnabled(chain, CHAIN_ID); - }, - }); - }, - }, ], }, }); await rsbuild.initConfigs(); if (detected === undefined) { - throw new Error('refresh probe hook did not run'); + throw new Error('refresh probe did not run'); } return detected; }; @@ -47,7 +42,7 @@ const refreshFragment = { jsc: { transform: { react: { refresh: true } } }, }; -describe('isSwcReactRefreshEnabled', () => { +describe('isRspackSwcReactRefreshEnabled', () => { it('detects the plain-object tools.swc form', async () => { await expect(detectRefresh({ swc: refreshFragment })).resolves.toBe(true); }); diff --git a/tests/setup.ts b/tests/setup.ts index dfdb907f..4b5f77df 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -170,7 +170,6 @@ rstest.mock('@scripts/test-helper', () => ({ // Rsbuild config (modifyRsbuildConfig), not post-normalization environment // mutations. modifyEnvironmentConfig: rstest.fn(), - modifyBundlerChain: rstest.fn(), transform: rstest.fn(), logger: { warn: rstest.fn(), From b2bd3256344c602135b83f050c2535d0ec869ffb Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:24:34 +0000 Subject: [PATCH 7/7] fix: preserve HMR revalidation fallback --- src/dev-hmr.ts | 7 +++++-- tests/dev-hmr.test.ts | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/dev-hmr.ts b/src/dev-hmr.ts index 9d277a11..af8f5c5b 100644 --- a/src/dev-hmr.ts +++ b/src/dev-hmr.ts @@ -377,7 +377,9 @@ async function revalidateRouter(router) { async function refreshRouteState(router) { if (typeof router.revalidate === 'function') { await withHdrActive(() => router.revalidate()); + return true; } + return false; } function performReactRefresh() { @@ -410,8 +412,9 @@ async function flush() { shouldRefreshRouteState && (routesToRevalidate.size > 0 || shouldRevalidate) ) { - await refreshRouteState(router); - shouldRevalidate = false; + if (await refreshRouteState(router)) { + shouldRevalidate = false; + } } if (shouldRevalidate) { await revalidateRouter(router); diff --git a/tests/dev-hmr.test.ts b/tests/dev-hmr.test.ts index 558e1d0c..ee319cf3 100644 --- a/tests/dev-hmr.test.ts +++ b/tests/dev-hmr.test.ts @@ -41,6 +41,8 @@ describe('generateDevHmrRuntimeModule', () => { ); expect(code).toContain('export function registerReactRouterRouteExports'); expect(code).toContain('export function scheduleReactRouterRouteUpdate'); + expect(code).toContain('if (await refreshRouteState(router))'); + expect(code).toContain('await revalidateRouter(router)'); }); });