Skip to content

Commit ea8362a

Browse files
committed
feat(hot): add a building indicator with optional compilation progress (#2358)
The client shows a small badge (shadow DOM, CSSOM-only styles) while a rebuild is in progress, enabled by default (disable with `?progress=false` on the client entry). When the server enables the new `hot.progress` option, webpack's ProgressPlugin publishes throttled `progress` events over SSE (deduplicated by rounded percent, reset on each new build) and the badge renders a CSP-safe SVG progress ring with the compilation percentage. The palette is shared with the error overlay through a common theme module, and the indicator is exposed as a standalone subpath export (webpack-dev-middleware/client/indicator) so other tooling can reuse it. Both the indicator and the overlay now bail out safely when `document.body` does not exist yet. Ref webpack/webpack-hot-middleware#167
1 parent a9a8808 commit ea8362a

13 files changed

Lines changed: 470 additions & 19 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,13 @@ Default: `10000`
344344

345345
Heartbeat interval (in milliseconds) used to keep the SSE connection alive when no compilation events are produced.
346346

347+
#### `hot.progress`
348+
349+
Type: `Boolean`
350+
Default: `undefined`
351+
352+
Publish compilation progress events (`{ action: "progress", percent, message }`) to the clients using webpack's `ProgressPlugin`. The bundled client shows the percentage in its building badge (see the client `progress` option).
353+
347354
#### `hot.statsOptions`
348355

349356
Type: `Boolean | Object`
@@ -384,6 +391,7 @@ entry: [
384391
| `logging` | `string` | `"info"` | Logger level — one of `"none"`, `"error"`, `"warn"`, `"info"`, `"log"`, `"verbose"`. Uses webpack's runtime logger. |
385392
| `name` | `string` | `""` | Restrict updates to a specific compilation name (useful with multi-compiler). |
386393
| `autoConnect` | `boolean` | `true` | Connect on load; set to `false` and call `setOptionsAndConnect()` manually. |
394+
| `progress` | `boolean` | `true` | Show a small badge in the page while a rebuild is in progress (with the compilation percentage when the server enables `hot.progress`). Set to `false` to disable. |
387395
| `dynamicPublicPath` | `boolean` | `false` | Prefix `path` with `__webpack_public_path__` at runtime. The leading slash of `path` is stripped and no other normalization is applied, so the public path should end with `/`. |
388396

389397
### Programmatic API
@@ -441,6 +449,16 @@ overlay.showProblems("errors", ["Something broke"]);
441449
overlay.clear();
442450
```
443451

452+
The building indicator is exposed the same way:
453+
454+
```js
455+
import { hide, show } from "webpack-dev-middleware/client/indicator";
456+
457+
show("Rebuilding…"); // pulsing dot
458+
show("Rebuilding… 42%", 42); // progress ring
459+
hide();
460+
```
461+
444462
## API
445463

446464
`webpack-dev-middleware` also provides convenience methods that can be use to

client-src/index.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* global __resourceQuery, __webpack_public_path__ */
22

3+
import * as indicator from "./indicator.js";
34
import configureOverlay from "./overlay.js";
45
import applyUpdate from "./process-update.js";
56
import { log, setLogLevel } from "./utils/log.js";
@@ -31,6 +32,7 @@ import stripAnsi from "./utils/strip-ansi.js";
3132
* @property {LogLevel} logging logger level
3233
* @property {string} name limit updates to this compilation name
3334
* @property {boolean} autoConnect connect immediately when the entry runs
35+
* @property {boolean} progress show a small badge while a rebuild is in progress
3436
*/
3537

3638
/** @type {ClientOptions} */
@@ -42,6 +44,7 @@ const options = {
4244
logging: "info",
4345
name: "",
4446
autoConnect: true,
47+
progress: true,
4548
};
4649

4750
/**
@@ -112,6 +115,10 @@ function setOverrides(overrides) {
112115
options.name = overrides.name;
113116
}
114117

118+
if (overrides.progress) {
119+
options.progress = overrides.progress !== "false";
120+
}
121+
115122
if (overrides.dynamicPublicPath) {
116123
// `path` is appended like a filename (no leading slash); the public path
117124
// itself is not normalized.
@@ -253,7 +260,7 @@ export function disconnect() {
253260
// eslint-disable-next-line jsdoc/reject-any-type
254261
/** @typedef {any} EXPECTED_ANY */
255262

256-
/** @typedef {{ name?: string, errors: string[], warnings: string[], hash: string, time?: number, action?: string, file?: string }} HMRPayload */
263+
/** @typedef {{ name?: string, errors: string[], warnings: string[], hash: string, time?: number, action?: string, file?: string, percent?: number, message?: string }} HMRPayload */
257264

258265
/**
259266
* @returns {{
@@ -420,10 +427,25 @@ function processMessage(obj) {
420427
obj.file ? ` (${obj.file} changed)` : ""
421428
}`,
422429
);
430+
if (options.progress && typeof document !== "undefined") {
431+
indicator.show(obj.file ? `Rebuilding… (${obj.file})` : undefined);
432+
}
433+
break;
434+
}
435+
case "progress": {
436+
if (options.progress && typeof document !== "undefined") {
437+
indicator.show(
438+
`Rebuilding… ${obj.percent}%${obj.message ? ` (${obj.message})` : ""}`,
439+
obj.percent,
440+
);
441+
}
423442
break;
424443
}
425444
case "built":
426445
case "sync": {
446+
if (options.progress && typeof document !== "undefined") {
447+
indicator.hide();
448+
}
427449
if (obj.action === "built") {
428450
log.info(
429451
`bundle ${obj.name ? `'${obj.name}' ` : ""}rebuilt in ${obj.time}ms`,

client-src/indicator.js

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Small badge shown while a rebuild is in progress. It lives in a shadow root
2+
// so page styles cannot affect it; styles go through the CSSOM and SVG
3+
// presentation attributes, which a strict `style-src` CSP allows.
4+
5+
import theme from "./theme.js";
6+
7+
const INDICATOR_ID = "webpack-dev-middleware-building-indicator";
8+
const SVG_NS = "http://www.w3.org/2000/svg";
9+
// Circumference of the progress ring (r = 6).
10+
const RING_LENGTH = 2 * Math.PI * 6;
11+
12+
/** @type {HTMLElement | null} */
13+
let host = null;
14+
/** @type {HTMLElement | null} */
15+
let label = null;
16+
/** @type {HTMLElement | null} */
17+
let dot = null;
18+
/** @type {SVGSVGElement | null} */
19+
let ring = null;
20+
/** @type {SVGCircleElement | null} */
21+
let ringValue = null;
22+
23+
// eslint-disable-next-line jsdoc/reject-any-type
24+
/** @typedef {any} EXPECTED_ANY */
25+
26+
/**
27+
* @param {EXPECTED_ANY} element element
28+
* @param {Record<string, string | number>} style style map
29+
*/
30+
function applyStyle(element, style) {
31+
for (const key of Object.keys(style)) {
32+
element.style[key] = style[key];
33+
}
34+
}
35+
36+
/**
37+
* Create (or reuse) the indicator host element.
38+
*/
39+
function ensureIndicator() {
40+
if (host && host.parentNode) {
41+
return;
42+
}
43+
44+
if (!document.body) {
45+
return;
46+
}
47+
48+
host = document.createElement("div");
49+
host.id = INDICATOR_ID;
50+
applyStyle(host, {
51+
position: "fixed",
52+
right: "16px",
53+
bottom: "16px",
54+
zIndex: 9999,
55+
pointerEvents: "none",
56+
});
57+
58+
const root = host.attachShadow({ mode: "open" });
59+
60+
const badge = document.createElement("div");
61+
applyStyle(badge, {
62+
display: "flex",
63+
alignItems: "center",
64+
gap: "8px",
65+
background: theme.panelTranslucent,
66+
color: theme.text,
67+
fontFamily: "Menlo, Consolas, 'Courier New', monospace",
68+
fontSize: "12px",
69+
padding: "6px 12px",
70+
borderRadius: "16px",
71+
boxShadow: "0 2px 12px rgba(0,0,0,0.35)",
72+
});
73+
74+
// Indeterminate mode: a pulsing dot.
75+
dot = document.createElement("span");
76+
applyStyle(dot, {
77+
width: "8px",
78+
height: "8px",
79+
borderRadius: "50%",
80+
background: theme.accent,
81+
});
82+
83+
// Pulse through the Web Animations API — no <style> element involved.
84+
if (typeof dot.animate === "function") {
85+
dot.animate([{ opacity: 1 }, { opacity: 0.2 }, { opacity: 1 }], {
86+
duration: 1000,
87+
iterations: Number.POSITIVE_INFINITY,
88+
});
89+
}
90+
91+
// Determinate mode: a progress ring drawn with SVG presentation attributes.
92+
ring = document.createElementNS(SVG_NS, "svg");
93+
ring.setAttribute("viewBox", "0 0 16 16");
94+
applyStyle(ring, { width: "14px", height: "14px", display: "none" });
95+
96+
const track = document.createElementNS(SVG_NS, "circle");
97+
track.setAttribute("cx", "8");
98+
track.setAttribute("cy", "8");
99+
track.setAttribute("r", "6");
100+
track.setAttribute("fill", "none");
101+
track.setAttribute("stroke", "rgba(255,255,255,0.25)");
102+
track.setAttribute("stroke-width", "2.5");
103+
104+
ringValue = document.createElementNS(SVG_NS, "circle");
105+
ringValue.setAttribute("cx", "8");
106+
ringValue.setAttribute("cy", "8");
107+
ringValue.setAttribute("r", "6");
108+
ringValue.setAttribute("fill", "none");
109+
ringValue.setAttribute("stroke", theme.accent);
110+
ringValue.setAttribute("stroke-width", "2.5");
111+
ringValue.setAttribute("stroke-linecap", "round");
112+
ringValue.setAttribute("stroke-dasharray", String(RING_LENGTH));
113+
ringValue.setAttribute("stroke-dashoffset", String(RING_LENGTH));
114+
// Start the ring at 12 o'clock.
115+
ringValue.setAttribute("transform", "rotate(-90 8 8)");
116+
117+
ring.append(track, ringValue);
118+
119+
label = document.createElement("span");
120+
badge.append(dot, ring, label);
121+
root.append(badge);
122+
document.body.append(host);
123+
}
124+
125+
/**
126+
* Show the indicator (idempotent). With a percent the badge renders a
127+
* progress ring; without one it renders a pulsing dot.
128+
* @param {string=} text label text
129+
* @param {number=} percent compilation progress (0-100)
130+
*/
131+
export function show(text, percent) {
132+
ensureIndicator();
133+
134+
if (!label) {
135+
return;
136+
}
137+
138+
label.textContent = text || "Rebuilding…";
139+
140+
const determinate = typeof percent === "number";
141+
142+
/** @type {HTMLElement} */ (dot).style.display = determinate
143+
? "none"
144+
: "inline-block";
145+
/** @type {SVGSVGElement} */ (ring).style.display = determinate
146+
? "block"
147+
: "none";
148+
149+
if (determinate) {
150+
const clamped = Math.min(100, Math.max(0, percent));
151+
152+
/** @type {SVGCircleElement} */ (ringValue).setAttribute(
153+
"stroke-dashoffset",
154+
String(RING_LENGTH * (1 - clamped / 100)),
155+
);
156+
}
157+
}
158+
159+
/**
160+
* Remove the indicator from the page.
161+
*/
162+
export function hide() {
163+
if (host && host.parentNode) {
164+
host.remove();
165+
}
166+
167+
host = null;
168+
label = null;
169+
dot = null;
170+
ring = null;
171+
ringValue = null;
172+
}

client-src/overlay.js

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import ansiHTML from "ansi-html-community";
22

3+
import theme from "./theme.js";
4+
35
// eslint-disable-next-line jsdoc/reject-any-type
46
/** @typedef {any} EXPECTED_ANY */
57

@@ -51,16 +53,15 @@ const backdropStyles = {
5153
height: "100vh",
5254
border: "none",
5355
zIndex: 9999,
54-
// webpack "Outer Space" (#2B3A42), translucent.
55-
background: "rgba(43,58,66,0.72)",
56+
background: theme.backdrop,
5657
};
5758

5859
/** @type {Record<string, string | number>} */
5960
const styles = {
6061
// Dark panel; the top accent bar color is set per problem type in showProblems.
6162
position: "relative",
62-
background: "#101619",
63-
color: "#f2f2f2",
63+
background: theme.panel,
64+
color: theme.text,
6465
lineHeight: "1.6",
6566
whiteSpace: "pre-wrap",
6667
fontFamily: "Menlo, Consolas, 'Courier New', monospace",
@@ -99,7 +100,7 @@ const closeButtonStyles = {
99100
right: "12px",
100101
border: "none",
101102
background: "transparent",
102-
color: "#999999",
103+
color: theme.muted,
103104
fontSize: "22px",
104105
lineHeight: "1",
105106
cursor: "pointer",
@@ -247,7 +248,7 @@ function highlightFilePath(html) {
247248
(match, filePath, location) => {
248249
if (!location) {
249250
return (
250-
'<span style="color:#8dd6f9; text-decoration:underline; ' +
251+
`<span style="color:${theme.accent}; text-decoration:underline; ` +
251252
`text-underline-offset:2px;">${match}</span>`
252253
);
253254
}
@@ -256,15 +257,15 @@ function highlightFilePath(html) {
256257
const position = location.trim().replace(/^:/, "");
257258

258259
return (
259-
'<span style="color:#8dd6f9; cursor:pointer; ' +
260+
`<span style="color:${theme.accent}; cursor:pointer; ` +
260261
'text-decoration:underline; text-underline-offset:2px;" ' +
261262
`data-open-file="${filePath}:${position}" ` +
262263
'title="Click to open in your editor">' +
263264
`${filePath}</span>${location}\n`
264265
);
265266
}
266267

267-
return `<span style="color:#8dd6f9;">${filePath}</span>${location}\n`;
268+
return `<span style="color:${theme.accent};">${filePath}</span>${location}\n`;
268269
},
269270
);
270271
}
@@ -282,7 +283,7 @@ function linkify(html) {
282283
const href = url.slice(0, url.length - cut.length);
283284
return (
284285
`<a href="${href}" target="_blank" rel="noopener noreferrer" ` +
285-
`style="color:#8dd6f9;">${href}</a>${cut}`
286+
`style="color:${theme.accent};">${href}</a>${cut}`
286287
);
287288
});
288289
}
@@ -304,6 +305,10 @@ function ensureOverlay() {
304305
return overlayCard;
305306
}
306307

308+
if (!document.body) {
309+
return null;
310+
}
311+
307312
// Enable Trusted Types if they are available in the current browser.
308313
if (window.trustedTypes && !trustedTypesPolicy) {
309314
trustedTypesPolicy = window.trustedTypes.createPolicy(
@@ -570,7 +575,7 @@ function renderProblems() {
570575
marginTop: "4px",
571576
paddingTop: "16px",
572577
borderTop: "1px solid #465e69",
573-
color: "#999999",
578+
color: theme.muted,
574579
fontSize: "13px",
575580
});
576581
hint.textContent = paginated

client-src/theme.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Shared palette used by the error overlay and the building indicator.
2+
export default {
3+
// Dark panel and its translucent variant.
4+
panel: "#101619",
5+
panelTranslucent: "rgba(16,22,25,0.92)",
6+
// webpack "Outer Space" (#2B3A42), translucent.
7+
backdrop: "rgba(43,58,66,0.72)",
8+
text: "#f2f2f2",
9+
muted: "#999999",
10+
accent: "#8dd6f9",
11+
};

0 commit comments

Comments
 (0)