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(hmr): adding hot module reloading #334

Merged
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
0830301
feat(hmr): adding hot module reloading
evilebottnawi Dec 13, 2018
875f166
Merge remote-tracking branch 'origin/hot-module-reloading' into hot-m…
ScriptedAlchemy Jan 6, 2019
7f99f5f
fix: remove unused dependency
ScriptedAlchemy Jan 6, 2019
4e72531
refactor: refactors based on PR comments
ScriptedAlchemy Jan 13, 2019
50b3fd8
refactor: move hot loader into base loader
ScriptedAlchemy Jan 13, 2019
c2207af
refactor: get hmr working with new context
ScriptedAlchemy Jan 24, 2019
1700774
test: added hmr test case
ScriptedAlchemy Jan 27, 2019
3f975cd
revert: roll back package upgrades
ScriptedAlchemy Jan 27, 2019
4c0c742
fix: updating dependencies
ScriptedAlchemy Jan 27, 2019
edf19e1
fix: remove console log
ScriptedAlchemy Jan 27, 2019
bac3930
test: fixing tests
ScriptedAlchemy Jan 27, 2019
86134d0
fix: use older version of normalize
ScriptedAlchemy Jan 27, 2019
8e6f14c
fix: remove vulnerabilities in depencencies
ScriptedAlchemy Jan 27, 2019
3979add
test: adding snapshot for hmr
ScriptedAlchemy Jan 28, 2019
3157c84
fix: moved es checker to dev dependencies
ScriptedAlchemy Jan 30, 2019
01126f8
docs: updated readme to detail hmr
ScriptedAlchemy Mar 3, 2019
2cf31af
fix: hmr on global styles
ScriptedAlchemy Mar 13, 2019
e5a5afb
fix: lock webpack version to avoid npm acorn issue
ScriptedAlchemy Mar 15, 2019
9ed9a85
fix: patching issue with global css missing source
ScriptedAlchemy Mar 26, 2019
2f94eed
fix: renaming hot to hmr
ScriptedAlchemy Mar 27, 2019
969514f
fix: remove unused method and using locals for css modules
ScriptedAlchemy Mar 27, 2019
b06d817
test: updating tests and snapshots
ScriptedAlchemy Mar 27, 2019
0aa328c
fix: using older version of normalize for older node support
ScriptedAlchemy Mar 27, 2019
be929ae
fix: removing reloadAll option
ScriptedAlchemy Mar 27, 2019
06e7789
fix: auto detect locals and reloadAll
ScriptedAlchemy Apr 4, 2019
a6bdafd
feat: adding order warning option
ScriptedAlchemy Apr 4, 2019
150f858
fix: added default option for orderWarning
ScriptedAlchemy Apr 4, 2019
a91857c
test: updating test expectations
ScriptedAlchemy Apr 4, 2019
55b6158
docs: updating readme about order warning flags
ScriptedAlchemy Apr 4, 2019
d9ebdc5
docs: updating docs about filtering warnings warningsFilter
ScriptedAlchemy Apr 4, 2019
6e6642a
feat(hmr): adding hot module reloading
evilebottnawi Dec 13, 2018
5a9fc85
fix: remove unused dependency
ScriptedAlchemy Jan 6, 2019
50144a7
refactor: refactors based on PR comments
ScriptedAlchemy Jan 13, 2019
39dd711
refactor: move hot loader into base loader
ScriptedAlchemy Jan 13, 2019
6126e7e
refactor: get hmr working with new context
ScriptedAlchemy Jan 24, 2019
cd6df8e
test: added hmr test case
ScriptedAlchemy Jan 27, 2019
bf0f745
revert: roll back package upgrades
ScriptedAlchemy Jan 27, 2019
d1cbf81
fix: updating dependencies
ScriptedAlchemy Jan 27, 2019
31bfa71
fix: remove console log
ScriptedAlchemy Jan 27, 2019
a51d862
test: fixing tests
ScriptedAlchemy Jan 27, 2019
2bd6661
fix: use older version of normalize
ScriptedAlchemy Jan 27, 2019
9803db3
fix: remove vulnerabilities in depencencies
ScriptedAlchemy Jan 27, 2019
c190436
test: adding snapshot for hmr
ScriptedAlchemy Jan 28, 2019
0c93cf0
fix: moved es checker to dev dependencies
ScriptedAlchemy Jan 30, 2019
c111e9b
docs: updated readme to detail hmr
ScriptedAlchemy Mar 3, 2019
936b8d3
fix: lock webpack version to avoid npm acorn issue
ScriptedAlchemy Mar 15, 2019
b08a3ce
fix: renaming hot to hmr
ScriptedAlchemy Mar 27, 2019
9f16d99
fix: remove unused method and using locals for css modules
ScriptedAlchemy Mar 27, 2019
79001b8
test: updating tests and snapshots
ScriptedAlchemy Mar 27, 2019
59797cc
fix: using older version of normalize for older node support
ScriptedAlchemy Mar 27, 2019
c149cc2
fix: removing reloadAll option
ScriptedAlchemy Mar 27, 2019
33109c4
fix: auto detect locals and reloadAll
ScriptedAlchemy Apr 4, 2019
6981838
feat: adding order warning option
ScriptedAlchemy Apr 4, 2019
2708a67
fix: added default option for orderWarning
ScriptedAlchemy Apr 4, 2019
4d1c75d
test: updating test expectations
ScriptedAlchemy Apr 4, 2019
15dd658
docs: updating readme about order warning flags
ScriptedAlchemy Apr 4, 2019
7f4159e
docs: updating docs about filtering warnings warningsFilter
ScriptedAlchemy Apr 4, 2019
641bdce
Merge branch 'master' into hot-module-reloading
ScriptedAlchemy Apr 8, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
27 changes: 17 additions & 10 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -43,8 +43,9 @@
"webpack": "^4.4.0"
},
"dependencies": {
"schema-utils": "^1.0.0",
"loader-utils": "^1.1.0",
"normalize-url": "^4.1.0",
"schema-utils": "^1.0.0",
"webpack-sources": "^1.1.0"
},
"devDependencies": {
Expand Down
23 changes: 23 additions & 0 deletions src/hmr/.eslintrc.js
@@ -0,0 +1,23 @@
module.exports = {
"parserOptions": {
"ecmaVersion": 5,
"sourceType": "module",
},
globals: {
document: true
},
plugins: ['prettier'],
extends: ['@webpack-contrib/eslint-config-webpack'],
rules: {
'prettier/prettier': [
'error',
{singleQuote: true, trailingComma: 'es5', arrowParens: 'avoid'},
],
'class-methods-use-this': 'off',
'no-undefined': 'off',
'func-style': ["error", "declaration", {"allowArrowFunctions": false}],
'prefer-rest-params': 0,
'prefer-spread': 0,
'prefer-destructuring': 0
},
};
ScriptedAlchemy marked this conversation as resolved.
Show resolved Hide resolved
33 changes: 33 additions & 0 deletions src/hmr/hotLoader.js
@@ -0,0 +1,33 @@
const path = require('path');

const loaderUtils = require('loader-utils');

const defaultOptions = {
fileMap: '{fileName}',
};

function hotLoader(content) {
this.cacheable();
ScriptedAlchemy marked this conversation as resolved.
Show resolved Hide resolved
const options = Object.assign(
{},
defaultOptions,
loaderUtils.getOptions(this)
);

const accept = options.cssModules
? ''
: 'module.hot.accept(undefined, cssReload);';
return `${content}
if(module.hot) {
// ${Date.now()}
var cssReload = require(${loaderUtils.stringifyRequest(
this,
path.join(__dirname, 'hotModuleReplacement.js')
)})(module.id, ${JSON.stringify(options)});
module.hot.dispose(cssReload);
${accept};
}
`;
}

module.exports = hotLoader;
ScriptedAlchemy marked this conversation as resolved.
Show resolved Hide resolved
142 changes: 142 additions & 0 deletions src/hmr/hotModuleReplacement.js
@@ -0,0 +1,142 @@
const normalizeUrl = require('normalize-url');

const srcByModuleId = Object.create(null);

const noDocument = typeof document === 'undefined';

function debounce(fn, time) {
let timeout;

// eslint-disable-next-line func-names
return function() {
const functionCall = () => fn.apply(this, arguments);

clearTimeout(timeout);
timeout = setTimeout(functionCall, time);
};
}

const forEach = Array.prototype.forEach;

function noop() {}

function getCurrentScriptUrl(moduleId) {
let src = srcByModuleId[moduleId];

if (!src) {
if (document.currentScript) {
src = document.currentScript.src;
} else {
const scripts = document.getElementsByTagName('script');
const lastScriptTag = scripts[scripts.length - 1];

if (lastScriptTag) {
src = lastScriptTag.src;
}
}
srcByModuleId[moduleId] = src;
}

return function(fileMap) {
const splitResult = /([^\\/]+)\.js$/.exec(src);
const filename = splitResult && splitResult[1];
if (!filename) {
return [src.replace('.js', '.css')];
}
return fileMap.split(',').map(mapRule => {
const reg = new RegExp(`${filename}\\.js$`, 'g');
return normalizeUrl(
src.replace(reg, `${mapRule.replace(/{fileName}/g, filename)}.css`),
{ stripWWW: false }
);
});
};
}

function updateCss(el, url) {
if (!url) {
url = el.href.split('?')[0];
}
if (el.isLoaded === false) {
// We seem to be about to replace a css link that hasn't loaded yet.
// We're probably changing the same file more than once.
return;
}
if (!url || !(url.indexOf('.css') > -1)) return;

el.visited = true;
const newEl = el.cloneNode();

newEl.isLoaded = false;

newEl.addEventListener('load', () => {
newEl.isLoaded = true;
el.parentNode.removeChild(el);
});

newEl.addEventListener('error', () => {
newEl.isLoaded = true;
el.parentNode.removeChild(el);
});

newEl.href = `${url}?${Date.now()}`;
el.parentNode.appendChild(newEl);
}

function getReloadUrl(href, src) {
href = normalizeUrl(href, { stripWWW: false });
let ret;
// eslint-disable-next-line array-callback-return
src.some(url => {
if (href.indexOf(src) > -1) {
ret = url;
}
});
return ret;
}

function reloadStyle(src) {
const elements = document.querySelectorAll('link');
let loaded = false;

forEach.call(elements, el => {
if (el.visited === true) return;

const url = getReloadUrl(el.href, src);
if (url) {
updateCss(el, url);
loaded = true;
}
});

return loaded;
}

function reloadAll() {
const elements = document.querySelectorAll('link');
forEach.call(elements, el => {
if (el.visited === true) return;
updateCss(el);
});
}

module.exports = function(moduleId, options) {
if (noDocument) {
return noop;
ScriptedAlchemy marked this conversation as resolved.
Show resolved Hide resolved
}

const getScriptSrc = getCurrentScriptUrl(moduleId);

function update() {
const src = getScriptSrc(options.fileMap);
const reloaded = reloadStyle(src);
if (reloaded && !options.reloadAll) {
console.log('[HMR] css reload %s', src.join(' '));
} else {
console.log('[HMR] Reload all css');
reloadAll();
}
}

return debounce(update, 10);
};
102 changes: 102 additions & 0 deletions src/index.js
@@ -1,6 +1,10 @@
import path from 'path';

import webpack from 'webpack';
import sources from 'webpack-sources';

const hotLoader = path.resolve(__dirname, './hmr/hotLoader.js');

const { ConcatSource, SourceMapSource, OriginalSource } = sources;
const {
Template,
Expand All @@ -15,6 +19,24 @@ const REGEXP_CHUNKHASH = /\[chunkhash(?::(\d+))?\]/i;
const REGEXP_CONTENTHASH = /\[contenthash(?::(\d+))?\]/i;
const REGEXP_NAME = /\[name\]/i;

const isHMR = (compiler) => {
if (compiler && compiler.options) {
if (compiler.options.devServer && compiler.options.devServer.hot) {
return true;
}

if (compiler.options.entry) {
const entry =
typeof compiler.options.entry === 'function'
? compiler.options.entry()
: compiler.options.entry;
const entryString = JSON.stringify(entry);
return entryString.includes('hot') || entryString.includes('hmr');
}
}
return false;
};
ScriptedAlchemy marked this conversation as resolved.
Show resolved Hide resolved

class CssDependency extends webpack.Dependency {
constructor(
{ identifier, content, media, sourceMap },
Expand Down Expand Up @@ -116,6 +138,8 @@ class MiniCssExtractPlugin {
},
options
);
const { cssModules, reloadAll } = this.options;

if (!this.options.chunkFilename) {
const { filename } = this.options;
const hasName = filename.includes('[name]');
Expand All @@ -132,9 +156,36 @@ class MiniCssExtractPlugin {
);
}
}

this.hotLoaderObject = Object.assign(
{
loader: hotLoader,
options: {
cssModules: false,
reloadAll: false,
},
},
{
options: {
cssModules,
reloadAll,
},
}
);
}

apply(compiler) {
try {
const isHOT = this.options.hot ? true : isHMR(compiler);

if (isHOT && compiler.options.module && compiler.options.module.rules) {
compiler.options.module.rules = this.updateWebpackConfig(
compiler.options.module.rules
);
}
} catch (e) {
throw new Error(`unknown config cannot inject HMR: ${e.stack || e}`);
}
compiler.hooks.thisCompilation.tap(pluginName, (compilation) => {
compilation.hooks.normalModuleLoader.tap(pluginName, (lc, m) => {
const loaderContext = lc;
Expand Down Expand Up @@ -393,6 +444,56 @@ class MiniCssExtractPlugin {
});
}

traverseDepthFirst(root, visit) {
ScriptedAlchemy marked this conversation as resolved.
Show resolved Hide resolved
let nodesToVisit = [root];

while (nodesToVisit.length > 0) {
const currentNode = nodesToVisit.shift();

if (currentNode !== null && typeof currentNode === 'object') {
const children = Object.values(currentNode);
nodesToVisit = [...children, ...nodesToVisit];
}

visit(currentNode);
}
}

updateWebpackConfig(initialRules) {
return initialRules.reduce((rules, rule) => {
this.traverseDepthFirst(rule, (node) => {
if (node && node.use && Array.isArray(node.use)) {
const isExtractCss = node.use.some((l) => {
const needle = l.loader || l;
if (typeof l === 'function') {
return false;
}
return needle.includes(pluginName);
});
if (isExtractCss) {
node.use.unshift(this.hotLoaderObject);
}
}
if (node && node.loader && Array.isArray(node.loader)) {
const isExtractCss = node.loader.some((l) => {
const needle = l.loader || l;
if (typeof l === 'function') {
return false;
}
return needle.includes(pluginName);
});
if (isExtractCss) {
node.loader.unshift(this.hotLoaderObject);
}
}
});

rules.push(rule);

return rules;
}, []);
}

getCssChunkObject(mainChunk) {
const obj = {};
for (const chunk of mainChunk.getAllAsyncChunks()) {
Expand Down Expand Up @@ -546,5 +647,6 @@ class MiniCssExtractPlugin {
}

MiniCssExtractPlugin.loader = require.resolve('./loader');
MiniCssExtractPlugin.hotLoader = require.resolve('./hmr/hotLoader');

export default MiniCssExtractPlugin;