Skip to content

Commit dcd0ea5

Browse files
committed
feat: add support the HMR for styles imported in JavaScript files
1 parent 0ad6fc4 commit dcd0ea5

40 files changed

Lines changed: 590 additions & 19 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Change log
22

3+
## 4.5.0 (2024-11-25)
4+
5+
- feat: add limited support the HMR for styles imported in JavaScript files
6+
- feat: add new `css.hot` option to enable HMR for styles
7+
38
## 4.4.3 (2024-11-23)
49

510
- fix: issue by inline a style when in the tag used single quotes for attribute

README.md

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ Advanced alternative to [html-webpack-plugin](https://github.com/jantimon/html-w
1717

1818
---
1919

20-
<div align="center">
20+
<h4 align="center">
2121
📋 <a href="#contents">Table of Contents</a> 🚀 <a href="#install">Install and Quick Start</a> 🖼 <a href="#usage-examples">Usage examples</a> 🔆 <a href="#whats-new">What's New</a>
22-
</div>
22+
</h4>
2323

2424
<!--
2525
#### 📋 [Table of Contents](#contents) 🚀 [Install and Quick Start](#install) 🖼 [Usage examples](#usage-examples)
@@ -44,6 +44,7 @@ Advanced alternative to [html-webpack-plugin](https://github.com/jantimon/html-w
4444
- `<img src="@images/pic.png" srcset="@images/pic400.png 1x, @images/pic800.png 2x" />`\
4545
Source files will be resolved, processed and auto-replaced with correct URLs in the bundled output.
4646
- **Inlines** [JS](#recipe-inline-js), [CSS](#recipe-inline-css) and [Images](#recipe-inline-image) into HTML. See [how to inline all resources](#recipe-inline-all-assets-to-html) into single HTML file.
47+
- [HMR for CSS](#option-css-hot) - update CSS in browser without a full reload.
4748
- Recompiles the template after changes in the [data file](#option-entry-data) assigned to the entry page as a JSON or JS filename.
4849
- Generates the [preload](#option-preload) tags for fonts, images, video, scripts, styles.
4950
- Generates the [integrity](#option-integrity) attribute in the `link` and `script` tags.
@@ -208,6 +209,7 @@ If you have discovered a bug or have a feature suggestion, feel free to create a
208209

209210
## 🔆 What's New in v4
210211

212+
- **NEW** added supports the [HMR for CSS](#option-css-hot) (since `v4.5.0`).
211213
- **NEW** added supports the [multiple configurations](https://webpack.js.org/configuration/configuration-types/#exporting-multiple-configurations).
212214
- **SUPPORTS** Webpack version `5.96+` (since `v4.2.0`).
213215
- **SUPPORTS** Webpack version `5.81+` (since `v4.0.0`).
@@ -1964,6 +1966,7 @@ type CssOptions = {
19641966
chunkFilename?: FilenameTemplate;
19651967
outputPath?: string;
19661968
inline?: 'auto' | boolean;
1969+
hot?: boolean;
19671970
};
19681971
```
19691972
@@ -1976,19 +1979,22 @@ Default properties:
19761979
chunkFilename: '[name].css',
19771980
outputPath: null,
19781981
inline: false,
1982+
hot: false,
19791983
}
19801984
```
19811985
19821986
- `test` - an RegEpx to process all source styles that pass test assertion
19831987
- `filename` - an output filename of extracted CSS. Details see by [filename option](#option-filename).
19841988
- `chunkFilename` - an output filename of non-initial chunk files, e.g., a style file imported in JavaScript.
19851989
- `outputPath` - an output path of extracted CSS. Details see by [outputPath option](#option-outputpath).
1986-
- `inline` - inlines extracted CSS into HTML, available values:
1990+
- `inline` - inject CSS into into HTML at render time, available values:
19871991
- `false` - stores CSS in an output file (**defaults**)
19881992
- `true` - adds CSS to the DOM by injecting a `<style>` tag
19891993
- `'auto'` - in `development` mode - adds to DOM, in `production` mode - stores as a file
1994+
- `hot` - inject CSS into the DOM at runtime and enable HMR (hot update CSS without a full reload),\
1995+
similar to how it works in [style-loader](https://github.com/webpack-contrib/style-loader).
19901996
1991-
All source style files specified in `<link href="..." rel="stylesheet">` are automatically resolved,
1997+
All source style files specified in `<link href="..." rel="stylesheet">` are automatically resolved,
19921998
and CSS will be extracted to output file. The source filename will be replaced with the output filename.
19931999
19942000
For example:
@@ -2022,13 +2028,53 @@ The `[name]` is the base filename of a loaded style.
20222028
For example, if source file is `style.scss`, then output filename will be `css/style.1234abcd.css`.\
20232029
If you want to have a different output filename, you can use the `filename` options as the [function](https://webpack.js.org/configuration/output/#outputfilename).
20242030
2031+
<a id="option-css-hot" name="option-css-hot"></a>
2032+
2033+
#### `css.hot` option
2034+
2035+
> ⚠️ Limitation
2036+
>
2037+
> - HMR works only for styles imported in JavaScript files. Doesn't works for styles defined directly in HTML via `link` tag.
2038+
> - Hot update without a full reload works only for styles imported in a last JavaScript file.\
2039+
> If you have many JS files defined in HTML, where are imported styles, and change a style file imported in the first JS file,
2040+
> then changes will not be detected in HMR module. You should reload the browser manually.
2041+
> This behaviour is a BUG in Webpack. The [style-loader](https://github.com/webpack-contrib/style-loader) has exactly same limitation.
2042+
>
2043+
2044+
If you use the [Live Reload](#setup-live-reload) configuration, then be sure to exclude the style files (CSS/SCSS) from watching,
2045+
otherwise after changes a style file, a page will be full reloaded.
2046+
2047+
> ℹ️ Note
2048+
>
2049+
> If `devServer` is configured for HRM with styles, then after changing the styles defined in HTML, `Live Reload` will not work for them.
2050+
> You should then reload the browser self.
2051+
2052+
Configuration of `devServer` to enable HMR:
2053+
2054+
```js
2055+
devServer: {
2056+
static: {
2057+
directory: path.join(__dirname, 'dist'),
2058+
},
2059+
watchFiles: {
2060+
paths: ['src/**/*.(html|eta)'], // <= exclude *.s?css from watching
2061+
options: {
2062+
usePolling: true,
2063+
},
2064+
},
2065+
},
2066+
```
2067+
2068+
**💡 Tip**: to enable HMR for all style files without a full reload, import all those styles in one JS file.
2069+
2070+
20252071
> ⚠️ **Warning**
20262072
>
20272073
> Don't use `mini-css-extract-plugin` because the bundler plugin extracts CSS much faster than other plugins.
20282074
>
20292075
> Don't use `resolve-url-loader` because the bundler plugin resolves all URLs in CSS, including assets from node modules.
20302076
>
2031-
> Don't use `style-loader` because the bundler plugin can auto inline CSS.
2077+
> Don't use `style-loader` because the bundler plugin can auto inline CSS and HMR.
20322078
20332079
#### [↑ back to contents](#contents)
20342080

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "html-bundler-webpack-plugin",
3-
"version": "4.4.3",
3+
"version": "4.5.0",
44
"description": "HTML Bundler Plugin for Webpack renders HTML templates containing source files of scripts, styles, images. Supports template engines: Eta, EJS, Handlebars, Nunjucks, Pug, TwigJS. Alternative to html-webpack-plugin.",
55
"keywords": [
66
"html",
@@ -25,7 +25,7 @@
2525
"javascript",
2626
"css",
2727
"scss",
28-
"style",
28+
"style-loader",
2929
"html-webpack-plugin"
3030
],
3131
"license": "ISC",

src/Loader/Hmr/hot-update.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
// To enable live reload, just add an empty JS file in HTML.
2-
// Webpack automatically inserts the hot update code into this file.
2+
// Webpack automatically inserts the runtime code into this file.

src/Loader/cssLoader.js

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const pitchLoader = async function (remaining) {
2929
const pluginCompiler = loaderContext._compilation.compiler;
3030
const pluginContext = PluginService.getPluginContext(pluginCompiler);
3131
const collection = pluginContext.collection;
32+
const isHmr = pluginContext.pluginOption.isCssHot();
3233

3334
// TODO: find the module from this._compilation, because this._module is deprecated
3435
const { resource, resourcePath, _module: module } = this;
@@ -49,8 +50,11 @@ const pitchLoader = async function (remaining) {
4950

5051
// defaults, the css-loader option `esModule` is `true`
5152
const esModule = result.default != null;
53+
const cssSource = esModule ? result.default : result;
5254
let styles;
5355

56+
collection.setImportStyleEsModule(esModule);
57+
5458
if (esModule) {
5559
const exports = Object.keys(result).filter((key) => key !== 'default');
5660

@@ -64,15 +68,62 @@ const pitchLoader = async function (remaining) {
6468
styles = result.locals;
6569
}
6670

67-
module._cssSource = esModule ? result.default : result;
68-
collection.setImportStyleEsModule(esModule);
71+
if (!isHmr) {
72+
module._cssSource = cssSource;
73+
}
6974

7075
// support for lazy load CSS in JavaScript, see the test js-import-css-lazy-url
7176
if (isUrl) {
72-
return exportComment + module._cssSource;
77+
return exportComment + cssSource;
78+
}
79+
80+
let hmrCode = '';
81+
82+
if (isHmr) {
83+
const css = result.default.toString().replaceAll('\n', '');
84+
85+
hmrCode = `
86+
const css = \`${css}\`;
87+
const isDocument = typeof document !== 'undefined';
88+
89+
if (!isDocument) {
90+
console.log('CSS HMR does not work!');
91+
}
92+
93+
if (isDocument && module.hot) {
94+
module.hot.accept(undefined, function () {
95+
// required to avoid full reload
96+
});
97+
98+
const key = '__bundlerCssHmr';
99+
100+
document[key] = document[key] || { idx: 1, styleIds: new Map() };
101+
const hmr = document[key];
102+
const moduleId = module.id;
103+
104+
let styleId = hmr.styleIds.get(moduleId);
105+
let styleElm;
106+
107+
if (styleId) {
108+
styleElm = document.getElementById(styleId);
109+
} else {
110+
styleId = 'hot-update-style-' + hmr.idx++;
111+
styleElm = document.createElement('style');
112+
styleElm.setAttribute('id', styleId);
113+
document.head.appendChild(styleElm);
114+
hmr.styleIds.set(moduleId, styleId);
115+
}
116+
117+
if (styleElm) {
118+
styleElm.innerText = css;
119+
}
120+
}
121+
`;
73122
}
74123

75-
return styles ? (esModule ? 'export default' : 'module.exports = ') + JSON.stringify(styles) : exportComment;
124+
return styles
125+
? (esModule ? 'export default' : 'module.exports = ') + JSON.stringify(styles)
126+
: exportComment + hmrCode;
76127
};
77128

78129
module.exports = loader;

src/Plugin/AssetCompiler.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -988,7 +988,7 @@ class AssetCompiler {
988988
}
989989

990990
// 2. renders styles imported in JavaScript
991-
if (this.collection.hasImportedStyle(this.currentEntryPoint?.id)) {
991+
if (!this.option.isCssHot() && this.collection.hasImportedStyle(this.currentEntryPoint?.id)) {
992992
this.renderImportStyles(result, { chunk });
993993
}
994994
}
@@ -1429,6 +1429,8 @@ class AssetCompiler {
14291429
resource,
14301430
filename: assetFile,
14311431
};
1432+
const isStyle = type === 'style';
1433+
14321434
this.resolver.setContext(this.currentEntryPoint, issuer);
14331435

14341436
const vmScript = new VMScript({
@@ -1441,11 +1443,11 @@ class AssetCompiler {
14411443

14421444
// the css-loader defaults generate ESM code, which must be transformed into CommonJS to compile the code
14431445
// the template loader generates CommonJS code, no need to transform
1444-
const esModule = type === 'style' || loaderOptions.esModule === true;
1446+
const esModule = isStyle || loaderOptions.esModule === true;
14451447
let result = vmScript.exec(source.source(), { filename: sourceFile, esModule });
14461448

1447-
if (type === 'style') {
1448-
result = this.cssExtractModule.apply(result);
1449+
if (isStyle) {
1450+
return this.cssExtractModule.apply(result);
14491451
}
14501452

14511453
return result;

src/Plugin/Messages/Exception.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,10 @@ const optionPreloadAsException = (config, type, availableTypes) => {
7474
* @param {string} file The unresolved file can be absolute or relative.
7575
* @param {string} issuer The absolute issuer file of unresolved file.
7676
* @param {string} rootContext The absolute path to project files.
77-
* @param {object} pluginOptions The instance of the pluginOptions.
77+
* @param {object} pluginOption The instance of the plugin Option.
7878
* @throws {Error}
7979
*/
80-
const resolveException = (file, issuer, rootContext, pluginOptions) => {
80+
const resolveException = (file, issuer, rootContext, pluginOption) => {
8181
let isExistsFile = true;
8282
issuer = path.relative(rootContext, issuer);
8383

@@ -104,7 +104,7 @@ const resolveException = (file, issuer, rootContext, pluginOptions) => {
104104
},
105105
],
106106
},`;
107-
} else if (pluginOptions.isStyle(file) && hasSplitChunksCacheGroups(pluginOptions.webpackOptions)) {
107+
} else if (pluginOption.isStyle(file) && hasSplitChunksCacheGroups(pluginOption.webpackOptions)) {
108108
message += `\n
109109
${whiteBright.bgGreen` Tip `}
110110
Add the ${white`'splitChunks.cacheGroups.{cacheGroup}.test'`} option as a RegExp to each cache group to split only script files, excluding styles.

src/Plugin/Option.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class Option {
1717
context = '';
1818
testEntry = null;
1919
compiler = null;
20+
devServerHot = false;
2021

2122
js = {
2223
test: /\.(js|ts|jsx|tsx|mjs|cjs|mts|cts)$/,
@@ -34,6 +35,7 @@ class Option {
3435
chunkFilename: undefined,
3536
outputPath: undefined,
3637
inline: false,
38+
hot: false,
3739
};
3840

3941
#entryLibrary = {
@@ -128,6 +130,7 @@ class Option {
128130

129131
css.enabled = this.toBool(css.enabled, true, this.css.enabled);
130132
css.inline = this.toBool(css.inline, false, this.css.inline);
133+
131134
if (!css.outputPath) css.outputPath = options.output.path;
132135

133136
if (!css.chunkFilename) {
@@ -231,6 +234,13 @@ class Option {
231234

232235
this.initEntry(this.loaderPath);
233236
this.enableLibraryType();
237+
238+
if (options.devServer) {
239+
// default value of the `hot` is `true`
240+
// https://webpack.js.org/configuration/dev-server/#devserverhot
241+
const hot = options.devServer?.hot;
242+
this.devServerHot = (hot == null || hot === true || hot === 'only') && !this.isProduction();
243+
}
234244
}
235245

236246
/**
@@ -319,6 +329,24 @@ class Option {
319329
return this.productionMode;
320330
}
321331

332+
/**
333+
* Returns the value of the `devServer.hot` webpack option.
334+
* @return {boolean}
335+
*/
336+
isDevServerHot() {
337+
return this.devServerHot;
338+
}
339+
340+
/**
341+
* Whether HMR for CSS is available.
342+
*
343+
* @return {boolean}
344+
*/
345+
346+
isCssHot() {
347+
return this.options.css.hot && this.devServerHot;
348+
}
349+
322350
/**
323351
* @return {boolean}
324352
*/
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"scripts": {
3+
"start": "webpack serve --mode development",
4+
"watch": "webpack watch --mode development",
5+
"build": "webpack --mode=production --progress"
6+
},
7+
"devDependencies": {
8+
"html-bundler-webpack-plugin": "file:../../.."
9+
}
10+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import timer from './timer.js';
2+
3+
import './timer.css';
4+
//import './style-a.css';
5+
//import './style-b.css';
6+
7+
timer('#timer');

0 commit comments

Comments
 (0)