Skip to content

Commit 4aaafdd

Browse files
committed
feat(client): share the building indicator and track builds by source (#2369)
The badge state lives in a window singleton (same pattern as the overlay), so a second bundled copy of the module drives the same badge instead of stacking a duplicate, and fields missing from another version's state are filled in place. show() and hide() take an optional source: each concurrent build keeps the badge alive until every source finished, so in a MultiCompiler one bundle's `built` no longer hides the badge while a sibling is still compiling — and with two clients sharing the badge, one client's hide() cannot drop the other's indication. hide() without a source still removes the badge unconditionally. Progress payloads carry no name, so the client attributes them to the most recent `building` event.
1 parent a14f2a4 commit 4aaafdd

5 files changed

Lines changed: 251 additions & 55 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,18 @@ show("Rebuilding… 42%", 42); // progress ring
459459
hide();
460460
```
461461

462+
The badge is a per-page singleton shared by every bundled copy of the module.
463+
Concurrent builds can report through a `source` — the badge stays until every
464+
source finished:
465+
466+
```js
467+
show("Rebuilding app…", undefined, "app");
468+
show("Rebuilding admin…", undefined, "admin");
469+
hide("app"); // still shown — "admin" is building
470+
hide("admin"); // removed
471+
hide(); // without a source: removed unconditionally
472+
```
473+
462474
## HMR notes and troubleshooting
463475

464476
### Browser connection limits (many tabs)

client-src/index.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,10 @@ let customHandler;
418418
/** @type {((obj: HMRPayload) => void) | undefined} */
419419
let subscribeAllHandler;
420420

421+
// Name of the build that most recently reported `building` — progress
422+
// payloads carry no name, so they are attributed to it.
423+
let lastBuildingName = "";
424+
421425
/**
422426
* @param {HMRPayload} obj payload
423427
*/
@@ -430,23 +434,31 @@ function processMessage(obj) {
430434
}`,
431435
);
432436
if (options.progress && typeof document !== "undefined") {
433-
indicator.show(obj.file ? `Rebuilding… (${obj.file})` : undefined);
437+
lastBuildingName = obj.name || "";
438+
indicator.show(
439+
obj.file ? `Rebuilding… (${obj.file})` : undefined,
440+
undefined,
441+
lastBuildingName,
442+
);
434443
}
435444
break;
436445
}
437446
case "progress": {
447+
// Progress payloads carry no name — attribute them to the build that
448+
// most recently reported `building`.
438449
if (options.progress && typeof document !== "undefined") {
439450
indicator.show(
440451
`Rebuilding… ${obj.percent}%${obj.message ? ` (${obj.message})` : ""}`,
441452
obj.percent,
453+
lastBuildingName,
442454
);
443455
}
444456
break;
445457
}
446458
case "built":
447459
case "sync": {
448460
if (options.progress && typeof document !== "undefined") {
449-
indicator.hide();
461+
indicator.hide(obj.name || "");
450462
}
451463
if (obj.action === "built") {
452464
log.info(

client-src/indicator.js

Lines changed: 112 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,60 @@ const SVG_NS = "http://www.w3.org/2000/svg";
99
// Circumference of the progress ring (r = 6).
1010
const RING_LENGTH = 2 * Math.PI * 6;
1111

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-
2312
// eslint-disable-next-line jsdoc/reject-any-type
2413
/** @typedef {any} EXPECTED_ANY */
2514

15+
/**
16+
* @typedef {object} IndicatorState
17+
* @property {HTMLElement | null} host badge host element
18+
* @property {HTMLElement | null} label label inside the badge
19+
* @property {HTMLElement | null} dot pulsing dot (indeterminate mode)
20+
* @property {SVGSVGElement | null} ring progress ring (determinate mode)
21+
* @property {SVGCircleElement | null} ringValue ring value circle
22+
* @property {Record<string, true>} building sources with a build in progress — the badge hides only when every source finished
23+
*/
24+
25+
/** @returns {IndicatorState} fresh indicator state */
26+
function createIndicatorState() {
27+
return {
28+
host: null,
29+
label: null,
30+
dot: null,
31+
ring: null,
32+
ringValue: null,
33+
building: {},
34+
};
35+
}
36+
37+
// Shared through `window` so every bundled copy of this module drives one
38+
// single badge instead of stacking duplicates (same pattern as the overlay).
39+
const INDICATOR_STATE_KEY = "__webpack_dev_middleware_hot_indicator_state__";
40+
41+
/** @type {IndicatorState} */
42+
const state = (() => {
43+
if (typeof window === "undefined") {
44+
return createIndicatorState();
45+
}
46+
47+
const holder = /** @type {EXPECTED_ANY} */ (window);
48+
49+
if (!holder[INDICATOR_STATE_KEY]) {
50+
holder[INDICATOR_STATE_KEY] = createIndicatorState();
51+
} else {
52+
// Fill fields another package version may not have created, in place.
53+
const defaults = createIndicatorState();
54+
55+
for (const key of Object.keys(defaults)) {
56+
if (!(key in holder[INDICATOR_STATE_KEY])) {
57+
holder[INDICATOR_STATE_KEY][key] =
58+
defaults[/** @type {keyof IndicatorState} */ (key)];
59+
}
60+
}
61+
}
62+
63+
return holder[INDICATOR_STATE_KEY];
64+
})();
65+
2666
/**
2767
* @param {EXPECTED_ANY} element element
2868
* @param {Record<string, string | number>} style style map
@@ -37,25 +77,25 @@ function applyStyle(element, style) {
3777
* Create (or reuse) the indicator host element.
3878
*/
3979
function ensureIndicator() {
40-
if (host && host.parentNode) {
80+
if (state.host && state.host.parentNode) {
4181
return;
4282
}
4383

4484
if (!document.body) {
4585
return;
4686
}
4787

48-
host = document.createElement("div");
49-
host.id = INDICATOR_ID;
50-
applyStyle(host, {
88+
state.host = document.createElement("div");
89+
state.host.id = INDICATOR_ID;
90+
applyStyle(state.host, {
5191
position: "fixed",
5292
right: "16px",
5393
bottom: "16px",
5494
zIndex: 9999,
5595
pointerEvents: "none",
5696
});
5797

58-
const root = host.attachShadow({ mode: "open" });
98+
const root = state.host.attachShadow({ mode: "open" });
5999

60100
const badge = document.createElement("div");
61101
applyStyle(badge, {
@@ -72,26 +112,26 @@ function ensureIndicator() {
72112
});
73113

74114
// Indeterminate mode: a pulsing dot.
75-
dot = document.createElement("span");
76-
applyStyle(dot, {
115+
state.dot = document.createElement("span");
116+
applyStyle(state.dot, {
77117
width: "8px",
78118
height: "8px",
79119
borderRadius: "50%",
80120
background: theme.accent,
81121
});
82122

83123
// 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 }], {
124+
if (typeof state.dot.animate === "function") {
125+
state.dot.animate([{ opacity: 1 }, { opacity: 0.2 }, { opacity: 1 }], {
86126
duration: 1000,
87127
iterations: Number.POSITIVE_INFINITY,
88128
});
89129
}
90130

91131
// 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" });
132+
state.ring = document.createElementNS(SVG_NS, "svg");
133+
state.ring.setAttribute("viewBox", "0 0 16 16");
134+
applyStyle(state.ring, { width: "14px", height: "14px", display: "none" });
95135

96136
const track = document.createElementNS(SVG_NS, "circle");
97137
track.setAttribute("cx", "8");
@@ -101,72 +141,91 @@ function ensureIndicator() {
101141
track.setAttribute("stroke", "rgba(255,255,255,0.25)");
102142
track.setAttribute("stroke-width", "2.5");
103143

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));
144+
state.ringValue = document.createElementNS(SVG_NS, "circle");
145+
state.ringValue.setAttribute("cx", "8");
146+
state.ringValue.setAttribute("cy", "8");
147+
state.ringValue.setAttribute("r", "6");
148+
state.ringValue.setAttribute("fill", "none");
149+
state.ringValue.setAttribute("stroke", theme.accent);
150+
state.ringValue.setAttribute("stroke-width", "2.5");
151+
state.ringValue.setAttribute("stroke-linecap", "round");
152+
state.ringValue.setAttribute("stroke-dasharray", String(RING_LENGTH));
153+
state.ringValue.setAttribute("stroke-dashoffset", String(RING_LENGTH));
114154
// Start the ring at 12 o'clock.
115-
ringValue.setAttribute("transform", "rotate(-90 8 8)");
155+
state.ringValue.setAttribute("transform", "rotate(-90 8 8)");
116156

117-
ring.append(track, ringValue);
157+
state.ring.append(track, state.ringValue);
118158

119-
label = document.createElement("span");
120-
badge.append(dot, ring, label);
159+
state.label = document.createElement("span");
160+
badge.append(state.dot, state.ring, state.label);
121161
root.append(badge);
122-
document.body.append(host);
162+
document.body.append(state.host);
123163
}
124164

125165
/**
126166
* Show the indicator (idempotent). With a percent the badge renders a
127167
* progress ring; without one it renders a pulsing dot.
128168
* @param {string=} text label text
129169
* @param {number=} percent compilation progress (0-100)
170+
* @param {string=} source who is building (e.g. a compilation name, or a
171+
* client sharing the badge) — the badge stays until every source finished
130172
*/
131-
export function show(text, percent) {
173+
export function show(text, percent, source = "") {
174+
state.building[source] = true;
132175
ensureIndicator();
133176

134-
if (!label) {
177+
if (!state.label) {
135178
return;
136179
}
137180

138-
label.textContent = text || "Rebuilding…";
181+
state.label.textContent = text || "Rebuilding…";
139182

140183
const determinate = typeof percent === "number";
141184

142-
/** @type {HTMLElement} */ (dot).style.display = determinate
185+
/** @type {HTMLElement} */ (state.dot).style.display = determinate
143186
? "none"
144187
: "inline-block";
145-
/** @type {SVGSVGElement} */ (ring).style.display = determinate
188+
/** @type {SVGSVGElement} */ (state.ring).style.display = determinate
146189
? "block"
147190
: "none";
148191

149192
if (determinate) {
150193
const clamped = Math.min(100, Math.max(0, percent));
151194

152-
/** @type {SVGCircleElement} */ (ringValue).setAttribute(
195+
/** @type {SVGCircleElement} */ (state.ringValue).setAttribute(
153196
"stroke-dashoffset",
154197
String(RING_LENGTH * (1 - clamped / 100)),
155198
);
156199
}
157200
}
158201

159202
/**
160-
* Remove the indicator from the page.
203+
* Mark one source's build as finished, or remove the indicator entirely.
204+
* @param {string=} source when given, only that source is dropped and the
205+
* badge stays while any other source is still building; without it the badge
206+
* is removed unconditionally
161207
*/
162-
export function hide() {
163-
if (host && host.parentNode) {
164-
host.remove();
208+
export function hide(source) {
209+
if (source !== undefined) {
210+
if (!Object.prototype.hasOwnProperty.call(state.building, source)) {
211+
return;
212+
}
213+
214+
delete state.building[source];
215+
216+
if (Object.keys(state.building).length > 0) {
217+
return;
218+
}
219+
}
220+
221+
if (state.host && state.host.parentNode) {
222+
state.host.remove();
165223
}
166224

167-
host = null;
168-
label = null;
169-
dot = null;
170-
ring = null;
171-
ringValue = null;
225+
state.host = null;
226+
state.label = null;
227+
state.dot = null;
228+
state.ring = null;
229+
state.ringValue = null;
230+
state.building = {};
172231
}

test/client.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ describe("client", () => {
9393
delete globalThis.EventSource;
9494
delete globalThis.__wdmEventSourceWrapper;
9595
delete globalThis.__webpack_dev_middleware_hot_reporter__;
96+
delete globalThis.__webpack_dev_middleware_hot_indicator_state__;
9697
jest.useRealTimers();
9798
});
9899

@@ -892,6 +893,39 @@ describe("client", () => {
892893

893894
expect(document.getElementById(INDICATOR_ID)).toBeNull();
894895
});
896+
897+
it("keeps the badge until every compilation finished", () => {
898+
loadClient();
899+
const es = EventSourceStub.lastInstance();
900+
901+
es.onmessage(makeMessage({ action: "building", name: "app" }));
902+
es.onmessage(makeMessage({ action: "building", name: "admin" }));
903+
904+
es.onmessage(
905+
makeMessage({
906+
action: "built",
907+
name: "app",
908+
time: 1,
909+
hash: "h1",
910+
errors: [],
911+
warnings: [],
912+
}),
913+
);
914+
// "admin" is still building — its `built` has not arrived yet.
915+
expect(document.getElementById(INDICATOR_ID)).not.toBeNull();
916+
917+
es.onmessage(
918+
makeMessage({
919+
action: "built",
920+
name: "admin",
921+
time: 1,
922+
hash: "h2",
923+
errors: [],
924+
warnings: [],
925+
}),
926+
);
927+
expect(document.getElementById(INDICATOR_ID)).toBeNull();
928+
});
895929
});
896930

897931
describe("connection lifecycle", () => {

0 commit comments

Comments
 (0)