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..ae7ed986 --- /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 or Fast Refresh is disabled. 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/ diff --git a/src/build-output-transforms.ts b/src/build-output-transforms.ts index c06bae2e..9b14d0ec 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; + isDevHmrEnabled?: () => boolean; }; export const registerBuildOutputTransforms = ({ @@ -61,6 +62,7 @@ export const registerBuildOutputTransforms = ({ ssr, isSpaMode, rootRoutePath, + isDevHmrEnabled = () => false, }: RegisterBuildOutputTransformsOptions): void => { const transformRouteModule = async (args: Parameters[0]) => performanceProfiler.record( @@ -81,6 +83,7 @@ export const registerBuildOutputTransforms = ({ isBuild, isSpaMode, rootRoutePath, + devHmr: isDevHmrEnabled(), }) ); @@ -158,6 +161,8 @@ export const registerBuildOutputTransforms = ({ environmentName: args.environment?.name, isBuild, routeChunkConfig, + routeId: routeByFilePath.get(args.resourcePath)?.id, + devHmr: isDevHmrEnabled(), }) ) ); diff --git a/src/dev-hmr.ts b/src/dev-hmr.ts new file mode 100644 index 00000000..af8f5c5b --- /dev/null +++ b/src/dev-hmr.ts @@ -0,0 +1,431 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +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'; + +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 + * 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 DEV_HDR_REVISION_RELATIVE_PATH = '.react-router/hdr-revision.mjs'; + +export const getDevHdrRevisionFilePath = (rootPath: string): string => + join(rootPath, DEV_HDR_REVISION_RELATIVE_PATH); + +export type DevHdrRevisionSignal = { + /** 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 { + 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)}; + +void __hdrRevision; + +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, + routeFlags, + getRouteModuleExports +) { + pendingRouteUpdates.set(routeId, { routeFlags, 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 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, getRouteMetadata(update.routeFlags)); + 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; + + // Shallow clone is enough: only top-level flags are mutated below. + const routeEntry = { ...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 withHdrActive(fn) { + try { + window.__reactRouterHdrActive = true; + await fn(); + } finally { + window.__reactRouterHdrActive = false; + } +} + +async function revalidateRouter(router) { + if (typeof router.revalidate === 'function') { + await withHdrActive(() => router.revalidate()); + return; + } + 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()); + return true; + } + return 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) + ) { + if (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)}, + scheduleReactRouterRevalidation + ); +} +`; diff --git a/src/dev-runtime-controller.ts b/src/dev-runtime-controller.ts index 1ead3f0e..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, @@ -55,6 +56,11 @@ type CreateControllerOptions = { api: RsbuildPluginAPI; isBuild: boolean; buildPlan: ReactRouterDevBuildPlan; + /** + * 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 +71,29 @@ const escapeHtml = (value: string): string => const CSS_SOURCE_RELOAD_DELAY_MS = 1000; +const isHdrRevisionFile = (file: string): boolean => + 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, buildPlan, + onNodeRebuildCommitted, }: CreateControllerOptions): ReactRouterDevRuntimeController => { if (isBuild) { return { @@ -132,6 +157,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 +177,22 @@ 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 && + hasHdrTriggeringChange(changes.node.files) ) { - pair.node.watching?.invalidate(); + hdrSignaledNodeIdentity.set(pair, identity.node); + onNodeRebuildCommitted?.(); } }) ), diff --git a/src/index.ts b/src/index.ts index d312804d..7144c910 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,14 @@ import { createReactRouterRouteWatchFiles, registerReactRouterDevBackgroundResources, } from './dev-background-resources.js'; +import { + createDevHdrRevisionSignal, + generateDevHmrRuntimeModule, + getDevHdrRevisionFilePath, + isRspackSwcReactRefreshEnabled, + 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 +584,26 @@ 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; + let devHmrEnabled = false; + const devRuntime = createReactRouterDevRuntimeController({ api, isBuild, buildPlan: serverBuildPlan, + onNodeRebuildCommitted: () => devHdrSignal?.bump(), }); let clientStats: ReactRouterManifestStats | undefined; @@ -703,6 +727,16 @@ export const pluginReactRouter = ( ...bundleVirtualModules, ...bundleManifestModules, 'virtual/react-router/with-props': generateWithProps(), + ...(devHmrRefreshRuntimePath + ? { + [DEV_HMR_RUNTIME_MODULE_ID]: generateDevHmrRuntimeModule({ + reactRefreshRuntimePath: devHmrRefreshRuntimePath, + hdrRevisionFilePath: getDevHdrRevisionFilePath( + api.context.rootPath + ), + }), + } + : {}), }) ); }; @@ -914,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) { @@ -986,6 +1032,7 @@ export const pluginReactRouter = ( ssr, isSpaMode, rootRoutePath, + isDevHmrEnabled: () => devHmrEnabled, }); }, }); 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/src/route-artifacts.ts b/src/route-artifacts.ts index 97483760..2c186dd8 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,111 @@ type RouteChunkArtifact = { map: null; }; +// 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', + 'hasClientLoader', + 'hasClientMiddleware', + 'hasErrorBoundary', + 'hasLoader', +] as const; + +const HMR_FLAG_EXPORT_NAME: Record< + (typeof HMR_PATCHABLE_ROUTE_FLAGS)[number], + 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 buildRouteHmrFlags = (exportNames: readonly string[]): number => { + const exports = new Set(exportNames); + let flags = 0; + HMR_PATCHABLE_ROUTE_FLAGS.forEach((flag, index) => { + if (exports.has(HMR_FLAG_EXPORT_NAME[flag])) flags |= 1 << index; + }); + return flags; +}; + +/** + * 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, + flags, +}: { + routeId: string; + target: string; + acceptTarget: string; + flags: number; +}): string => { + const targetJson = JSON.stringify(target); + const acceptTargetJson = JSON.stringify(acceptTarget); + return ` +import * as __rrm from ${targetJson}; +import { + registerReactRouterRouteExports as __rrr, + scheduleReactRouterRouteUpdate as __rru, +} from "virtual/react-router/hmr-runtime"; + +const __rrid = ${JSON.stringify(routeId)}; +const __rrf = ${flags}; +const __rrg = () => __rrm; +const __rru0 = () => { + __rrr(__rrid, __rrm); + __rru(__rrid, __rrf, __rrg); +}; + +__rrr(__rrid, __rrm); + +if (import.meta.webpackHot) { + 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`; +}; + 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 +170,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), + flags: buildRouteHmrFlags(exportNames), + }) + ); }; export const createRouteClientEntryArtifact = async ({ @@ -77,6 +192,8 @@ export const createRouteClientEntryArtifact = async ({ isBuild, routeChunkCache, routeChunkConfig, + routeId, + devHmr, }: RouteClientEntryArtifactOptions): Promise => { const isServer = environmentName === 'node'; const mightHaveRouteChunks = @@ -100,6 +217,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..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; @@ -41,6 +44,8 @@ export type RouteClientEntryTransformTask = BaseRouteTransformTask & { environmentName?: string; isBuild: boolean; routeChunkConfig: RouteChunkConfig; + routeId?: string; + devHmr?: boolean; }; export type RouteChunkTransformTask = BaseRouteTransformTask & { @@ -69,6 +74,7 @@ export type RouteModuleTransformTask = BaseRouteTransformTask & { isBuild: boolean; isSpaMode: boolean; rootRoutePath: string | null; + devHmr?: boolean; }; export type RouteTransformTask = @@ -151,6 +157,158 @@ const createClientOnlyStub = async ( }; }; +const isComponentishName = (name: string): boolean => /^[A-Z]/.test(name); + +/** + * 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: 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?: AnyNode[]; +}): 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, 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 +365,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 +398,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/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); 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..d6c48d66 --- /dev/null +++ b/tests/dev-hmr-fast-refresh-detection.test.ts @@ -0,0 +1,93 @@ +import { createRsbuild, type RsbuildConfig } from '@rsbuild/core'; +import { pluginReact } from '@rsbuild/plugin-react'; +import { describe, expect, it } from '@rstest/core'; +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 Rspack config the same +// way the plugin's `tools.rspack` hook does. +const detectRefresh = async ({ + swc, + plugins = [], +}: { + swc?: ToolsSwc; + plugins?: NonNullable; +}): Promise => { + let detected: boolean | undefined; + const rsbuild = await createRsbuild({ + rsbuildConfig: { + mode: 'development', + tools: { + ...(swc ? { swc } : {}), + rspack: config => { + detected = isRspackSwcReactRefreshEnabled(config); + return config; + }, + }, + plugins: [ + ...plugins, + ], + }, + }); + await rsbuild.initConfigs(); + if (detected === undefined) { + throw new Error('refresh probe did not run'); + } + return detected; +}; + +const refreshFragment = { + jsc: { transform: { react: { refresh: true } } }, +}; + +describe('isRspackSwcReactRefreshEnabled', () => { + 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/dev-hmr.test.ts b/tests/dev-hmr.test.ts new file mode 100644 index 00000000..ee319cf3 --- /dev/null +++ b/tests/dev-hmr.test.ts @@ -0,0 +1,58 @@ +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'); + expect(code).toContain('if (await refreshRouteState(router))'); + expect(code).toContain('await revalidateRouter(router)'); + }); +}); + +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")'); + }); + }); }); 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', () => {