@@ -303,3 +303,190 @@ describe("error overlay (browser)", () => {
303303 expect ( await page . $ ( `#${ OVERLAY_ID } ` ) ) . toBeNull ( ) ;
304304 } ) ;
305305} ) ;
306+
307+ describe ( "overlay shared state across bundled copies (browser)" , ( ) => {
308+ const OVERLAY_ENTRY = require . resolve ( "../../client-src/overlay.js" ) ;
309+ const OVERLAY_STATE_KEY = "__webpack_dev_middleware_hot_overlay_state__" ;
310+ const CARD_ID = `${ OVERLAY_ID } -card` ;
311+
312+ let hotApp ;
313+ let browser ;
314+ let page ;
315+
316+ /**
317+ * Two real bundled copies of the overlay module — one per compilation —
318+ * exposed as globals so the tests can drive both from the page.
319+ * @param {string } globalName global to expose the copy under
320+ * @returns {string } app source
321+ */
322+ const exposeOverlay = ( globalName ) =>
323+ `globalThis.${ globalName } = require(${ JSON . stringify ( OVERLAY_ENTRY ) } );` ;
324+
325+ const start = async ( ) => {
326+ hotApp = await createHotApp ( {
327+ // overlay=false keeps the real hot clients from reporting into the
328+ // overlay these tests drive themselves.
329+ query : "?overlay=false" ,
330+ apps : [
331+ { name : "a" , code : exposeOverlay ( "overlayA" ) } ,
332+ { name : "b" , code : exposeOverlay ( "overlayB" ) } ,
333+ ] ,
334+ } ) ;
335+ ( { page, browser } = await runBrowser ( ) ) ;
336+ } ;
337+
338+ /**
339+ * @returns {Promise<import("puppeteer").Frame> } the overlay iframe's frame
340+ */
341+ const overlayFrame = async ( ) => {
342+ const handle = await page . waitForSelector ( `#${ OVERLAY_ID } ` , {
343+ timeout : 30000 ,
344+ } ) ;
345+
346+ return handle . contentFrame ( ) ;
347+ } ;
348+
349+ afterEach ( async ( ) => {
350+ // try/finally: a rejected browser.close() must not leak the watcher,
351+ // the server, and the temp dir behind it.
352+ try {
353+ if ( browser ) {
354+ await browser . close ( ) ;
355+ }
356+ } finally {
357+ browser = undefined ;
358+ if ( hotApp ) {
359+ const closing = hotApp ;
360+ hotApp = undefined ;
361+ await closing . close ( ) ;
362+ }
363+ }
364+ } ) ;
365+
366+ it ( "shows the problems of two copies in the same overlay" , async ( ) => {
367+ await start ( ) ;
368+ await page . goto ( hotApp . url ) ;
369+
370+ await page . evaluate ( ( ) => {
371+ globalThis . overlayA . showProblems ( "errors" , [ "boom from copy A" ] ) ;
372+ globalThis . overlayB . showProblems ( "errors" , [ "boom from copy B" ] , "b" ) ;
373+ } ) ;
374+
375+ // One iframe: the second copy adopted it instead of stacking another,
376+ // and both sources are paginated together in the union.
377+ expect (
378+ await page . evaluate ( ( ) => document . querySelectorAll ( "iframe" ) . length ) ,
379+ ) . toBe ( 1 ) ;
380+
381+ const frame = await overlayFrame ( ) ;
382+
383+ expect ( await frame . evaluate ( ( ) => document . body . textContent ) ) . toContain (
384+ "boom from copy A" ,
385+ ) ;
386+ expect ( await frame . evaluate ( ( ) => document . body . textContent ) ) . toContain (
387+ "1 / 2" ,
388+ ) ;
389+
390+ await frame . click ( '[aria-label="Next problem"]' ) ;
391+ await frame . waitForFunction ( ( ) =>
392+ document . body . textContent . includes ( "boom from copy B" ) ,
393+ ) ;
394+
395+ // The state is shared both ways: dismissing from the first copy removes
396+ // the overlay the second copy rendered into.
397+ await page . evaluate ( ( ) => globalThis . overlayA . clear ( ) ) ;
398+ expect (
399+ await page . evaluate ( ( ) => document . querySelectorAll ( "iframe" ) . length ) ,
400+ ) . toBe ( 0 ) ;
401+ } ) ;
402+
403+ it ( "prefers one copy's errors over another copy's warnings" , async ( ) => {
404+ await start ( ) ;
405+ await page . goto ( hotApp . url ) ;
406+
407+ await page . evaluate ( ( ) => {
408+ globalThis . overlayA . showProblems ( "errors" , [ "boom from copy A" ] ) ;
409+ globalThis . overlayB . showProblems (
410+ "warnings" ,
411+ [ "careful from copy B" ] ,
412+ "b" ,
413+ ) ;
414+ } ) ;
415+
416+ const frame = await overlayFrame ( ) ;
417+ const errorRed = "rgb(255, 51, 72)" ;
418+ const warningYellow = "rgb(255, 211, 14)" ;
419+
420+ expect ( await frame . evaluate ( ( ) => document . body . textContent ) ) . toContain (
421+ "boom from copy A" ,
422+ ) ;
423+ expect ( await frame . evaluate ( ( ) => document . body . textContent ) ) . not . toContain (
424+ "careful from copy B" ,
425+ ) ;
426+ expect (
427+ await frame . evaluate (
428+ ( id ) => document . getElementById ( id ) . style . borderTopColor ,
429+ CARD_ID ,
430+ ) ,
431+ ) . toBe ( errorRed ) ;
432+
433+ // Once the erroring source recovers, the warnings surface.
434+ await page . evaluate ( ( ) => globalThis . overlayA . clear ( "" ) ) ;
435+ await frame . waitForFunction ( ( ) =>
436+ document . body . textContent . includes ( "careful from copy B" ) ,
437+ ) ;
438+ expect (
439+ await frame . evaluate (
440+ ( id ) => document . getElementById ( id ) . style . borderTopColor ,
441+ CARD_ID ,
442+ ) ,
443+ ) . toBe ( warningYellow ) ;
444+ } ) ;
445+
446+ it ( "does not re-render when a copy clears a source that reported nothing" , async ( ) => {
447+ await start ( ) ;
448+ await page . goto ( hotApp . url ) ;
449+
450+ await page . evaluate ( ( ) => {
451+ globalThis . overlayA . showProblems ( "errors" , [ "a" , "b" ] , "x" ) ;
452+ } ) ;
453+
454+ const frame = await overlayFrame ( ) ;
455+
456+ await frame . evaluate ( ( id ) => {
457+ globalThis . __cardChild = document . getElementById ( id ) . firstElementChild ;
458+ } , CARD_ID ) ;
459+
460+ // What the reporter does on every clean build of its own bundle.
461+ await page . evaluate ( ( ) => globalThis . overlayB . clear ( "never-reported" ) ) ;
462+
463+ // Same DOM nodes — the card the other copy is showing was not rebuilt.
464+ expect (
465+ await frame . evaluate (
466+ ( id ) =>
467+ globalThis . __cardChild ===
468+ document . getElementById ( id ) . firstElementChild ,
469+ CARD_ID ,
470+ ) ,
471+ ) . toBe ( true ) ;
472+ expect ( await page . $ ( `#${ OVERLAY_ID } ` ) ) . not . toBeNull ( ) ;
473+ } ) ;
474+
475+ it ( "fills state fields missing from an older copy's shape" , async ( ) => {
476+ await start ( ) ;
477+ // An older package version created a leaner shared state before the
478+ // bundles load.
479+ await page . evaluateOnNewDocument ( ( key ) => {
480+ globalThis [ key ] = { frame : null , card : null } ;
481+ } , OVERLAY_STATE_KEY ) ;
482+ await page . goto ( hotApp . url ) ;
483+
484+ await page . evaluate ( ( ) => {
485+ globalThis . overlayA . showProblems ( "errors" , [ "boom" ] , "newer" ) ;
486+ } ) ;
487+
488+ expect (
489+ await page . evaluate ( ( ) => document . querySelectorAll ( "iframe" ) . length ) ,
490+ ) . toBe ( 1 ) ;
491+ } ) ;
492+ } ) ;
0 commit comments