Skip to content

Commit

Permalink
Merge pull request #89 from pmmmwh/feat/hot-invalidate
Browse files Browse the repository at this point in the history
  • Loading branch information
pmmmwh committed May 18, 2020
2 parents 57a0da1 + 0bd72e8 commit f6139f2
Show file tree
Hide file tree
Showing 6 changed files with 84 additions and 70 deletions.
15 changes: 8 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,27 +52,27 @@
"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",
"jest-environment-node": "^26.0.1",
"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",
Expand All @@ -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"
Expand Down
33 changes: 22 additions & 11 deletions src/loader/RefreshModuleRuntime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
$RefreshUtils$.isReactRefreshBoundary(prevExports) &&
$RefreshUtils$.shouldInvalidateReactRefreshBoundary(prevExports, currentExports)
) {
module.hot.invalidate();
} else {
$RefreshUtils$.enqueueUpdate();
}
}
} else {
if (isHotUpdate && $RefreshUtils$.isReactRefreshBoundary(prevExports)) {
module.hot.invalidate();
}
$RefreshUtils$.enqueueUpdate();
}
}
};
33 changes: 15 additions & 18 deletions src/runtime/refreshUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -57,15 +57,15 @@ function createHotDisposeCallback(module) {
}

// We have to mutate the data object to get data registered and cached
data.module = module;
data.prevExports = moduleExports;
}

return hotDisposeCallback;
}

/**
* 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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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%');
Expand Down Expand Up @@ -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;
Expand All @@ -228,6 +224,7 @@ module.exports = Object.freeze({
createHotDisposeCallback,
createHotErrorHandler,
enqueueUpdate: createDebounceUpdate(),
getModuleExports,
isReactRefreshBoundary,
shouldInvalidateReactRefreshBoundary,
registerExportsForReactRefresh,
Expand Down
66 changes: 33 additions & 33 deletions test/conformance/ReactRefreshRequire.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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; };`
);

Expand All @@ -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']);
Expand All @@ -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']);
Expand All @@ -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(
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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();
Expand All @@ -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*
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -265,18 +266,17 @@ 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
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() {};`
);

Expand All @@ -289,7 +289,7 @@ test.skip('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']);
Expand All @@ -300,7 +300,7 @@ test.skip('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
Expand All @@ -325,7 +325,7 @@ test.skip('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.
Expand All @@ -341,7 +341,7 @@ test.skip('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']);
Expand All @@ -353,7 +353,7 @@ test.skip('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')
Expand Down
5 changes: 5 additions & 0 deletions test/sandbox/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const sleep = (ms) => {
* @property {function(string, string): Promise<boolean>} patch
* @property {function(string): Promise<void>} remove
* @property {function(*): Promise<*>} evaluate
* @property {function(): Promise<void>} reload
*/

const rootSandboxDir = path.join(__dirname, '..', '__tmp__');
Expand Down Expand Up @@ -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<void>} */
async reload() {
await page.reload({ waitUntil: 'networkidle2' });
},
},
cleanupSandbox,
];
Expand Down
Loading

0 comments on commit f6139f2

Please sign in to comment.