diff --git a/package.json b/package.json index 65ba6eeb..f08a1920 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ ], "scripts": { "pretest": "yarn link && yarn link \"@pmmmwh/react-refresh-webpack-plugin\"", + "posttest": "yarn unlink \"@pmmmwh/react-refresh-webpack-plugin\"", "test": "node scripts/test.js", "lint": "eslint --report-unused-disable-directives --ext .js .", "lint:fix": "yarn lint --fix", @@ -47,7 +48,8 @@ "html-entities": "^1.2.1", "lodash.debounce": "^4.0.8", "native-url": "^0.2.6", - "schema-utils": "^2.6.5" + "schema-utils": "^2.6.5", + "source-map": "^0.7.3" }, "devDependencies": { "@babel/core": "^7.9.6", diff --git a/scripts/test.js b/scripts/test.js index cbd3bcc2..ca94d161 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -3,8 +3,8 @@ process.env.NODE_ENV = 'test'; // Crash on unhandled rejections instead of failing silently. -process.on('unhandledRejection', (error) => { - throw error; +process.on('unhandledRejection', (reason) => { + throw reason; }); const jest = require('jest'); diff --git a/src/helpers/createRefreshTemplate.js b/src/helpers/createRefreshTemplate.js deleted file mode 100644 index afd25628..00000000 --- a/src/helpers/createRefreshTemplate.js +++ /dev/null @@ -1,60 +0,0 @@ -const { Template } = require('webpack'); - -/** - * Code to run before each module, sets up react-refresh. - * - * `module.i` is injected by Webpack and should always exist. - * - * [Ref](https://github.com/webpack/webpack/blob/master/lib/MainTemplate.js#L233) - */ -const beforeModule = ` -let cleanup = function NoOp() {}; - -const check = function(it) { - return it && it.Math == Math && it; -}; - -const safeThis = - check(typeof globalThis == 'object' && globalThis) || - check(typeof window == 'object' && window) || - check(typeof self == 'object' && self) || - check(typeof global == 'object' && global) || - Function('return this')(); - -if (safeThis && safeThis.$RefreshSetup$) { - cleanup = safeThis.$RefreshSetup$(module.i); -} - -try { -`; - -/** Code to run after each module, sets up react-refresh */ -const afterModule = ` -} finally { - cleanup(); -} -`; - -/** - * Creates a module wrapped by a refresh template. - * @param {string} source The source code of a module. - * @returns {string} A refresh-wrapped module. - */ -function createRefreshTemplate(source) { - const lines = source.split('\n'); - - // Webpack generates this line whenever the mainTemplate is called - const moduleInitializationLineNumber = lines.findIndex((line) => - line.startsWith('modules[moduleId].call') - ); - - return Template.asString([ - ...lines.slice(0, moduleInitializationLineNumber), - beforeModule, - Template.indent(lines[moduleInitializationLineNumber]), - afterModule, - ...lines.slice(moduleInitializationLineNumber + 1, lines.length), - ]); -} - -module.exports = createRefreshTemplate; diff --git a/src/helpers/index.js b/src/helpers/index.js index 254704aa..63a0007b 100644 --- a/src/helpers/index.js +++ b/src/helpers/index.js @@ -1,10 +1,8 @@ -const createRefreshTemplate = require('./createRefreshTemplate'); const getSocketIntegration = require('./getSocketIntegration'); const injectRefreshEntry = require('./injectRefreshEntry'); const normalizeOptions = require('./normalizeOptions'); module.exports = { - createRefreshTemplate, getSocketIntegration, injectRefreshEntry, normalizeOptions, diff --git a/src/index.js b/src/index.js index ea0bee71..d87fab1e 100644 --- a/src/index.js +++ b/src/index.js @@ -1,15 +1,31 @@ const path = require('path'); const validateOptions = require('schema-utils'); -const webpack = require('webpack'); -const { - createRefreshTemplate, - getSocketIntegration, - injectRefreshEntry, - normalizeOptions, -} = require('./helpers'); -const { errorOverlay, initSocket, refreshUtils } = require('./runtime/globals'); +const { DefinePlugin, ModuleFilenameHelpers, ProvidePlugin, Template } = require('webpack'); +const ConstDependency = require('webpack/lib/dependencies/ConstDependency'); +const NullFactory = require('webpack/lib/NullFactory'); +const ParserHelpers = require('webpack/lib/ParserHelpers'); +const { getSocketIntegration, injectRefreshEntry, normalizeOptions } = require('./helpers'); const schema = require('./options.json'); +const refreshObj = '__webpack_require__.$Refresh$'; + +// Mapping of react-refresh globals to Webpack require extensions +const PARSER_REPLACEMENTS = { + $RefreshRuntime$: `${refreshObj}.runtime`, + $RefreshSetup$: `${refreshObj}.setup`, + $RefreshCleanup$: `${refreshObj}.cleanup`, + $RefreshReg$: `${refreshObj}.register`, + $RefreshSig$: `${refreshObj}.signature`, +}; + +const PARSER_REPLACEMENT_TYPES = { + $RefreshRuntime$: 'object', + $RefreshSetup$: 'function', + $RefreshCleanup$: 'function', + $RefreshReg$: 'function', + $RefreshSig$: 'function', +}; + class ReactRefreshPlugin { /** * @param {import('./types').ReactRefreshPluginOptions} [options] Options for react-refresh-plugin. @@ -49,24 +65,24 @@ class ReactRefreshPlugin { // Inject react-refresh context to all Webpack entry points compiler.options.entry = injectRefreshEntry(compiler.options.entry, this.options); - // Inject necessary modules to Webpack's global scope + // Inject necessary modules to bundle's global scope let providedModules = { - [refreshUtils]: require.resolve('./runtime/refreshUtils'), + __react_refresh_utils__: require.resolve('./runtime/refreshUtils'), }; if (this.options.overlay === false) { // Stub errorOverlay module so calls to it will be erased - const definePlugin = new webpack.DefinePlugin({ [errorOverlay]: false }); + const definePlugin = new DefinePlugin({ __react_refresh_error_overlay__: false }); definePlugin.apply(compiler); } else { providedModules = { ...providedModules, - [errorOverlay]: require.resolve(this.options.overlay.module), - [initSocket]: getSocketIntegration(this.options.overlay.sockIntegration), + __react_refresh_error_overlay__: require.resolve(this.options.overlay.module), + __react_refresh_init_socket__: getSocketIntegration(this.options.overlay.sockIntegration), }; } - const providePlugin = new webpack.ProvidePlugin(providedModules); + const providePlugin = new ProvidePlugin(providedModules); providePlugin.apply(compiler); compiler.hooks.beforeRun.tap(this.constructor.name, (compiler) => { @@ -86,7 +102,7 @@ class ReactRefreshPlugin { } }); - const matchObject = webpack.ModuleFilenameHelpers.matchObject.bind(undefined, this.options); + const matchObject = ModuleFilenameHelpers.matchObject.bind(undefined, this.options); compiler.hooks.normalModuleFactory.tap(this.constructor.name, (nmf) => { nmf.hooks.afterResolve.tap(this.constructor.name, (data) => { // Inject refresh loader to all JavaScript-like files @@ -108,43 +124,143 @@ class ReactRefreshPlugin { }); }); - compiler.hooks.compilation.tap(this.constructor.name, (compilation) => { - compilation.mainTemplate.hooks.require.tap( - this.constructor.name, - // Constructs the correct module template for react-refresh - (source, chunk, hash) => { - const mainTemplate = compilation.mainTemplate; - - // Check for the output filename - // This is to ensure we are processing a JS-related chunk - let filename = mainTemplate.outputOptions.filename; - if (typeof filename === 'function') { - // Only usage of the `chunk` property is documented by Webpack. - // However, some internal Webpack plugins uses other properties, - // so we also pass them through to be on the safe side. - filename = filename({ - chunk, - hash, - // TODO: Figure out whether we need to stub the following properties, probably no - contentHashType: 'javascript', - hashWithLength: (length) => mainTemplate.renderCurrentHashCode(hash, length), - noChunkHash: mainTemplate.useChunkHash(chunk), - }); + compiler.hooks.compilation.tap( + this.constructor.name, + (compilation, { normalModuleFactory }) => { + compilation.dependencyFactories.set(ConstDependency, new NullFactory()); + compilation.dependencyTemplates.set(ConstDependency, new ConstDependency.Template()); + + compilation.mainTemplate.hooks.require.tap( + this.constructor.name, + // Constructs the module template for react-refresh + (source, chunk, hash) => { + const mainTemplate = compilation.mainTemplate; + + // Check for the output filename + // This is to ensure we are processing a JS-related chunk + let filename = mainTemplate.outputOptions.filename; + if (typeof filename === 'function') { + // Only usage of the `chunk` property is documented by Webpack. + // However, some internal Webpack plugins uses other properties, + // so we also pass them through to be on the safe side. + filename = filename({ + chunk, + hash, + // TODO: Figure out whether we need to stub the following properties, probably no + contentHashType: 'javascript', + hashWithLength: (length) => mainTemplate.renderCurrentHashCode(hash, length), + noChunkHash: mainTemplate.useChunkHash(chunk), + }); + } + + // Check whether the current compilation is outputting to JS, + // since other plugins can trigger compilations for other file types too. + // If we apply the transform to them, their compilation will break fatally. + // One prominent example of this is the HTMLWebpackPlugin. + // If filename is falsy, something is terribly wrong and there's nothing we can do. + if (!filename || !filename.includes('.js')) { + return source; + } + + // Split template source code into lines for easier processing + const lines = source.split('\n'); + // Webpack generates this line when the MainTemplate is called + const moduleInitializationLineNumber = lines.findIndex((line) => + line.startsWith('modules[moduleId].call') + ); + + return Template.asString([ + ...lines.slice(0, moduleInitializationLineNumber), + '', + `${refreshObj}.setup = function(currentModuleId) {`, + Template.indent([ + `const prevSetup = ${refreshObj}.setup;`, + `const prevCleanup = ${refreshObj}.cleanup;`, + `const prevReg = ${refreshObj}.register;`, + `const prevSig = ${refreshObj}.signature;`, + '', + `${refreshObj}.register = function register(type, id) {`, + Template.indent([ + 'const typeId = currentModuleId + " " + id;', + `${refreshObj}.runtime.register(type, typeId);`, + ]), + '};', + '', + `${refreshObj}.signature = ${refreshObj}.runtime.createSignatureFunctionForTransform;`, + '', + `${refreshObj}.cleanup = function cleanup() {`, + Template.indent([ + `${refreshObj}.register = prevReg;`, + `${refreshObj}.signature = prevSig;`, + `${refreshObj}.cleanup = prevCleanup;`, + ]), + '};', + '', + `${refreshObj}.setup = prevSetup;`, + ]), + '};', + '', + 'try {', + Template.indent(lines[moduleInitializationLineNumber]), + '} finally {', + Template.indent(`${refreshObj}.cleanup();`), + '}', + '', + ...lines.slice(moduleInitializationLineNumber + 1, lines.length), + ]); } + ); - // Check whether the current compilation is outputting to JS, - // since other plugins can trigger compilations for other file types too. - // If we apply the transform to them, their compilation will break fatally. - // One prominent example of this is the HTMLWebpackPlugin. - // If filename is falsy, something is terribly wrong and there's nothing we can do. - if (!filename || !filename.includes('.js')) { - return source; + compilation.mainTemplate.hooks.requireExtensions.tap( + this.constructor.name, + // Setup react-refresh globals as extensions to Webpack's require function + (source) => { + return Template.asString([ + source, + '', + `${refreshObj} = {};`, + `${refreshObj}.runtime = {};`, + `${refreshObj}.setup = function() {};`, + `${refreshObj}.cleanup = function() {};`, + `${refreshObj}.register = function() {};`, + `${refreshObj}.signature = function() {`, + Template.indent('return function(type) { return type; };'), + '};', + ]); } + ); - return createRefreshTemplate(source); - } - ); - }); + // Transform global calls into require extensions calls + const parserHandler = (parser) => { + Object.entries(PARSER_REPLACEMENTS).forEach(([key, replacement]) => { + parser.hooks.expression + .for(key) + .tap( + this.constructor.name, + ParserHelpers.toConstantDependencyWithWebpackRequire(parser, replacement) + ); + if (PARSER_REPLACEMENT_TYPES[key]) { + parser.hooks.evaluateTypeof + .for(key) + .tap( + this.constructor.name, + ParserHelpers.evaluateToString(PARSER_REPLACEMENT_TYPES[key]) + ); + } + }); + }; + + normalModuleFactory.hooks.parser + .for('javascript/auto') + .tap(this.constructor.name, parserHandler); + normalModuleFactory.hooks.parser + .for('javascript/dynamic') + .tap(this.constructor.name, parserHandler); + normalModuleFactory.hooks.parser + .for('javascript/esm') + .tap(this.constructor.name, parserHandler); + } + ); } } diff --git a/src/loader/RefreshModuleRuntime.js b/src/loader/RefreshModule.runtime.js similarity index 50% rename from src/loader/RefreshModuleRuntime.js rename to src/loader/RefreshModule.runtime.js index b05e90e6..fb7c2f29 100644 --- a/src/loader/RefreshModuleRuntime.js +++ b/src/loader/RefreshModule.runtime.js @@ -1,7 +1,7 @@ -/* global $RefreshUtils$ */ +/* global __react_refresh_utils__ */ /** - * Code injected to each JS-like module for react-refresh capabilities. + * Code appended to each JS-like module for react-refresh capabilities. * * `$RefreshUtils$` is replaced to the actual utils during source parsing by `webpack.ProvidePlugin`. * @@ -11,29 +11,29 @@ * [Reference for HMR Error Recovery](https://github.com/webpack/webpack/issues/418#issuecomment-490296365) */ module.exports = function () { - const currentExports = $RefreshUtils$.getModuleExports(module); - $RefreshUtils$.registerExportsForReactRefresh(currentExports, module.id); + const currentExports = __react_refresh_utils__.getModuleExports(module); + __react_refresh_utils__.registerExportsForReactRefresh(currentExports, module.id); if (module.hot) { const isHotUpdate = !!module.hot.data; const prevExports = isHotUpdate ? module.hot.data.prevExports : null; - if ($RefreshUtils$.isReactRefreshBoundary(currentExports)) { - module.hot.dispose($RefreshUtils$.createHotDisposeCallback(currentExports)); - module.hot.accept($RefreshUtils$.createHotErrorHandler(module.id)); + if (__react_refresh_utils__.isReactRefreshBoundary(currentExports)) { + module.hot.dispose(__react_refresh_utils__.createHotDisposeCallback(currentExports)); + module.hot.accept(__react_refresh_utils__.createHotErrorHandler(module.id)); if (isHotUpdate) { if ( - $RefreshUtils$.isReactRefreshBoundary(prevExports) && - $RefreshUtils$.shouldInvalidateReactRefreshBoundary(prevExports, currentExports) + __react_refresh_utils__.isReactRefreshBoundary(prevExports) && + __react_refresh_utils__.shouldInvalidateReactRefreshBoundary(prevExports, currentExports) ) { module.hot.invalidate(); } else { - $RefreshUtils$.enqueueUpdate(); + __react_refresh_utils__.enqueueUpdate(); } } } else { - if (isHotUpdate && $RefreshUtils$.isReactRefreshBoundary(prevExports)) { + if (isHotUpdate && __react_refresh_utils__.isReactRefreshBoundary(prevExports)) { module.hot.invalidate(); } } diff --git a/src/loader/RefreshSetup.runtime.js b/src/loader/RefreshSetup.runtime.js new file mode 100644 index 00000000..5a102ae4 --- /dev/null +++ b/src/loader/RefreshSetup.runtime.js @@ -0,0 +1,14 @@ +/* eslint-disable no-global-assign, no-unused-vars */ +/* global $RefreshRuntime$, $RefreshSetup$ */ + +/** + * Code prepended to each JS-like module to setup react-refresh globals. + * + * All globals are injected via Webpack parser hooks. + * + * The function declaration syntax below is needed for `Template.getFunctionContent` to parse this. + */ +module.exports = function () { + $RefreshRuntime$ = require('react-refresh/runtime'); + $RefreshSetup$(module.id); +}; diff --git a/src/loader/index.js b/src/loader/index.js index a750fa00..f66512e1 100644 --- a/src/loader/index.js +++ b/src/loader/index.js @@ -1,10 +1,17 @@ +const { SourceMapConsumer, SourceNode } = require('source-map'); const { Template } = require('webpack'); -const { refreshUtils } = require('../runtime/globals'); -const RefreshModuleRuntime = Template.getFunctionContent(require('./RefreshModuleRuntime')) - .trim() - .replace(/^ {2}/gm, '') - .replace(/\$RefreshUtils\$/g, refreshUtils); +/** + * Gets a runtime template from provided function. + * @param {function(): void} fn A function containing the runtime template. + * @returns {string} The "sanitized" runtime template. + */ +function getTemplate(fn) { + return Template.getFunctionContent(fn).trim().replace(/^ {2}/gm, ''); +} + +const RefreshSetupRuntime = getTemplate(require('./RefreshSetup.runtime')); +const RefreshModuleRuntime = getTemplate(require('./RefreshModule.runtime')); /** * A simple Webpack loader to inject react-refresh HMR code into modules. @@ -13,11 +20,37 @@ const RefreshModuleRuntime = Template.getFunctionContent(require('./RefreshModul * @this {import('webpack').loader.LoaderContext} * @param {string} source The original module source code. * @param {import('source-map').RawSourceMap} [inputSourceMap] The source map of the module. + * @param {*} [meta] The loader metadata passed in. * @returns {void} */ -function RefreshHotLoader(source, inputSourceMap) { - // Use callback to allow source maps to pass through - this.callback(null, source + '\n\n' + RefreshModuleRuntime, inputSourceMap); +function ReactRefreshLoader(source, inputSourceMap, meta) { + const callback = this.async(); + + async function _loader(source, inputSourceMap) { + if (this.sourceMap) { + const node = SourceNode.fromStringWithSourceMap( + source, + await new SourceMapConsumer(inputSourceMap) + ); + + node.prepend([RefreshSetupRuntime, '\n\n']); + node.add(['\n\n', RefreshModuleRuntime]); + + const { code, map } = node.toStringWithSourceMap(); + return [code, map.toJSON()]; + } else { + return [[RefreshSetupRuntime, source, RefreshModuleRuntime].join('\n\n'), inputSourceMap]; + } + } + + _loader.call(this, source, inputSourceMap).then( + ([code, map]) => { + callback(null, code, map, meta); + }, + (error) => { + callback(error); + } + ); } -module.exports = RefreshHotLoader; +module.exports = ReactRefreshLoader; diff --git a/src/runtime/LegacyWDSSocketEntry.js b/src/runtime/LegacyWDSSocketEntry.js index b61e5fcc..7137fe9e 100644 --- a/src/runtime/LegacyWDSSocketEntry.js +++ b/src/runtime/LegacyWDSSocketEntry.js @@ -1,4 +1,5 @@ const SockJS = require('sockjs-client/dist/sockjs'); +const safeThis = require('./safeThis'); /** * A SockJS client adapted for use with webpack-dev-server. @@ -27,4 +28,4 @@ SockJSClient.prototype.onMessage = function onMessage(fn) { }; }; -window.__webpack_dev_server_client__ = SockJSClient; +safeThis.__webpack_dev_server_client__ = SockJSClient; diff --git a/src/runtime/ReactRefreshEntry.js b/src/runtime/ReactRefreshEntry.js index afd51c93..2d6354e4 100644 --- a/src/runtime/ReactRefreshEntry.js +++ b/src/runtime/ReactRefreshEntry.js @@ -1,48 +1,13 @@ -if (process.env.NODE_ENV !== 'production' && typeof window !== 'undefined') { - const Refresh = require('react-refresh/runtime'); - - // Inject refresh runtime into global - Refresh.injectIntoGlobalHook(window); - - // Setup placeholder functions - window.$RefreshReg$ = function () {}; - window.$RefreshSig$ = function () { - return function (type) { - return type; - }; - }; - - /** - * Setup module refresh. - * @param {number} moduleId An ID of a module. - * @returns {function(): void} A function to restore handlers to their previous state. - */ - window.$RefreshSetup$ = function setupModuleRefresh(moduleId) { - // Capture previous refresh state - const prevRefreshReg = window.$RefreshReg$; - const prevRefreshSig = window.$RefreshSig$; - - /** - * Registers a refresh to react-refresh. - * @param {string} [type] A valid type of a module. - * @param {number} [id] An ID of a module. - * @returns {void} - */ - window.$RefreshReg$ = function (type, id) { - const typeId = moduleId + ' ' + id; - Refresh.register(type, typeId); - }; - - /** - * Creates a module signature function from react-refresh. - * @returns {function(type: string): string} A created signature function. - */ - window.$RefreshSig$ = Refresh.createSignatureFunctionForTransform; - - // Restore to previous refresh functions after initialization - return function cleanup() { - window.$RefreshReg$ = prevRefreshReg; - window.$RefreshSig$ = prevRefreshSig; - }; - }; +const safeThis = require('./safeThis'); + +if (process.env.NODE_ENV !== 'production' && typeof safeThis !== 'undefined') { + // Only inject the runtime if it hasn't been injected + if (!safeThis.__reactRefreshInjected) { + const RefreshRuntime = require('react-refresh/runtime'); + // Inject refresh runtime into global scope + RefreshRuntime.injectIntoGlobalHook(safeThis); + + // Mark the runtime as injected to prevent double-injection + safeThis.__reactRefreshInjected = true; + } } diff --git a/src/runtime/globals.js b/src/runtime/globals.js deleted file mode 100644 index f4020c25..00000000 --- a/src/runtime/globals.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - errorOverlay: '__react_refresh_error_overlay__', - initSocket: '__react_refresh_init_socket__', - refreshUtils: '__react_refresh_utils__', -}; diff --git a/src/runtime/refreshUtils.js b/src/runtime/refreshUtils.js index 2547b76f..0c0335fa 100644 --- a/src/runtime/refreshUtils.js +++ b/src/runtime/refreshUtils.js @@ -71,11 +71,11 @@ function createHotErrorHandler(moduleId) { * @returns {void} */ function hotErrorHandler(error) { - if (__react_refresh_error_overlay__) { + if (typeof __react_refresh_error_overlay__ !== 'undefined' && __react_refresh_error_overlay__) { __react_refresh_error_overlay__.handleRuntimeError(error); } - if (typeof __react_refresh_test__ !== 'undefined') { + if (typeof __react_refresh_test__ !== 'undefined' && __react_refresh_test__) { if (window.onHotAcceptError) { window.onHotAcceptError(error.message); } @@ -115,7 +115,10 @@ function createDebounceUpdate() { refreshTimeout = setTimeout(function () { refreshTimeout = undefined; Refresh.performReactRefresh(); - if (__react_refresh_error_overlay__) { + if ( + typeof __react_refresh_error_overlay__ !== 'undefined' && + __react_refresh_error_overlay__ + ) { __react_refresh_error_overlay__.clearRuntimeErrors(); } }, 30); diff --git a/src/runtime/safeThis.js b/src/runtime/safeThis.js new file mode 100644 index 00000000..db099101 --- /dev/null +++ b/src/runtime/safeThis.js @@ -0,0 +1,19 @@ +/* global globalThis */ +/* + This file is copied from `core-js`. + https://github.com/zloirock/core-js/blob/master/packages/core-js/internals/global.js + + MIT License + Author: Denis Pushkarev (@zloirock) +*/ + +const check = function (it) { + return it && it.Math == Math && it; +}; + +module.exports = + check(typeof globalThis == 'object' && globalThis) || + check(typeof window == 'object' && window) || + check(typeof self == 'object' && self) || + check(typeof global == 'object' && global) || + Function('return this')();