From 0732964196d80fd727c0f629b0af5ad31843d2f1 Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Mon, 6 Jul 2026 19:33:18 +0100 Subject: [PATCH 1/2] feat: restructure react refresh support for rspack 2 DevelopmentPlugin now splits on the detected rspack major: - rspack >= 2 applies the official @rspack/plugin-react-refresh (v2) with injectEntry: false (Re.Pack controls entry placement per entrypoint), forceEnable: true, and Re.Pack's own react-refresh-loader. The package is ESM-only, so it is loaded lazily inside the rspack-2 branch and resolved from the project context with an actionable error when missing. - webpack and rspack 1 keep the existing manual wiring, now pointed at client runtime files vendored from @rspack/plugin-react-refresh@2.0.2 (MIT) into the package-root vendor/react-refresh/ directory, shipped as-is via package.json#files and excluded from biome. The @rspack/plugin-react-refresh@1.0.0 dependency is removed (its module-load-time deprecated_runtimePaths access crashes under the v2 plugin); the v2 plugin becomes an optional peer dependency instead, so rspack 2 users install it (plus react-refresh) in their project. --- .changeset/rspack-2-react-refresh.md | 8 + biome.jsonc | 13 +- packages/repack/package.json | 7 +- .../repack/src/plugins/DevelopmentPlugin.ts | 159 ++++++++-- packages/repack/vendor/react-refresh/LICENSE | 63 ++++ .../vendor/react-refresh/reactRefresh.js | 36 +++ .../vendor/react-refresh/reactRefreshEntry.js | 59 ++++ .../vendor/react-refresh/refreshUtils.js | 297 ++++++++++++++++++ pnpm-lock.yaml | 34 +- 9 files changed, 625 insertions(+), 51 deletions(-) create mode 100644 .changeset/rspack-2-react-refresh.md create mode 100644 packages/repack/vendor/react-refresh/LICENSE create mode 100644 packages/repack/vendor/react-refresh/reactRefresh.js create mode 100644 packages/repack/vendor/react-refresh/reactRefreshEntry.js create mode 100644 packages/repack/vendor/react-refresh/refreshUtils.js diff --git a/.changeset/rspack-2-react-refresh.md b/.changeset/rspack-2-react-refresh.md new file mode 100644 index 000000000..3954b7aec --- /dev/null +++ b/.changeset/rspack-2-react-refresh.md @@ -0,0 +1,8 @@ +--- +"@callstack/repack": patch +--- + +Restructure React Refresh support to work under both Rspack 1 and Rspack 2. + +- Under Rspack 2, Re.Pack applies the official `@rspack/plugin-react-refresh` v2 plugin. The plugin is now an **optional peer dependency** (`^2.0.0`) instead of a regular dependency - Rspack 2 users must install it (along with `react-refresh`) in their project. +- Under Rspack 1 and webpack, nothing changes: the React Refresh client runtime files are now bundled with Re.Pack (vendored from `@rspack/plugin-react-refresh@2.0.2`, MIT), so the `@rspack/plugin-react-refresh@1` dependency is no longer needed and has been removed. diff --git a/biome.jsonc b/biome.jsonc index 802f920b5..db9ccffb1 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -19,6 +19,7 @@ "bracketSpacing": true, "ignore": [ "pnpm-lock.yaml", + "packages/repack/vendor/**", "tests/metro-compat/**/__tests__/**", "website/src/2.x/**", "website/src/3.x/**", @@ -27,7 +28,11 @@ }, "organizeImports": { "enabled": true, - "ignore": ["templates/*", "tests/metro-compat/**/__tests__/**"] + "ignore": [ + "templates/*", + "packages/repack/vendor/**", + "tests/metro-compat/**/__tests__/**" + ] }, "linter": { "enabled": true, @@ -55,7 +60,11 @@ "noConfusingVoidType": "off" } }, - "ignore": ["templates/*", "tests/metro-compat/**/__tests__/**"] + "ignore": [ + "templates/*", + "packages/repack/vendor/**", + "tests/metro-compat/**/__tests__/**" + ] }, "javascript": { "formatter": { diff --git a/packages/repack/package.json b/packages/repack/package.json index af4cb615b..e7bc31762 100644 --- a/packages/repack/package.json +++ b/packages/repack/package.json @@ -26,6 +26,7 @@ "client", "commands", "mf", + "vendor", "callstack-repack.podspec", "src/modules/ScriptManager/NativeScriptManager.ts" ], @@ -66,6 +67,7 @@ "peerDependencies": { "@module-federation/enhanced": ">=0.6.10", "@rspack/core": ">=1", + "@rspack/plugin-react-refresh": "^2.0.0", "react-native": ">=0.74", "webpack": ">=5.90" }, @@ -76,6 +78,9 @@ "@rspack/core": { "optional": true }, + "@rspack/plugin-react-refresh": { + "optional": true + }, "webpack": { "optional": true } @@ -83,7 +88,6 @@ "dependencies": { "@callstack/repack-dev-server": "workspace:*", "@discoveryjs/json-ext": "^0.5.7", - "@rspack/plugin-react-refresh": "1.0.0", "babel-loader": "^9.2.1", "colorette": "^2.0.20", "dedent": "^0.7.0", @@ -116,6 +120,7 @@ "@module-federation/sdk": "0.6.10", "@rspack/core": "^2.1.2", "@rspack/core-v1": "npm:@rspack/core@^1.7.12", + "@rspack/plugin-react-refresh": "^2.0.2", "@swc/helpers": "^0.5.23", "@types/babel__core": "^7.20.5", "@types/dedent": "^0.7.0", diff --git a/packages/repack/src/plugins/DevelopmentPlugin.ts b/packages/repack/src/plugins/DevelopmentPlugin.ts index be0e25ced..55720dea4 100644 --- a/packages/repack/src/plugins/DevelopmentPlugin.ts +++ b/packages/repack/src/plugins/DevelopmentPlugin.ts @@ -5,14 +5,20 @@ import type { Plugins, Compiler as RspackCompiler, } from '@rspack/core'; -import ReactRefreshPlugin from '@rspack/plugin-react-refresh'; import type { Compiler as WebpackCompiler } from 'webpack'; -import { isRspackCompiler, moveElementBefore } from '../helpers/index.js'; - -const [reactRefreshEntryPath, reactRefreshPath, refreshUtilsPath] = - ReactRefreshPlugin.deprecated_runtimePaths; +import { + getRspackMajorVersionFromCompiler, + isRspackCompiler, + moveElementBefore, +} from '../helpers/index.js'; type PackageJSON = { version: string }; + +// matches the file conditions of Re.Pack's JS transform rules +// (includes .flow files, unlike the react-refresh plugin defaults) +const REACT_REFRESH_LOADER_TEST = /\.([cm]js|[jt]sx?|flow)$/i; +const REACT_REFRESH_LOADER_EXCLUDE = /node_modules/i; + /** * {@link DevelopmentPlugin} configuration options. */ @@ -87,6 +93,113 @@ export class DevelopmentPlugin { return 'http'; } + private resolveReactRefreshPluginRequest(context: string, request: string) { + try { + return require.resolve(request, { paths: [context] }); + } catch { + try { + // fallback to Re.Pack's own resolution paths + return require.resolve(request); + } catch { + const error = new Error( + '[RepackDevelopmentPlugin] ' + + "Dependency named '@rspack/plugin-react-refresh' (version 2.x) is required " + + 'when using React Refresh with Rspack 2 but is not found in your project. ' + + 'Did you forget to install it?' + ); + // remove the stack trace to make the error more readable + error.stack = undefined; + throw error; + } + } + } + + /** + * Sets up React Refresh using the official `@rspack/plugin-react-refresh` (v2) + * with its integrator options: entry injection is left to Re.Pack (to control + * placement per entrypoint) and the loader is swapped for Re.Pack's own + * React Native-aware react-refresh-loader. + * + * The plugin package is ESM-only, so it's loaded lazily inside this branch - + * it must never be evaluated on setups that don't support `require(esm)` + * (webpack or Rspack 1 users on Node 18). + * + * @returns Path to the react-refresh entry module. + */ + private setupOfficialReactRefresh(compiler: RspackCompiler): string { + const pluginPath = this.resolveReactRefreshPluginRequest( + compiler.context, + '@rspack/plugin-react-refresh' + ); + // Rspack 2 requires Node >= 20.19 (enforced by Re.Pack commands), + // where require() of an ESM module is supported + const { ReactRefreshRspackPlugin } = require(pluginPath); + + new ReactRefreshRspackPlugin({ + // entries are injected by Re.Pack per entrypoint to control their order + injectEntry: false, + // DevelopmentPlugin gates on devServer.hot already - don't let + // the plugin second-guess based on `mode` + forceEnable: true, + reactRefreshLoader: '@callstack/repack/react-refresh-loader', + test: REACT_REFRESH_LOADER_TEST, + exclude: REACT_REFRESH_LOADER_EXCLUDE, + }).apply(compiler); + + return this.resolveReactRefreshPluginRequest( + compiler.context, + '@rspack/plugin-react-refresh/react-refresh-entry' + ); + } + + /** + * Sets up React Refresh manually using the client runtime files vendored + * from `@rspack/plugin-react-refresh` (package-root `vendor/` directory, + * shipped as-is) - used for webpack and Rspack 1 compilers, where the + * official v2 plugin cannot be applied. + * + * @returns Path to the react-refresh entry module. + */ + private setupManualReactRefresh(compiler: RspackCompiler): string { + // resolves from both src/plugins and dist/plugins to the package root + const reactRefreshPath = require.resolve( + '../../vendor/react-refresh/reactRefresh.js' + ); + const refreshUtilsPath = require.resolve( + '../../vendor/react-refresh/refreshUtils.js' + ); + const reactRefreshEntryPath = require.resolve( + '../../vendor/react-refresh/reactRefreshEntry.js' + ); + + new compiler.webpack.ProvidePlugin({ + $ReactRefreshRuntime$: reactRefreshPath, + }).apply(compiler); + + new compiler.webpack.DefinePlugin({ + __react_refresh_library__: JSON.stringify( + compiler.webpack.Template.toIdentifier( + compiler.options.output.uniqueName || compiler.options.output.library + ) + ), + // full reload on unrecoverable runtime errors is a web-oriented + // behavior - in React Native errors are surfaced through LogBox + __reload_on_runtime_errors__: false, + }).apply(compiler); + + new compiler.webpack.ProvidePlugin({ + __react_refresh_utils__: refreshUtilsPath, + }).apply(compiler); + + compiler.options.module.rules.unshift({ + test: REACT_REFRESH_LOADER_TEST, + exclude: REACT_REFRESH_LOADER_EXCLUDE, + use: '@callstack/repack/react-refresh-loader', + }); + + return reactRefreshEntryPath; + } + apply(compiler: RspackCompiler): void; apply(compiler: WebpackCompiler): void; @@ -121,38 +234,22 @@ export class DevelopmentPlugin { // setup HMR new compiler.webpack.HotModuleReplacementPlugin().apply(compiler); - // setup React Refresh manually instead of using the official plugin - // to avoid issues with placement of reactRefreshEntry - new compiler.webpack.ProvidePlugin({ - $ReactRefreshRuntime$: reactRefreshPath, - }).apply(compiler); - - new compiler.webpack.DefinePlugin({ - __react_refresh_error_overlay__: false, - __react_refresh_socket__: false, - __react_refresh_library__: JSON.stringify( - compiler.webpack.Template.toIdentifier( - compiler.options.output.uniqueName || - compiler.options.output.library - ) - ), - }).apply(compiler); - - new compiler.webpack.ProvidePlugin({ - __react_refresh_utils__: refreshUtilsPath, - }).apply(compiler); - + // alias react-refresh to Re.Pack's own copy to keep a single, + // version-controlled instance of the refresh runtime const refreshPath = path.dirname(require.resolve('react-refresh')); compiler.options.resolve.alias = { 'react-refresh': refreshPath, ...compiler.options.resolve.alias, }; - compiler.options.module.rules.unshift({ - include: /\.([cm]js|[jt]sx?|flow)$/i, - exclude: /node_modules/i, - use: '@callstack/repack/react-refresh-loader', - }); + // Rspack 2 gets the official react-refresh plugin (driven through + // its integrator options), webpack & Rspack 1 get manual wiring + // based on the vendored client runtime files + const rspackMajor = getRspackMajorVersionFromCompiler(compiler); + const reactRefreshEntryPath = + rspackMajor !== null && rspackMajor >= 2 + ? this.setupOfficialReactRefresh(compiler) + : this.setupManualReactRefresh(compiler); const devEntries = [ reactRefreshEntryPath, diff --git a/packages/repack/vendor/react-refresh/LICENSE b/packages/repack/vendor/react-refresh/LICENSE new file mode 100644 index 000000000..995a71fe4 --- /dev/null +++ b/packages/repack/vendor/react-refresh/LICENSE @@ -0,0 +1,63 @@ +These files are vendored from @rspack/plugin-react-refresh@2.0.2 +(https://github.com/rspack-contrib/rspack-plugin-react-refresh, the `client/` +runtime files), which is itself based on @pmmmwh/react-refresh-webpack-plugin +(https://github.com/pmmmwh/react-refresh-webpack-plugin). + +They are used by the manual React Refresh wiring in Re.Pack's +DevelopmentPlugin for webpack and Rspack 1 compilers - under Rspack 2 the +official @rspack/plugin-react-refresh plugin provides these files instead. + +Local changes relative to upstream v2.0.2 (re-derive on future bumps): +- provenance headers added to each file +- formatting aligned with this repository (no functional edits) +- note: upstream's `__reload_on_runtime_errors__` global is defined as + `false` by DevelopmentPlugin (full reload on unrecoverable runtime errors + is a web-oriented behavior; React Native surfaces errors through LogBox) + +-------------------------------------------------------------------------- + +MIT License + +Copyright (c) 2022-present Bytedance, Inc. and its affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +-------------------------------------------------------------------------- + +MIT License (@pmmmwh/react-refresh-webpack-plugin) + +Copyright (c) 2019 Michael Mok + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/repack/vendor/react-refresh/reactRefresh.js b/packages/repack/vendor/react-refresh/reactRefresh.js new file mode 100644 index 000000000..7c9c5706d --- /dev/null +++ b/packages/repack/vendor/react-refresh/reactRefresh.js @@ -0,0 +1,36 @@ +/** + * Vendored from @rspack/plugin-react-refresh@2.0.2 (client runtime files), + * which is itself based on @pmmmwh/react-refresh-webpack-plugin. + * + * Used by the manual React Refresh wiring in DevelopmentPlugin for + * webpack and Rspack 1 compilers - under Rspack 2 the official + * @rspack/plugin-react-refresh plugin provides these files instead. + * + * MIT Licensed + * Copyright (c) 2019 Michael Mok (react-refresh-webpack-plugin) + * Copyright (c) 2022-present Bytedance, Inc. and its affiliates (rspack-plugin-react-refresh) + */ + +import { + createSignatureFunctionForTransform, + register, +} from 'react-refresh/runtime'; +import { executeRuntime, getModuleExports } from './refreshUtils.js'; + +function refresh(moduleId, hot) { + const currentExports = getModuleExports(moduleId); + const runRefresh = (moduleExports) => { + const testMode = + typeof __react_refresh_test__ !== 'undefined' + ? __react_refresh_test__ + : undefined; + executeRuntime(moduleExports, moduleId, hot, testMode); + }; + if (typeof Promise !== 'undefined' && currentExports instanceof Promise) { + currentExports.then(runRefresh); + } else { + runRefresh(currentExports); + } +} + +export { createSignatureFunctionForTransform, refresh, register }; diff --git a/packages/repack/vendor/react-refresh/reactRefreshEntry.js b/packages/repack/vendor/react-refresh/reactRefreshEntry.js new file mode 100644 index 000000000..3fabb7a3f --- /dev/null +++ b/packages/repack/vendor/react-refresh/reactRefreshEntry.js @@ -0,0 +1,59 @@ +/** + * Vendored from @rspack/plugin-react-refresh@2.0.2 (client runtime files), + * which is itself based on @pmmmwh/react-refresh-webpack-plugin. + * + * Used by the manual React Refresh wiring in DevelopmentPlugin for + * webpack and Rspack 1 compilers - under Rspack 2 the official + * @rspack/plugin-react-refresh plugin provides these files instead. + * + * MIT Licensed + * Copyright (c) 2019 Michael Mok (react-refresh-webpack-plugin) + * Copyright (c) 2022-present Bytedance, Inc. and its affiliates (rspack-plugin-react-refresh) + */ + +import { injectIntoGlobalHook } from 'react-refresh/runtime'; + +const safeThis = (() => { + const check = (it) => it && it.Math === Math && it; + + // https://github.com/zloirock/core-js/issues/86#issuecomment-115759028 + // eslint-disable-next-line es/no-global-this -- safe + return ( + check(typeof globalThis === 'object' && globalThis) || + check(typeof window === 'object' && window) || + // eslint-disable-next-line no-restricted-globals -- safe + check(typeof self === 'object' && self) || + check(typeof __webpack_require__.g === 'object' && __webpack_require__.g) || + // eslint-disable-next-line no-new-func -- fallback + (function () { + return this; + })() || + this || + Function('return this')() + ); +})(); + +if (process.env.NODE_ENV !== 'production') { + if (typeof safeThis !== 'undefined') { + let $RefreshInjected$ = '__reactRefreshInjected'; + // Namespace the injected flag (if necessary) for monorepo compatibility + if ( + typeof __react_refresh_library__ !== 'undefined' && + __react_refresh_library__ + ) { + $RefreshInjected$ += `_${__react_refresh_library__}`; + } + + // Only inject the runtime if it hasn't been injected + if (!safeThis[$RefreshInjected$]) { + injectIntoGlobalHook(safeThis); + + // Empty implementation to avoid "ReferenceError: variable is not defined" in module which didn't pass builtin:react-refresh-loader + safeThis.$RefreshSig$ = () => (type) => type; + safeThis.$RefreshReg$ = () => {}; + + // Mark the runtime as injected to prevent double-injection + safeThis[$RefreshInjected$] = true; + } + } +} diff --git a/packages/repack/vendor/react-refresh/refreshUtils.js b/packages/repack/vendor/react-refresh/refreshUtils.js new file mode 100644 index 000000000..acf1c0b34 --- /dev/null +++ b/packages/repack/vendor/react-refresh/refreshUtils.js @@ -0,0 +1,297 @@ +/** + * Vendored from @rspack/plugin-react-refresh@2.0.2 (client runtime files), + * which is itself based on @pmmmwh/react-refresh-webpack-plugin. + * + * Used by the manual React Refresh wiring in DevelopmentPlugin for + * webpack and Rspack 1 compilers - under Rspack 2 the official + * @rspack/plugin-react-refresh plugin provides these files instead. + * + * MIT Licensed + * Copyright (c) 2019 Michael Mok (react-refresh-webpack-plugin) + * Copyright (c) 2022-present Bytedance, Inc. and its affiliates (rspack-plugin-react-refresh) + */ + +/* global __webpack_require__ */ +import { + getFamilyByType, + isLikelyComponentType, + performReactRefresh, + register, +} from 'react-refresh/runtime'; + +/** + * Extracts exports from a Rspack module object. + * @param {string} moduleId An Rspack module ID. + * @returns {*} An exports object from the module. + */ +function getModuleExports(moduleId) { + if (typeof moduleId === 'undefined') { + // `moduleId` is unavailable, which indicates that this module is not in the cache, + // which means we won't be able to capture any exports, + // and thus they cannot be refreshed safely. + // These are likely runtime or dynamically generated modules. + return {}; + } + + const maybeModule = __webpack_require__.c[moduleId]; + if (typeof maybeModule === 'undefined') { + // `moduleId` is available but the module in cache is unavailable, + // which indicates the module is somehow corrupted (e.g. broken Rspack `module` globals). + // We will warn the user (as this is likely a mistake) and assume they cannot be refreshed. + console.warn( + `[React Refresh] Failed to get exports for module: ${moduleId}.` + ); + return {}; + } + + const exportsOrPromise = maybeModule.exports; + if (typeof Promise !== 'undefined' && exportsOrPromise instanceof Promise) { + return exportsOrPromise.then((moduleExports) => moduleExports); + } + + return exportsOrPromise; +} + +/** + * Calculates the signature of a React refresh boundary. + * If this signature changes, it's unsafe to accept the boundary. + * + * This implementation is based on the one in [Metro](https://github.com/facebook/metro/blob/907d6af22ac6ebe58572be418e9253a90665ecbd/packages/metro/src/lib/polyfills/require.js#L795-L816). + * @param {*} moduleExports An Rspack module exports object. + * @returns {string[]} A React refresh boundary signature array. + */ +function getReactRefreshBoundarySignature(moduleExports) { + const signature = [getFamilyByType(moduleExports)]; + + if (moduleExports == null || typeof moduleExports !== 'object') { + // Exit if we can't iterate over exports. + return signature; + } + + for (const key in moduleExports) { + if (key === '__esModule') { + continue; + } + + signature.push(key); + signature.push(getFamilyByType(moduleExports[key])); + } + + return signature; +} + +/** + * Creates a helper that performs a delayed React refresh. + * @returns {function(function(): void): void} A debounced React refresh function. + */ +function createDebounceUpdate() { + /** + * A cached setTimeout handler. + * @type {number | undefined} + */ + let refreshTimeout; + + /** + * Performs React Refresh on a delay. + * @param {function(): void} [callback] + * @returns {void} + */ + const enqueueUpdate = (callback) => { + if (typeof refreshTimeout !== 'undefined') { + return; + } + + refreshTimeout = setTimeout(() => { + refreshTimeout = undefined; + performReactRefresh(); + if (callback) { + callback(); + } + }, 30); + }; + + return enqueueUpdate; +} + +/** + * Checks if all exports are likely a React component. + * + * This implementation is based on the one in [Metro](https://github.com/facebook/metro/blob/febdba2383113c88296c61e28e4ef6a7f4939fda/packages/metro/src/lib/polyfills/require.js#L748-L774). + * @param {*} moduleExports An Rspack module exports object. + * @returns {boolean} Whether the exports are React component like. + */ +function isReactRefreshBoundary(moduleExports) { + if (isLikelyComponentType(moduleExports)) { + return true; + } + if ( + moduleExports === undefined || + moduleExports === null || + typeof moduleExports !== 'object' + ) { + // Exit if we can't iterate over exports. + return false; + } + + let hasExports = false; + let areAllExportsComponents = true; + for (const key in moduleExports) { + hasExports = true; + + // This is the ES Module indicator flag + if (key === '__esModule') { + continue; + } + + // We can (and have to) safely execute getters here, + // as Rspack/webpack manually assigns ESM exports to getters, + // without any side-effects attached. + // Ref: https://github.com/webpack/webpack/blob/b93048643fe74de2a6931755911da1212df55897/lib/MainTemplate.js#L281 + const exportValue = moduleExports[key]; + if (!isLikelyComponentType(exportValue)) { + areAllExportsComponents = false; + } + } + + return hasExports && areAllExportsComponents; +} + +/** + * Checks if exports are likely a React component and registers them. + * + * This implementation is based on the one in [Metro](https://github.com/facebook/metro/blob/febdba2383113c88296c61e28e4ef6a7f4939fda/packages/metro/src/lib/polyfills/require.js#L818-L835). + * @param {*} moduleExports An Rspack module exports object. + * @param {string} moduleId An Rspack module ID. + * @returns {void} + */ +function registerExportsForReactRefresh(moduleExports, moduleId) { + if (isLikelyComponentType(moduleExports)) { + // Register module.exports if it is likely a component + register(moduleExports, `${moduleId} %exports%`); + } + + if ( + moduleExports === undefined || + moduleExports === null || + typeof moduleExports !== 'object' + ) { + // Exit if we can't iterate over the exports. + return; + } + + for (const key in moduleExports) { + // Skip registering the ES Module indicator + if (key === '__esModule') { + continue; + } + + const exportValue = moduleExports[key]; + if (isLikelyComponentType(exportValue)) { + const typeID = `${moduleId} %exports% ${key}`; + register(exportValue, typeID); + } + } +} + +/** + * Compares previous and next module objects to check for mutated boundaries. + * + * This implementation is based on the one in [Metro](https://github.com/facebook/metro/blob/907d6af22ac6ebe58572be418e9253a90665ecbd/packages/metro/src/lib/polyfills/require.js#L776-L792). + * @param {*} prevExports The current Rspack module exports object. + * @param {*} nextExports The next Rspack module exports object. + * @returns {boolean} Whether the React refresh boundary should be invalidated. + */ +function shouldInvalidateReactRefreshBoundary(prevExports, nextExports) { + const prevSignature = getReactRefreshBoundarySignature(prevExports); + const nextSignature = getReactRefreshBoundarySignature(nextExports); + + return ( + prevSignature.length !== nextSignature.length || + nextSignature.some( + (signatureItem, index) => prevSignature[index] !== signatureItem + ) + ); +} + +const enqueueUpdate = createDebounceUpdate(); + +function executeRuntime(moduleExports, moduleId, hot, isTest) { + registerExportsForReactRefresh(moduleExports, moduleId); + + if (hot) { + const isHotUpdate = Boolean(hot.data); + let prevExports; + if (isHotUpdate) { + prevExports = hot.data.prevExports; + } + + if (isReactRefreshBoundary(moduleExports)) { + /** + * A callback to perform a full refresh if React has unrecoverable errors, + * and also caches the to-be-disposed module. + * @param {*} data A hot module data object from Rspack HMR. + * @returns {void} + */ + const hotDisposeCallback = (data) => { + // We have to mutate the data object to get data registered and cached + data.prevExports = moduleExports; + }; + /** + * An error handler to allow self-recovering behaviors. + * @param {Error} error An error occurred during evaluation of a module. + * @returns {void} + */ + const hotErrorHandler = (error) => { + console.error(error); + if ( + __reload_on_runtime_errors__ && + isUnrecoverableRuntimeError(error) + ) { + location.reload(); + return; + } + + if ( + typeof isTest !== 'undefined' && + isTest && + window.onHotAcceptError + ) { + window.onHotAcceptError(error.message); + } + + __webpack_require__.c[moduleId].hot.accept(hotErrorHandler); + }; + + hot.dispose(hotDisposeCallback); + hot.accept(hotErrorHandler); + + if (isHotUpdate) { + if ( + isReactRefreshBoundary(prevExports) && + shouldInvalidateReactRefreshBoundary(prevExports, moduleExports) + ) { + hot.invalidate(); + } else { + enqueueUpdate(); + } + } + } else { + if (isHotUpdate && typeof prevExports !== 'undefined') { + hot.invalidate(); + } + } + } +} + +function isUnrecoverableRuntimeError(error) { + return error.message.startsWith('RuntimeError: factory is undefined'); +} + +export { + enqueueUpdate, + executeRuntime, + getModuleExports, + isReactRefreshBoundary, + registerExportsForReactRefresh, + shouldInvalidateReactRefreshBoundary, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fa9a1a9d..665b4884f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -583,9 +583,6 @@ importers: '@discoveryjs/json-ext': specifier: ^0.5.7 version: 0.5.7 - '@rspack/plugin-react-refresh': - specifier: 1.0.0 - version: 1.0.0(react-refresh@0.14.2) babel-loader: specifier: ^9.2.1 version: 9.2.1(@babel/core@7.25.2)(webpack@5.105.3) @@ -677,6 +674,9 @@ importers: '@rspack/core-v1': specifier: npm:@rspack/core@^1.7.12 version: '@rspack/core@1.7.12(@swc/helpers@0.5.23)' + '@rspack/plugin-react-refresh': + specifier: ^2.0.2 + version: 2.0.2(@rspack/core@2.1.2(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.23))(react-refresh@0.14.2) '@swc/helpers': specifier: ^0.5.23 version: 0.5.23 @@ -3805,21 +3805,22 @@ packages: '@rspack/lite-tapable@1.1.0': resolution: {integrity: sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==} - '@rspack/plugin-react-refresh@1.0.0': - resolution: {integrity: sha512-WvXkLewW5G0Mlo5H1b251yDh5FFiH4NDAbYlFpvFjcuXX2AchZRf9zdw57BDE/ADyWsJgA8kixN/zZWBTN3iYA==} + '@rspack/plugin-react-refresh@1.6.0': + resolution: {integrity: sha512-OO53gkrte/Ty4iRXxxM6lkwPGxsSsupFKdrPFnjwFIYrPvFLjeolAl5cTx+FzO5hYygJiGnw7iEKTmD+ptxqDA==} peerDependencies: react-refresh: '>=0.10.0 <1.0.0' + webpack-hot-middleware: 2.x peerDependenciesMeta: - react-refresh: + webpack-hot-middleware: optional: true - '@rspack/plugin-react-refresh@1.6.0': - resolution: {integrity: sha512-OO53gkrte/Ty4iRXxxM6lkwPGxsSsupFKdrPFnjwFIYrPvFLjeolAl5cTx+FzO5hYygJiGnw7iEKTmD+ptxqDA==} + '@rspack/plugin-react-refresh@2.0.2': + resolution: {integrity: sha512-dGNZiCxQxgAUI9sah7gd8u+O7OJZRCmqtEJNDOd8xW5RqcieC86F7p5qcShyw6onH5pKf57evpr2VjGbaFGkZg==} peerDependencies: + '@rspack/core': ^2.0.0 react-refresh: '>=0.10.0 <1.0.0' - webpack-hot-middleware: 2.x peerDependenciesMeta: - webpack-hot-middleware: + '@rspack/core': optional: true '@rspress/core@2.0.0': @@ -12647,19 +12648,18 @@ snapshots: '@rspack/lite-tapable@1.1.0': {} - '@rspack/plugin-react-refresh@1.0.0(react-refresh@0.14.2)': - dependencies: - error-stack-parser: 2.1.4 - html-entities: 2.6.0 - optionalDependencies: - react-refresh: 0.14.2 - '@rspack/plugin-react-refresh@1.6.0(react-refresh@0.18.0)': dependencies: error-stack-parser: 2.1.4 html-entities: 2.6.0 react-refresh: 0.18.0 + '@rspack/plugin-react-refresh@2.0.2(@rspack/core@2.1.2(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.23))(react-refresh@0.14.2)': + dependencies: + react-refresh: 0.14.2 + optionalDependencies: + '@rspack/core': 2.1.2(@module-federation/runtime-tools@2.1.0)(@swc/helpers@0.5.23) + '@rspress/core@2.0.0(@module-federation/runtime-tools@2.1.0)(@types/react@18.3.3)(core-js@3.41.0)': dependencies: '@mdx-js/mdx': 3.1.1 From 05b9027f250667bcacf4816ee57ed9fe26e67a89 Mon Sep 17 00:00:00 2001 From: Daniel Williams Date: Mon, 6 Jul 2026 20:22:52 +0100 Subject: [PATCH 2/2] fix: exclude vendored refresh runtime files from the manual refresh loader rule The manual React Refresh rule only excluded node_modules, so in workspace (symlinked) installs the vendored runtime files under packages/repack/vendor/react-refresh matched the rule and were processed by the react-refresh loader themselves. Mirror the upstream plugin's self-exclusion by also excluding the three vendored file paths. The official plugin path (Rspack 2) already appends these exclusions internally. --- packages/repack/src/plugins/DevelopmentPlugin.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/repack/src/plugins/DevelopmentPlugin.ts b/packages/repack/src/plugins/DevelopmentPlugin.ts index 55720dea4..51bf1ee6f 100644 --- a/packages/repack/src/plugins/DevelopmentPlugin.ts +++ b/packages/repack/src/plugins/DevelopmentPlugin.ts @@ -193,7 +193,14 @@ export class DevelopmentPlugin { compiler.options.module.rules.unshift({ test: REACT_REFRESH_LOADER_TEST, - exclude: REACT_REFRESH_LOADER_EXCLUDE, + // like upstream, exclude the refresh runtime files themselves - they + // live outside of node_modules in workspace (symlinked) installs + exclude: [ + REACT_REFRESH_LOADER_EXCLUDE, + reactRefreshPath, + refreshUtilsPath, + reactRefreshEntryPath, + ], use: '@callstack/repack/react-refresh-loader', });