From 11656fdef10618782f1e17071d25967c79574933 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Thu, 14 May 2020 01:04:33 +0800 Subject: [PATCH 01/10] refactor(runtime): unify refresh utils to accept moduleExports --- src/runtime/refreshUtils.js | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/runtime/refreshUtils.js b/src/runtime/refreshUtils.js index c2ca91b3..3e0593b7 100644 --- a/src/runtime/refreshUtils.js +++ b/src/runtime/refreshUtils.js @@ -41,10 +41,10 @@ function getReactRefreshBoundarySignature(moduleExports) { /** * Creates conditional full refresh dispose handler for Webpack hot. - * @param {*} module A Webpack module object. + * @param {*} moduleExports A Webpack module exports object. * @returns {hotDisposeCallback} A webpack hot dispose callback. */ -function createHotDisposeCallback(module) { +function createHotDisposeCallback(moduleExports) { /** * A callback to performs a full refresh if React has unrecoverable errors, * and also caches the to-be-disposed module. @@ -57,7 +57,7 @@ function createHotDisposeCallback(module) { } // We have to mutate the data object to get data registered and cached - data.module = module; + data.prevExports = moduleExports; } return hotDisposeCallback; @@ -65,7 +65,7 @@ function createHotDisposeCallback(module) { /** * Creates self-recovering an error handler for webpack hot. - * @param {string} moduleId A unique ID for a Webpack module. + * @param {string} moduleId A Webpack module ID. * @returns {selfAcceptingHotErrorHandler} A self-accepting webpack hot error handler. */ function createHotErrorHandler(moduleId) { @@ -127,12 +127,10 @@ function createDebounceUpdate() { * 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 {*} module A Webpack module object. + * @param {*} moduleExports A Webpack module exports object. * @returns {boolean} Whether the exports are React component like. */ -function isReactRefreshBoundary(module) { - const moduleExports = getModuleExports(module); - +function isReactRefreshBoundary(moduleExports) { if (Refresh.isLikelyComponentType(moduleExports)) { return true; } @@ -168,13 +166,11 @@ function isReactRefreshBoundary(module) { * 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 {*} module A Webpack module object. + * @param {*} moduleExports A Webpack module exports object. + * @param {string} moduleId A Webpack module ID. * @returns {void} */ -function registerExportsForReactRefresh(module) { - const moduleExports = getModuleExports(module); - const moduleId = module.id; - +function registerExportsForReactRefresh(moduleExports, moduleId) { if (Refresh.isLikelyComponentType(moduleExports)) { // Register module.exports if it is likely a component Refresh.register(moduleExports, moduleId + ' %exports%'); @@ -203,13 +199,13 @@ function registerExportsForReactRefresh(module) { * 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 prevModule {*} The current Webpack module exports object. - * @param nextModule {*} The next Webpack module exports object. + * @param prevExports {*} The current Webpack module exports object. + * @param nextExports {*} The next Webpack module exports object. * @returns {boolean} Whether the React refresh boundary should be invalidated. */ -function shouldInvalidateReactRefreshBoundary(prevModule, nextModule) { - const prevSignature = getReactRefreshBoundarySignature(getModuleExports(prevModule)); - const nextSignature = getReactRefreshBoundarySignature(getModuleExports(nextModule)); +function shouldInvalidateReactRefreshBoundary(prevExports, nextExports) { + const prevSignature = getReactRefreshBoundarySignature(prevExports); + const nextSignature = getReactRefreshBoundarySignature(nextExports); if (prevSignature.length !== nextSignature.length) { return true; @@ -228,6 +224,7 @@ module.exports = Object.freeze({ createHotDisposeCallback, createHotErrorHandler, enqueueUpdate: createDebounceUpdate(), + getModuleExports, isReactRefreshBoundary, shouldInvalidateReactRefreshBoundary, registerExportsForReactRefresh, From 8648361115020264d622187bc5d8d58c82c244b6 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Thu, 14 May 2020 01:05:08 +0800 Subject: [PATCH 02/10] feat(runtime): integrate with module.hot.invalidate API --- src/loader/RefreshModuleRuntime.js | 33 ++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/loader/RefreshModuleRuntime.js b/src/loader/RefreshModuleRuntime.js index 8cda0115..009c38d2 100644 --- a/src/loader/RefreshModuleRuntime.js +++ b/src/loader/RefreshModuleRuntime.js @@ -11,20 +11,31 @@ * [Reference for HMR Error Recovery](https://github.com/webpack/webpack/issues/418#issuecomment-490296365) */ module.exports = function () { - $RefreshUtils$.registerExportsForReactRefresh(module); + const currentExports = $RefreshUtils$.getModuleExports(module); + $RefreshUtils$.registerExportsForReactRefresh(currentExports, module.id); - if (module.hot && $RefreshUtils$.isReactRefreshBoundary(module)) { - module.hot.dispose($RefreshUtils$.createHotDisposeCallback(module)); - module.hot.accept($RefreshUtils$.createHotErrorHandler(module.id)); + if (module.hot) { + const isHotUpdate = !!module.hot.data; + const prevExports = isHotUpdate ? module.hot.data.prevExports : null; - if (!!module.hot.data && !!Object.keys(module.hot.data).length) { - if ( - !module.hot.data.module || - $RefreshUtils$.shouldInvalidateReactRefreshBoundary(module.hot.data.module, module) - ) { - window.location.reload(); + if ($RefreshUtils$.isReactRefreshBoundary(currentExports)) { + module.hot.dispose($RefreshUtils$.createHotDisposeCallback(currentExports)); + module.hot.accept($RefreshUtils$.createHotErrorHandler(module.id)); + + if (isHotUpdate) { + if ( + !prevExports || + $RefreshUtils$.shouldInvalidateReactRefreshBoundary(prevExports, currentExports) + ) { + module.hot.invalidate(); + } else { + $RefreshUtils$.enqueueUpdate(); + } + } + } else { + if ($RefreshUtils$.isReactRefreshBoundary(prevExports)) { + module.hot.invalidate(); } - $RefreshUtils$.enqueueUpdate(); } } }; From eb67315223260357d9e9721336956e1425d2c8e3 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Thu, 14 May 2020 01:14:27 +0800 Subject: [PATCH 03/10] chore(deps)!: add strict peer dependency on webpack 4.43 --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index c866b48f..d1d2e280 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "react-refresh": "^0.8.2", "sockjs-client": "^1.4.0", "type-fest": "^0.13.1", + "webpack": ">=4.43.0", "webpack-dev-server": "3.x", "webpack-hot-middleware": "2.x", "webpack-plugin-serve": "0.x || 1.x" From bf8eef4bf52d90ccf29fb6d5c837630be470c7b8 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Thu, 14 May 2020 01:14:45 +0800 Subject: [PATCH 04/10] chore(deps-dev): update development dependencies --- package.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index d1d2e280..bb64690c 100644 --- a/package.json +++ b/package.json @@ -52,13 +52,13 @@ "devDependencies": { "@babel/core": "^7.9.6", "@types/json-schema": "^7.0.4", - "@types/node": "^13.11.1", + "@types/node": "^14.0.1", "@types/puppeteer": "^2.1.0", - "@types/webpack": "^4.41.11", + "@types/webpack": "^4.41.12", "babel-loader": "^8.1.0", "cross-spawn": "^7.0.2", - "eslint": "^6.8.0", - "eslint-config-prettier": "^6.10.1", + "eslint": "^7.0.0", + "eslint-config-prettier": "^6.11.0", "fs-extra": "^9.0.0", "get-port": "^5.1.1", "jest": "^26.0.1", @@ -66,13 +66,13 @@ "jest-watch-typeahead": "^0.6.0", "nanoid": "^3.1.7", "postinstall-postinstall": "^2.1.0", - "prettier": "^2.0.4", + "prettier": "^2.0.5", "puppeteer": "^3.0.4", - "react-refresh": "^0.8.1", + "react-refresh": "^0.8.2", "rimraf": "^3.0.2", "type-fest": "^0.13.1", "typescript": "^3.8.3", - "webpack": "^4.42.1", + "webpack": "^4.43.0", "webpack-cli": "^3.3.11", "webpack-dev-server": "^3.11.0", "webpack-hot-middleware": "^2.25.0", From b78ce5afe84d399bda7a5a9c2a659375f72773b1 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 10:49:10 +0800 Subject: [PATCH 05/10] test: enable tests for invalidation API --- test/conformance/ReactRefreshRequire.test.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/conformance/ReactRefreshRequire.test.js b/test/conformance/ReactRefreshRequire.test.js index 88dc284b..f784c199 100644 --- a/test/conformance/ReactRefreshRequire.test.js +++ b/test/conformance/ReactRefreshRequire.test.js @@ -265,8 +265,7 @@ test.todo('bails out if update bubbles to the root via the only path'); test.todo('bails out if the update bubbles to the root via one of the paths'); // https://github.com/facebook/metro/blob/b651e535cd0fc5df6c0803b9aa647d664cb9a6c3/packages/metro/src/lib/polyfills/__tests__/require-test.js#L2373-L2472 -// FIXME: Enable this test in #89 -test.skip('propagates a module that stops accepting in next version', async () => { +test('propagates a module that stops accepting in next version', async () => { const [session] = await createSandbox(); // Accept in parent From 3805e1593913d88440a100f205ad1842e7ed0c8f Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 16:21:37 +0800 Subject: [PATCH 06/10] test: add sandbox reload handler --- test/sandbox/index.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/sandbox/index.js b/test/sandbox/index.js index 4a24dcc7..67374692 100644 --- a/test/sandbox/index.js +++ b/test/sandbox/index.js @@ -47,6 +47,7 @@ const sleep = (ms) => { * @property {function(string, string): Promise} patch * @property {function(string): Promise} remove * @property {function(*): Promise<*>} evaluate + * @property {function(): Promise} reload */ const rootSandboxDir = path.join(__dirname, '..', '__tmp__'); @@ -228,6 +229,10 @@ async function sandbox({ id = nanoid(), initialFiles = new Map() } = {}) { throw new Error('You must pass a function to be evaluated in the browser!'); } }, + /** @returns {Promise} */ + async reload() { + await page.reload({ waitUntil: 'networkidle2' }); + }, }, cleanupSandbox, ]; From 5a89d85001360f3fed000a1046278f689a80e517 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 16:21:48 +0800 Subject: [PATCH 07/10] test: spawn wds with hot instead of hot-only --- test/sandbox/spawn.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sandbox/spawn.js b/test/sandbox/spawn.js index fc55cdb1..56482f6e 100644 --- a/test/sandbox/spawn.js +++ b/test/sandbox/spawn.js @@ -84,7 +84,7 @@ function spawnWDS(port, directory, options) { path.resolve(directory, 'webpack.config.js'), '--content-base', directory, - '--hot-only', + '--hot', '--port', port, ], From f24a025abe0600325292e6f98694f5fbaa2bf469 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 16:27:25 +0800 Subject: [PATCH 08/10] test: reload session in bootstrap to not rely on auto-reload --- test/conformance/ReactRefreshRequire.test.js | 63 ++++++++++---------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/test/conformance/ReactRefreshRequire.test.js b/test/conformance/ReactRefreshRequire.test.js index f784c199..1140fb6d 100644 --- a/test/conformance/ReactRefreshRequire.test.js +++ b/test/conformance/ReactRefreshRequire.test.js @@ -4,11 +4,13 @@ const createSandbox = require('../sandbox'); test('re-runs accepted modules', async () => { const [session] = await createSandbox(); - await session.patch('./index.js', `export default function Noop() { return null; };`); + // Bootstrap test and reload session to not rely on auto-refresh semantics + await session.write('index.js', `export default function Noop() { return null; };`); + await session.reload(); - await session.write('./foo.js', `window.logs.push('init FooV1'); require('./bar');`); + await session.write('foo.js', `window.logs.push('init FooV1'); require('./bar');`); await session.write( - './bar.js', + 'bar.js', `window.logs.push('init BarV1'); export default function Bar() { return null; };` ); @@ -23,7 +25,7 @@ test('re-runs accepted modules', async () => { // So we expect it to re-run alone. await session.resetLogs(); await session.patch( - './bar.js', + 'bar.js', `window.logs.push('init BarV2'); export default function Bar() { return null; };` ); await expect(session.logs).resolves.toEqual(['init BarV2']); @@ -32,7 +34,7 @@ test('re-runs accepted modules', async () => { // So we expect it to re-run alone. await session.resetLogs(); await session.patch( - './bar.js', + 'bar.js', `window.logs.push('init BarV3'); export default function Bar() { return null; };` ); await expect(session.logs).resolves.toEqual(['init BarV3']); @@ -46,14 +48,15 @@ test('re-runs accepted modules', async () => { test('propagates a hot update to closest accepted module', async () => { const [session] = await createSandbox(); - await session.patch('index.js', `export default function Noop() { return null; };`); + await session.write('index.js', `export default function Noop() { return null; };`); + await session.reload(); await session.write( - './foo.js', + 'foo.js', // Exporting a component marks it as auto-accepting. `window.logs.push('init FooV1'); require('./bar'); export default function Foo() {};` ); - await session.write('./bar.js', `window.logs.push('init BarV1');`); + await session.write('bar.js', `window.logs.push('init BarV1');`); await session.resetLogs(); await session.patch( @@ -65,7 +68,7 @@ test('propagates a hot update to closest accepted module', async () => { // We edited Bar, but it doesn't accept. // So we expect it to re-run together with Foo which does. await session.resetLogs(); - await session.patch('./bar.js', `window.logs.push('init BarV2');`); + await session.patch('bar.js', `window.logs.push('init BarV2');`); await expect(session.logs).resolves.toEqual([ // // FIXME: Metro order: // 'init BarV2', @@ -80,7 +83,7 @@ test('propagates a hot update to closest accepted module', async () => { // We edited Bar, but it doesn't accept. // So we expect it to re-run together with Foo which does. await session.resetLogs(); - await session.patch('./bar.js', `window.logs.push('init BarV3');`); + await session.patch('bar.js', `window.logs.push('init BarV3');`); await expect(session.logs).resolves.toEqual([ // // FIXME: Metro order: // 'init BarV3', @@ -96,31 +99,28 @@ test('propagates a hot update to closest accepted module', async () => { // We still re-run Foo because the exports of Bar changed. await session.resetLogs(); await session.patch( - './bar.js', + 'bar.js', // Exporting a component marks it as auto-accepting. - `window.logs.push('init BarV3'); export default function Bar() {};` + `window.logs.push('init BarV4'); export default function Bar() {};` ); - expect(await session.evaluate(() => window.logs)).toEqual([ + await expect(session.logs).resolves.toEqual([ // // FIXME: Metro order: - // 'init BarV3', + // 'init BarV4', // 'init FooV1', 'init FooV1', - 'init BarV3', + 'init BarV4', // Webpack runs in this order because it evaluates modules parent down, not // child up. Parents will re-run child modules in the order that they're // imported from the parent. ]); // Further edits to Bar don't re-run Foo. - await session.evaluate(() => (window.logs = [])); + await session.resetLogs(); await session.patch( - './bar.js', - ` - window.logs.push('init BarV4'); - export default function Bar() {}; - ` + 'bar.js', + `window.logs.push('init BarV5'); export default function Bar() {};` ); - await expect(session.logs).resolves.toEqual(['init BarV4']); + await expect(session.logs).resolves.toEqual(['init BarV5']); // TODO: // expect(Refresh.performReactRefresh).toHaveBeenCalled(); @@ -131,7 +131,8 @@ test('propagates a hot update to closest accepted module', async () => { test('propagates hot update to all inverse dependencies', async () => { const [session] = await createSandbox(); - await session.patch('index.js', `export default function Noop() { return null; };`); + await session.write('index.js', `export default function Noop() { return null; };`); + await session.reload(); // This is the module graph: // MiddleA* @@ -183,11 +184,11 @@ test('propagates hot update to all inverse dependencies', async () => { // Doesn't accept its own updates; they will propagate. await session.write('leaf.js', `window.logs.push('init LeafV1'); export default {};`); + await session.resetLogs(); await session.patch( 'index.js', `require('./root'); export default function Noop() { return null; };` ); - await expect(session.logs).resolves.toEqual([ 'init LeafV1', 'init MiddleAV1', @@ -270,12 +271,12 @@ test('propagates a module that stops accepting in next version', async () => { // Accept in parent await session.write( - './foo.js', + 'foo.js', `window.logs.push('init FooV1'); import './bar'; export default function Foo() {};` ); // Accept in child await session.write( - './bar.js', + 'bar.js', `window.logs.push('init BarV1'); export default function Bar() {};` ); @@ -288,7 +289,7 @@ test('propagates a module that stops accepting in next version', async () => { didFullRefresh = didFullRefresh || !(await session.patch( - './bar.js', + 'bar.js', `window.logs.push('init BarV1.1'); export default function Bar() {};` )); await expect(session.logs).resolves.toEqual(['init BarV1.1']); @@ -299,7 +300,7 @@ test('propagates a module that stops accepting in next version', async () => { didFullRefresh = didFullRefresh || !(await session.patch( - './bar.js', + 'bar.js', // It's important we still export _something_, otherwise webpack will // also emit an extra update to the parent module. This happens because // webpack converts the module from ESM to CJS, which means the parent @@ -324,7 +325,7 @@ test('propagates a module that stops accepting in next version', async () => { didFullRefresh = didFullRefresh || !(await session.patch( - './bar.js', + 'bar.js', `window.logs.push('init BarV2'); export default function Bar() {};` )); // Since the export list changed, we have to re-run both the parent and the child. @@ -340,7 +341,7 @@ test('propagates a module that stops accepting in next version', async () => { didFullRefresh = didFullRefresh || !(await session.patch( - './bar.js', + 'bar.js', `window.logs.push('init BarV3'); export default function Bar() {};` )); await expect(session.logs).resolves.toEqual(['init BarV3']); @@ -352,7 +353,7 @@ test('propagates a module that stops accepting in next version', async () => { didFullRefresh = didFullRefresh || !(await session.patch( - './foo.js', + 'foo.js', ` if (typeof window !== 'undefined' && window.localStorage) { window.localStorage.setItem('init', 'init FooV2') From 2ab3897edd88303ad830577d94011036171378ad Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 16:28:00 +0800 Subject: [PATCH 09/10] feat: only invalidate new boundary if old boundary exists --- src/loader/RefreshModuleRuntime.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/loader/RefreshModuleRuntime.js b/src/loader/RefreshModuleRuntime.js index 009c38d2..9119058d 100644 --- a/src/loader/RefreshModuleRuntime.js +++ b/src/loader/RefreshModuleRuntime.js @@ -24,7 +24,7 @@ module.exports = function () { if (isHotUpdate) { if ( - !prevExports || + $RefreshUtils$.isReactRefreshBoundary(prevExports) && $RefreshUtils$.shouldInvalidateReactRefreshBoundary(prevExports, currentExports) ) { module.hot.invalidate(); From 0bd72e8b1a8cffee93ed5a31cc73cc2bd2ada8a7 Mon Sep 17 00:00:00 2001 From: Michael Mok Date: Mon, 18 May 2020 16:43:41 +0800 Subject: [PATCH 10/10] feat: only invalidate old boundary during hot updates --- src/loader/RefreshModuleRuntime.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/loader/RefreshModuleRuntime.js b/src/loader/RefreshModuleRuntime.js index 9119058d..b05e90e6 100644 --- a/src/loader/RefreshModuleRuntime.js +++ b/src/loader/RefreshModuleRuntime.js @@ -33,7 +33,7 @@ module.exports = function () { } } } else { - if ($RefreshUtils$.isReactRefreshBoundary(prevExports)) { + if (isHotUpdate && $RefreshUtils$.isReactRefreshBoundary(prevExports)) { module.hot.invalidate(); } }