Skip to content

Commit d54e87d

Browse files
fix(client): an empty overlay, and an indicator that never left (#2437)
Two states the page could get stuck in. An overlay with nothing in it, covering the app, dismissable only by hand. A source reporting an empty list of problems is how a source says it has nothing, but it was kept as a slot, so the union of every source's problems was non-empty and the card mounted empty. Worse once another source cleared: the card stayed, showing only its dismiss hint, on page `-1`. An empty list is now the source having nothing, and the overlay closes when no source has anything left. The bundled client guards its own call sites, so this is reached through `webpack-dev-middleware/client/overlay`, which is a published export. A building indicator that stayed up for good after a multi-compiler build. A progress payload carries no compilation name, so the client attributed it to whichever compilation most recently started building — and in a multi-compiler build a payload from a still-running compilation could re-mark a sibling that had already finished, and would never report again, as building. Progress now reports on the build that is running (`indicator.update`) rather than starting one, so the badge goes away once every compilation that started has reported back. Claude-Session: https://claude.ai/code/session_01UjuMAuk9o6UazjHzcAQCTA Co-authored-by: Claude Opus 5 <[email protected]>
1 parent f35a596 commit d54e87d

7 files changed

Lines changed: 235 additions & 11 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"webpack-dev-middleware": patch
3+
---
4+
5+
Fixed an empty overlay that covered the page and could only be dismissed by
6+
hand. A source reporting an empty list of problems — which is how a source
7+
says it has nothing — was kept as a slot, so the union of every source's
8+
problems was non-empty and the card mounted with nothing in it. Worse when
9+
another source then cleared: the overlay stayed, showing only its dismiss
10+
hint, on page `-1`. An empty list is now the source having nothing, and the
11+
overlay closes when no source has anything left.
12+
13+
Fixed the building indicator staying on the page for good after a
14+
multi-compiler build. A progress payload carries no compilation name, so the
15+
client attributed it to whichever compilation most recently started building —
16+
and a payload from a still-running compilation could re-mark a sibling that
17+
had already finished, and would never report again, as building. Progress now
18+
reports on the build that is running instead of starting one, so the badge
19+
goes away once every compilation that started has reported back.

‎client-src/index.js‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -495,10 +495,6 @@ let customHandler;
495495
/** @type {((obj: HMRPayload) => void) | undefined} */
496496
let subscribeAllHandler;
497497

498-
// Name of the build that most recently reported `building` — progress
499-
// payloads carry no name, so they are attributed to it.
500-
let lastBuildingName = "";
501-
502498
/**
503499
* @param {HMRPayload} obj payload
504500
*/
@@ -518,11 +514,12 @@ function processMessage(obj) {
518514
reporter.clearRuntimeProblems();
519515
}
520516
if (options.progress && typeof document !== "undefined") {
521-
lastBuildingName = obj.name || "";
517+
// Named, so the badge stays until every compilation that started has
518+
// reported back — a sibling finishing is not this one finishing.
522519
indicator.show(
523520
obj.file ? `Rebuilding… (${obj.file})` : undefined,
524521
undefined,
525-
lastBuildingName,
522+
obj.name || "",
526523
);
527524
}
528525
sendMessage("Invalid");
@@ -542,13 +539,13 @@ function processMessage(obj) {
542539
break;
543540
}
544541
case "progress": {
545-
// Progress payloads carry no name — attribute them to the build that
546-
// most recently reported `building`.
542+
// Reported rather than shown: a progress payload carries no name, so it
543+
// cannot say whose build it is, and in a multi-compiler build one
544+
// compilation's progress arrives while a sibling has already finished.
547545
if (options.progress && typeof document !== "undefined") {
548-
indicator.show(
546+
indicator.update(
549547
`Rebuilding… ${obj.percent}%${obj.message ? ` (${obj.message})` : ""}`,
550548
obj.percent,
551-
lastBuildingName,
552549
);
553550
}
554551
sendMessage("Progress", obj);

‎client-src/indicator.js‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,34 @@ function showBar(percent) {
368368
*/
369369
export function show(text, percent, source = "") {
370370
state.building[source] = true;
371+
render(text, percent);
372+
}
373+
374+
/**
375+
* Report what the running build is doing, without starting one.
376+
*
377+
* A progress payload carries no compilation name, so it cannot say whose
378+
* build it belongs to. Letting it start one meant a payload from a
379+
* still-running compilation could re-mark a sibling that had already finished
380+
* — and would never report again — as building, leaving the badge on the page
381+
* for good. Nothing is building means there is nothing to report about.
382+
* @param {string=} text label text
383+
* @param {number=} percent compilation progress (0-100)
384+
*/
385+
export function update(text, percent) {
386+
if (Object.keys(state.building).length === 0) {
387+
return;
388+
}
389+
390+
render(text, percent);
391+
}
392+
393+
/**
394+
* Draw the badge (or the bar) as it currently is.
395+
* @param {string=} text label text
396+
* @param {number=} percent compilation progress (0-100)
397+
*/
398+
function render(text, percent) {
371399
ensureIndicator();
372400

373401
if (state.type === "linear") {

‎client-src/overlay.js‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,14 @@ function computeProblemsUnion() {
364364
const slots = [];
365365

366366
for (const source of Object.keys(state.problemsBySource)) {
367-
slots.push(state.problemsBySource[source]);
367+
const slot = state.problemsBySource[source];
368+
369+
// A slot with nothing in it is not something to show. `showProblems`
370+
// below no longer stores one, but a copy of this module from an older
371+
// version of the package shares this state and still can.
372+
if (slot && slot.lines.length > 0) {
373+
slots.push(slot);
374+
}
368375
}
369376

370377
if (slots.length === 0) {
@@ -819,6 +826,16 @@ render = renderProblems;
819826
* slot and the overlay renders the union of every slot
820827
*/
821828
export function showProblems(type, lines, source = "") {
829+
// Nothing to report is not something to report. Kept as a slot, it made the
830+
// union non-empty, which put an empty card on the page for the reader to
831+
// dismiss by hand — and, once another source cleared, left the page covered
832+
// by an overlay with no problems in it at all.
833+
if (lines.length === 0) {
834+
clear(source);
835+
836+
return;
837+
}
838+
822839
state.problemsBySource[source] = { type, lines: [...lines] };
823840

824841
const union =

‎test/e2e/indicator.test.js‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,36 @@ describe("building indicator (browser)", () => {
9292
expect(texts.some((text) => text.includes("%"))).toBe(true);
9393
});
9494

95+
it("goes away when every compilation of a multi-compiler build finishes", async () => {
96+
// Two compilations publishing progress into one stream, and a progress
97+
// payload carries no name to say whose it is. The guarantee is the same
98+
// as for one: once nothing is building, nothing is on the page.
99+
hotApp = await createHotApp({
100+
hot: { progress: true },
101+
apps: [
102+
{ name: "a", code: acceptedApp("a1") },
103+
{ name: "b", code: acceptedApp("b1") },
104+
],
105+
});
106+
({ page, browser } = await runBrowser());
107+
108+
await page.goto(`${hotApp.url}page/a`);
109+
await waitForAppText(page, "a1");
110+
await installBadgeSampler(page);
111+
112+
hotApp.edit("a", acceptedApp("a2"));
113+
await waitForAppText(page, "a2");
114+
hotApp.edit("b", acceptedApp("b2"));
115+
116+
expect(await page.evaluate(() => globalThis.__badgeSeen)).toBe(true);
117+
118+
await page.waitForFunction(
119+
(id) => document.getElementById(id) === null,
120+
{ timeout: 60000 },
121+
INDICATOR_ID,
122+
);
123+
});
124+
95125
it("never appears when progress=false", async () => {
96126
hotApp = await createHotApp({
97127
query: "?progress=false",
@@ -252,6 +282,63 @@ describe("indicator shared state across bundled copies (browser)", () => {
252282
({ browser, app: hotApp } = await closeE2e(browser, hotApp));
253283
});
254284

285+
// A progress payload carries no compilation name, so the client cannot say
286+
// whose build it belongs to. It must therefore report on the build that is
287+
// running rather than start one, or in a multi-compiler build a payload
288+
// from one compilation re-marks a sibling that already finished — and will
289+
// never report again — as building, and the badge never leaves the page.
290+
it("does not let progress restart a build that already finished", async () => {
291+
await start();
292+
await page.goto(hotApp.url);
293+
294+
const present = () =>
295+
page.evaluate((id) => Boolean(document.getElementById(id)), INDICATOR_ID);
296+
297+
await page.evaluate(() => {
298+
globalThis.indicatorA.show("Rebuilding…", undefined, "a");
299+
globalThis.indicatorB.show("Rebuilding…", undefined, "b");
300+
});
301+
302+
expect(await present()).toBe(true);
303+
304+
// `b` finishes; `a` is still going, so the badge stays.
305+
await page.evaluate(() => {
306+
globalThis.indicatorB.hide("b");
307+
});
308+
309+
expect(await present()).toBe(true);
310+
311+
// `a`'s progress arrives. It says nothing about who it belongs to.
312+
await page.evaluate(() => {
313+
globalThis.indicatorA.update("Rebuilding… 50%", 50);
314+
});
315+
316+
// ... and now `a` finishes too. Nothing is building any more.
317+
await page.evaluate(() => {
318+
globalThis.indicatorA.hide("a");
319+
});
320+
321+
expect(await present()).toBe(false);
322+
});
323+
324+
it("reports nothing when no build is running", async () => {
325+
await start();
326+
await page.goto(hotApp.url);
327+
328+
await page.evaluate(() => {
329+
globalThis.indicatorA.update("Rebuilding… 50%", 50);
330+
});
331+
332+
// A payload with no build behind it has nothing to report about, and
333+
// putting a badge up for it would leave one nothing takes down.
334+
expect(
335+
await page.evaluate(
336+
(id) => Boolean(document.getElementById(id)),
337+
INDICATOR_ID,
338+
),
339+
).toBe(false);
340+
});
341+
255342
it("stops a sweep when motion is declined mid-build, and recovers", async () => {
256343
await start();
257344
await page.goto(hotApp.url);

‎test/e2e/overlay.test.js‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1505,6 +1505,67 @@ describe("overlay shared state across bundled copies (browser)", () => {
15051505
);
15061506
});
15071507

1508+
// An empty list is what a source says when it has nothing to report. Kept
1509+
// as a slot it made the union non-empty, so the overlay mounted with
1510+
// nothing in it and stayed there for the reader to dismiss by hand.
1511+
it("shows nothing for a source that reports no problems", async () => {
1512+
await start();
1513+
await page.goto(hotApp.url);
1514+
1515+
await page.evaluate(() => {
1516+
globalThis.overlayA.showProblems("errors", [], "a");
1517+
});
1518+
1519+
await new Promise((resolve) => {
1520+
setTimeout(resolve, 500);
1521+
});
1522+
1523+
expect(await page.$(`#${OVERLAY_ID}`)).toBeNull();
1524+
});
1525+
1526+
it("takes an empty list as the source having nothing left", async () => {
1527+
await start();
1528+
await page.goto(hotApp.url);
1529+
1530+
await page.evaluate(() => {
1531+
globalThis.overlayA.showProblems("errors", ["boom"], "a");
1532+
});
1533+
await overlayFrame();
1534+
1535+
await page.evaluate(() => {
1536+
globalThis.overlayA.showProblems("errors", [], "a");
1537+
});
1538+
await waitForNoOverlay(page);
1539+
1540+
expect(await page.$(`#${OVERLAY_ID}`)).toBeNull();
1541+
});
1542+
1543+
it("leaves no empty overlay behind when the last real source clears", async () => {
1544+
await start();
1545+
await page.goto(hotApp.url);
1546+
1547+
const state = await page.evaluate(() => {
1548+
globalThis.overlayA.showProblems("errors", ["real problem"], "a");
1549+
globalThis.overlayB.showProblems("warnings", [], "b");
1550+
globalThis.overlayA.clear("a");
1551+
1552+
const shared = globalThis.__webpack_dev_middleware_hot_overlay_state__;
1553+
1554+
return {
1555+
pageIndex: shared.pageIndex,
1556+
currentProblems: shared.currentProblems,
1557+
attached: Boolean(shared.frame && shared.frame.parentNode),
1558+
};
1559+
});
1560+
1561+
// Before, `b`'s empty slot kept the union alive: the card stayed on the
1562+
// page showing only its dismiss hint, on page `-1`.
1563+
expect(state.attached).toBe(false);
1564+
expect(state.currentProblems).toBeNull();
1565+
expect(state.pageIndex).toBe(0);
1566+
expect(await page.$(`#${OVERLAY_ID}`)).toBeNull();
1567+
});
1568+
15081569
it("dismisses on Escape pressed inside the overlay frame", async () => {
15091570
await start();
15101571
await page.goto(hotApp.url);

‎types/client/indicator.d.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@ export function show(
1111
percent?: number | undefined,
1212
source?: string | undefined,
1313
): void;
14+
/**
15+
* Report what the running build is doing, without starting one.
16+
*
17+
* A progress payload carries no compilation name, so it cannot say whose
18+
* build it belongs to. Letting it start one meant a payload from a
19+
* still-running compilation could re-mark a sibling that had already finished
20+
* — and would never report again — as building, leaving the badge on the page
21+
* for good. Nothing is building means there is nothing to report about.
22+
* @param {string=} text label text
23+
* @param {number=} percent compilation progress (0-100)
24+
*/
25+
export function update(
26+
text?: string | undefined,
27+
percent?: number | undefined,
28+
): void;
1429
/**
1530
* Mark one source's build as finished, or remove the indicator entirely.
1631
* @param {string=} source when given, only that source is dropped and the

0 commit comments

Comments
 (0)