Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for HMR Invalidation API #89

Merged
merged 10 commits into from
May 18, 2020
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