From 4d29fd67cf2ce6c7fd4f86d8b0991e5b3fd8bf74 Mon Sep 17 00:00:00 2001 From: Sergey Nikitin Date: Wed, 7 Jan 2026 00:16:26 +0100 Subject: [PATCH] feat: allow to override the default react-refresh-loader Thank you for working on the React implementation for Rspack, we are excited to use it in our company stack. Yet, we have faced a challenge regarding the React Fast Refresh instrumentation. The problem is that, along with React jsx, we use a custom in-house rendering runtime with `/** @jsxImportSource custom-jsx */` pragma. Unfortunately, while Fast Refresh does work for the React components, for such custom runtime components the HMR is completely broken. As a workaround, engineers disable RFR and fall back to HMR. I want to improve the experience for the product teams and found out that I can write my own react-refresh loader, which is 99% the same as `builtin:react-refresh-loader` but can handle the case with a custom jsx runtime. This seems to work just fine. And now we are missing a way to override the default builtin:react-refresh-loader. Hence, this PR adds a plugin option that allows specifying a custom react-refresh loader for those who want to have a more advanced RFR implementation --- README.md | 15 ++ src/index.ts | 4 +- src/options.ts | 6 + test/exports.spec.mts | 11 +- test/exports.spec.ts | 3 +- test/fixtures/loader/index.js | 1 + test/fixtures/loader/loader.js | 8 + test/test.spec.ts | 395 ++++++++++++++++++++------------- 8 files changed, 278 insertions(+), 165 deletions(-) create mode 100644 test/fixtures/loader/index.js create mode 100644 test/fixtures/loader/loader.js diff --git a/README.md b/README.md index efd97b1..86f631d 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,21 @@ new ReactRefreshPlugin({ }); ``` +### reactRefreshLoader + +- Type: `string` +- Default: `builtin:react-refresh-loader` + +Be default, the plugin uses `builtin:react-refresh-loader` loader implementation [from Rspack](https://github.com/web-infra-dev/rspack/tree/main/crates/rspack_loader_react_refresh) in order ot inject +the React Refresh utilities into each module. `reactRefreshLoader` option allows to specify the loader, that implements +custom React Refresh instrumentation if needed. + +```js +new ReactRefreshPlugin({ + reactRefreshLoader: 'my-advanced-react-refresh-loader', +}); +``` + ## Credits Thanks to the [react-refresh-webpack-plugin](https://github.com/pmmmwh/react-refresh-webpack-plugin) created by [@pmmmwh](https://github.com/pmmmwh), which inspires implement this plugin. diff --git a/src/index.ts b/src/index.ts index 1f371ae..3f4045e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,8 +42,6 @@ class ReactRefreshRspackPlugin { return getRefreshRuntimePaths(); } - static loader = 'builtin:react-refresh-loader'; - constructor(options: PluginOptions = {}) { this.options = normalizeOptions(options); } @@ -104,7 +102,7 @@ class ReactRefreshRspackPlugin { // React Refresh should not be injected for asset modules as they are static resources not: ['url'], }, - use: ReactRefreshRspackPlugin.loader, + use: this.options.reactRefreshLoader, }); } diff --git a/src/options.ts b/src/options.ts index 00889e7..003b0ae 100644 --- a/src/options.ts +++ b/src/options.ts @@ -85,6 +85,11 @@ export type PluginOptions = { * @default false */ reloadOnRuntimeErrors?: boolean; + /** + * Allows to specify custom react-refresh loader + * @default "builtin:react-refresh-loader" + */ + reactRefreshLoader?: string; }; export interface NormalizedPluginOptions extends Required { @@ -135,6 +140,7 @@ export function normalizeOptions( d(options, 'injectLoader', true); d(options, 'injectEntry', true); d(options, 'reloadOnRuntimeErrors', false); + d(options, 'reactRefreshLoader', 'builtin:react-refresh-loader'); options.overlay = normalizeOverlay(options.overlay); return options as NormalizedPluginOptions; } diff --git a/test/exports.spec.mts b/test/exports.spec.mts index cadd01e..384cb14 100644 --- a/test/exports.spec.mts +++ b/test/exports.spec.mts @@ -1,6 +1,15 @@ import DefaultExport, { ReactRefreshRspackPlugin } from '../exports/index.mjs'; test('should allow to import from the package', () => { - expect(DefaultExport.loader).toBeTruthy(); + const instance = new ReactRefreshRspackPlugin(); + expect(instance.options.reactRefreshLoader).toBeTruthy(); +}); + +test('should allow the default import from the package', () => { + const instance = new DefaultExport(); + expect(instance.options.reactRefreshLoader).toBeTruthy(); +}); + +test('default import picks the same plugin class', () => { expect(DefaultExport).toEqual(ReactRefreshRspackPlugin); }); diff --git a/test/exports.spec.ts b/test/exports.spec.ts index 9ce7907..0b12a90 100644 --- a/test/exports.spec.ts +++ b/test/exports.spec.ts @@ -1,5 +1,6 @@ const DefaultExport = require('../exports/index.cjs'); test('should allow to require from the package', () => { - expect(DefaultExport.loader).toBeTruthy(); + const instance = new DefaultExport(); + expect(instance.options.reactRefreshLoader).toBeTruthy(); }); diff --git a/test/fixtures/loader/index.js b/test/fixtures/loader/index.js new file mode 100644 index 0000000..6d266ef --- /dev/null +++ b/test/fixtures/loader/index.js @@ -0,0 +1 @@ +require('foo'); diff --git a/test/fixtures/loader/loader.js b/test/fixtures/loader/loader.js new file mode 100644 index 0000000..ad5bb6b --- /dev/null +++ b/test/fixtures/loader/loader.js @@ -0,0 +1,8 @@ +module.exports = function customLoader(source, sourceMap) { + const callback = this.async(); + + const injected = `/** TEST_LOADER */`; + const result = `${source}\n${injected}`; + + callback(null, result, sourceMap); +}; diff --git a/test/test.spec.ts b/test/test.spec.ts index 89df179..c862ef7 100644 --- a/test/test.spec.ts +++ b/test/test.spec.ts @@ -17,6 +17,7 @@ type CompilationCallback = ( error: Error | null, stats: Stats | undefined, outputs: Outputs, + plugin: ReactRefreshPlugin, ) => void; const uniqueName = 'ReactRefreshLibrary'; @@ -26,10 +27,12 @@ const compileWithReactRefresh = ( refreshOptions: PluginOptions, callback: CompilationCallback, ) => { - let dist = path.join(fixturePath, 'dist'); - let cjsEntry = path.join(fixturePath, 'index.js'); - let mjsEntry = path.join(fixturePath, 'index.mjs'); - let entry = fs.existsSync(cjsEntry) ? cjsEntry : mjsEntry; + const dist = path.join(fixturePath, 'dist'); + const cjsEntry = path.join(fixturePath, 'index.js'); + const mjsEntry = path.join(fixturePath, 'index.mjs'); + const customLoader = path.join(fixturePath, 'loader.js'); + const entry = fs.existsSync(cjsEntry) ? cjsEntry : mjsEntry; + const plugin = new ReactRefreshPlugin(refreshOptions); rspack( { mode: 'development', @@ -42,7 +45,12 @@ const compileWithReactRefresh = ( uniqueName, assetModuleFilename: '[name][ext]', }, - plugins: [new ReactRefreshPlugin(refreshOptions)], + resolveLoader: { + alias: { + 'custom-react-refresh-loader': customLoader, + }, + }, + plugins: [plugin], optimization: { runtimeChunk: { name: 'runtime', @@ -72,188 +80,255 @@ const compileWithReactRefresh = ( const statsJson = stats.toJson({ all: true }); expect(statsJson.errors).toHaveLength(0); expect(statsJson.warnings).toHaveLength(0); - callback(error, stats, { - reactRefresh: fs.readFileSync( - path.join(fixturePath, 'dist', 'react-refresh.js'), - 'utf-8', - ), - fixture: fs.readFileSync( - path.join(fixturePath, 'dist', 'fixture.js'), - 'utf-8', - ), - runtime: fs.readFileSync( - path.join(fixturePath, 'dist', 'runtime.js'), - 'utf-8', - ), - vendor: fs.readFileSync( - path.join(fixturePath, 'dist', 'vendor.js'), - 'utf-8', - ), - }); + callback( + error, + stats, + { + reactRefresh: fs.readFileSync( + path.join(fixturePath, 'dist', 'react-refresh.js'), + 'utf-8', + ), + fixture: fs.readFileSync( + path.join(fixturePath, 'dist', 'fixture.js'), + 'utf-8', + ), + runtime: fs.readFileSync( + path.join(fixturePath, 'dist', 'runtime.js'), + 'utf-8', + ), + vendor: fs.readFileSync( + path.join(fixturePath, 'dist', 'vendor.js'), + 'utf-8', + ), + }, + plugin, + ); }, ); }; describe('react-refresh-rspack-plugin', () => { - it('should exclude node_modules when compiling with default options', (done) => { - compileWithReactRefresh( - path.join(__dirname, 'fixtures/default'), - {}, - (_, __, { reactRefresh, fixture, runtime, vendor }) => { - expect(vendor).not.toContain('function $RefreshReg$'); - done(); - }, - ); + it('should exclude node_modules when compiling with default options', () => { + return new Promise((done) => { + compileWithReactRefresh( + path.join(__dirname, 'fixtures/default'), + {}, + (_, __, { vendor }) => { + expect(vendor).not.toContain('function $RefreshReg$'); + done(); + }, + ); + }); }); - it('should include non node_modules when compiling with default options', (done) => { - compileWithReactRefresh( - path.join(__dirname, 'fixtures/default'), - {}, - (_, __, { fixture }) => { - expect(fixture).toContain('function $RefreshReg$'); - done(); - }, - ); + it('should include non node_modules when compiling with default options', () => { + return new Promise((done) => { + compileWithReactRefresh( + path.join(__dirname, 'fixtures/default'), + {}, + (_, __, { fixture }) => { + expect(fixture).toContain('function $RefreshReg$'); + done(); + }, + ); + }); }); - it('should add library to make sure work in Micro-Frontend', (done) => { - compileWithReactRefresh( - path.join(__dirname, 'fixtures/default'), - {}, - (_, __, { reactRefresh }) => { - expect(reactRefresh).toContain(uniqueName); - done(); - }, - ); + it('should add library to make sure work in Micro-Frontend', () => { + return new Promise((done) => { + compileWithReactRefresh( + path.join(__dirname, 'fixtures/default'), + {}, + (_, __, { reactRefresh }) => { + expect(reactRefresh).toContain(uniqueName); + done(); + }, + ); + }); }); - it('should test selected file when compiling', (done) => { - compileWithReactRefresh( - path.join(__dirname, 'fixtures/custom'), - { - exclude: null, - test: path.join(__dirname, 'fixtures/node_modules/foo'), - include: null, - }, - (_, __, { vendor }) => { - expect(vendor).toContain('function $RefreshReg$'); - done(); - }, - ); + it('should test selected file when compiling', () => { + return new Promise((done) => { + compileWithReactRefresh( + path.join(__dirname, 'fixtures/custom'), + { + exclude: null, + test: path.join(__dirname, 'fixtures/node_modules/foo'), + include: null, + }, + (_, __, { vendor }) => { + expect(vendor).toContain('function $RefreshReg$'); + done(); + }, + ); + }); }); - it('should include selected file when compiling', (done) => { - compileWithReactRefresh( - path.join(__dirname, 'fixtures/custom'), - { - exclude: null, - include: path.join(__dirname, 'fixtures/node_modules/foo'), - }, - (_, __, { vendor }) => { - expect(vendor).toContain('function $RefreshReg$'); - done(); - }, - ); + it('should include selected file when compiling', () => { + return new Promise((done) => { + compileWithReactRefresh( + path.join(__dirname, 'fixtures/custom'), + { + exclude: null, + include: path.join(__dirname, 'fixtures/node_modules/foo'), + }, + (_, __, { vendor }) => { + expect(vendor).toContain('function $RefreshReg$'); + done(); + }, + ); + }); }); - it('should exclude selected file when compiling', (done) => { - compileWithReactRefresh( - path.join(__dirname, 'fixtures/custom'), - { - exclude: path.join(__dirname, 'fixtures/custom/index.js'), - }, - (_, __, { fixture }) => { - expect(fixture).not.toContain('function $RefreshReg$'); - done(); - }, - ); + it('should exclude selected file when compiling', () => { + return new Promise((done) => { + compileWithReactRefresh( + path.join(__dirname, 'fixtures/custom'), + { + exclude: path.join(__dirname, 'fixtures/custom/index.js'), + }, + (_, __, { fixture }) => { + expect(fixture).not.toContain('function $RefreshReg$'); + done(); + }, + ); + }); }); - it('should exclude selected file via `resourceQuery` when compiling', (done) => { - compileWithReactRefresh( - path.join(__dirname, 'fixtures/query'), - { - resourceQuery: { not: /raw/ }, - }, - (_, __, { vendor }) => { - expect(vendor).not.toContain('function $RefreshReg$'); - done(); - }, - ); + it('should exclude selected file via `resourceQuery` when compiling', () => { + return new Promise((done) => { + compileWithReactRefresh( + path.join(__dirname, 'fixtures/query'), + { + resourceQuery: { not: /raw/ }, + }, + (_, __, { vendor }) => { + expect(vendor).not.toContain('function $RefreshReg$'); + done(); + }, + ); + }); }); - it('should exclude url dependency when compiling', (done) => { - compileWithReactRefresh( - path.join(__dirname, 'fixtures/url'), - {}, - (_, stats) => { - const json = stats!.toJson({ all: false, outputPath: true }); - const asset = fs.readFileSync( - path.resolve(json.outputPath!, 'sdk.js'), - 'utf-8', - ); - expect(asset).not.toContain('function $RefreshReg$'); - done(); - }, - ); + it('should exclude url dependency when compiling', () => { + return new Promise((done) => { + compileWithReactRefresh( + path.join(__dirname, 'fixtures/url'), + {}, + (_, stats) => { + const json = stats!.toJson({ all: false, outputPath: true }); + const asset = fs.readFileSync( + path.resolve(json.outputPath!, 'sdk.js'), + 'utf-8', + ); + expect(asset).not.toContain('function $RefreshReg$'); + done(); + }, + ); + }); }); - it('should allow custom inject loader when compiling', (done) => { - expect(ReactRefreshPlugin.loader).toBe('builtin:react-refresh-loader'); - compileWithReactRefresh( - path.join(__dirname, 'fixtures/custom'), - { - injectLoader: false, - }, - (_, __, { reactRefresh, fixture, runtime, vendor }) => { - expect(fixture).not.toContain('function $RefreshReg$'); - done(); - }, - ); + it('should allow custom inject loader when compiling', () => { + return new Promise((done) => { + compileWithReactRefresh( + path.join(__dirname, 'fixtures/custom'), + { + injectLoader: false, + }, + (_, __, { fixture }, pl) => { + expect(pl.options.reactRefreshLoader).toBe( + 'builtin:react-refresh-loader', + ); + expect(fixture).not.toContain('function $RefreshReg$'); + done(); + }, + ); + }); }); - it('should allow custom inject entry when compiling', (done) => { - compileWithReactRefresh( - path.join(__dirname, 'fixtures/custom'), - { - injectEntry: false, - }, - (_, __, { reactRefresh, fixture, runtime, vendor }) => { - expect(reactRefresh).not.toContain( - 'RefreshRuntime.injectIntoGlobalHook(safeThis)', - ); - done(); - }, - ); + it('should allow custom inject entry when compiling', () => { + return new Promise((done) => { + compileWithReactRefresh( + path.join(__dirname, 'fixtures/custom'), + { + injectEntry: false, + }, + (_, __, { reactRefresh }) => { + expect(reactRefresh).not.toContain( + 'RefreshRuntime.injectIntoGlobalHook(safeThis)', + ); + done(); + }, + ); + }); }); - it('should always exclude react-refresh related modules', (done) => { - compileWithReactRefresh( - path.join(__dirname, 'fixtures/custom'), - { - exclude: null, - }, - (_, __, { reactRefresh, fixture, runtime, vendor }) => { - expect(reactRefresh).not.toContain('function $RefreshReg$'); - done(); - }, - ); + it('should always exclude react-refresh related modules', () => { + return new Promise((done) => { + compileWithReactRefresh( + path.join(__dirname, 'fixtures/custom'), + { + exclude: null, + }, + (_, __, { reactRefresh }) => { + expect(reactRefresh).not.toContain('function $RefreshReg$'); + done(); + }, + ); + }); }); - it('should include entries for webpack-hot-middleware', (done) => { - compileWithReactRefresh( - path.join(__dirname, 'fixtures/custom'), - { - overlay: { - sockIntegration: 'whm', + it('should include entries for webpack-hot-middleware', () => { + return new Promise((done) => { + compileWithReactRefresh( + path.join(__dirname, 'fixtures/custom'), + { + overlay: { + sockIntegration: 'whm', + }, }, - }, - (_, __, { fixture }) => { - expect(fixture).toContain('webpack-hot-middleware/client'); - expect(fixture).toContain('WHMEventSource.js'); - done(); - }, - ); + (_, __, { fixture }) => { + expect(fixture).toContain('webpack-hot-middleware/client'); + expect(fixture).toContain('WHMEventSource.js'); + done(); + }, + ); + }); + }); + + it('should instrument the module with builtin:react-refresh-loader', () => { + return new Promise((done) => { + compileWithReactRefresh( + path.join(__dirname, 'fixtures/loader'), + {}, + (_, __, { fixture }, pl) => { + expect(pl.options.reactRefreshLoader).toBe( + 'builtin:react-refresh-loader', + ); + expect(fixture).not.toContain('TEST_LOADER'); + expect(fixture).toContain('function $RefreshReg$'); + done(); + }, + ); + }); + }); + + it('should instrument the module with the custom loader', () => { + return new Promise((done) => { + compileWithReactRefresh( + path.join(__dirname, 'fixtures/loader'), + { + reactRefreshLoader: 'custom-react-refresh-loader', + }, + (_, __, { fixture }, pl) => { + expect(pl.options.reactRefreshLoader).toBe( + 'custom-react-refresh-loader', + ); + expect(fixture).toContain('TEST_LOADER'); + expect(fixture).not.toContain('function $RefreshReg$'); + done(); + }, + ); + }); }); });