@@ -28,44 +28,72 @@ function createMockRpc(entries: DevToolsDockEntry[] = []): DevToolsRpcClient {
2828}
2929
3030/**
31- * The frame-nav adapter listens for `message` events on `globalThis`. These
32- * tests run in the node environment (no DOM), so stub the listener registry and
33- * drive it with synthetic events — this exercises the viewer wiring we own
34- * (auto-attach, member materialization, the nav loop) without a DOM dependency.
31+ * A `message` listener registry standing in for one window realm. Tests drive it
32+ * with synthetic events instead of real `postMessage`.
3533 */
36- function stubMessageBus ( ) {
34+ function createMessageRegistry ( ) {
3735 const listeners = new Set < ( ev : any ) => void > ( )
38- const origAdd = ( globalThis as any ) . addEventListener
39- const origRemove = ( globalThis as any ) . removeEventListener ;
40- ( globalThis as any ) . addEventListener = ( type : string , cb : any ) => {
41- if ( type === 'message' )
42- listeners . add ( cb )
43- }
44- ; ( globalThis as any ) . removeEventListener = ( type : string , cb : any ) => {
45- if ( type === 'message' )
46- listeners . delete ( cb )
47- }
4836 return {
37+ addEventListener ( type : string , cb : any ) {
38+ if ( type === 'message' )
39+ listeners . add ( cb )
40+ } ,
41+ removeEventListener ( type : string , cb : any ) {
42+ if ( type === 'message' )
43+ listeners . delete ( cb )
44+ } ,
4945 emit ( data : unknown , origin = 'http://localhost:5173' ) {
5046 for ( const cb of [ ...listeners ] ) cb ( { origin, data } )
5147 } ,
5248 get size ( ) {
5349 return listeners . size
5450 } ,
51+ }
52+ }
53+
54+ /**
55+ * The frame-nav adapter listens for `message` events on the iframe's own realm,
56+ * falling back to `globalThis`. These tests run in the node environment (no DOM),
57+ * so stub the global listener registry and drive it with synthetic events — this
58+ * exercises the viewer wiring we own (auto-attach, member materialization, the
59+ * nav loop) without a DOM dependency.
60+ */
61+ function stubMessageBus ( ) {
62+ const registry = createMessageRegistry ( )
63+ const origAdd = ( globalThis as any ) . addEventListener
64+ const origRemove = ( globalThis as any ) . removeEventListener ;
65+ ( globalThis as any ) . addEventListener = registry . addEventListener
66+ ; ( globalThis as any ) . removeEventListener = registry . removeEventListener
67+ return {
68+ emit : registry . emit ,
69+ get size ( ) {
70+ return registry . size
71+ } ,
5572 restore ( ) {
5673 ; ( globalThis as any ) . addEventListener = origAdd
5774 ; ( globalThis as any ) . removeEventListener = origRemove
5875 } ,
5976 }
6077}
6178
62- function makeFakeIframe ( src : string ) {
79+ /**
80+ * A window realm other than the main one — what the dock sees when it is mounted
81+ * inside a Document Picture-in-Picture popup.
82+ */
83+ function makeFakeRealm ( ) {
84+ return createMessageRegistry ( )
85+ }
86+
87+ function makeFakeIframe ( src : string , realm ?: ReturnType < typeof makeFakeRealm > ) {
6388 const posted : { msg : any , origin : string } [ ] = [ ]
6489 const iframe = {
6590 src,
6691 contentWindow : {
6792 postMessage : ( msg : any , origin : string ) => posted . push ( { msg, origin } ) ,
6893 } ,
94+ // Left undefined unless a realm is given, so the adapter falls back to
95+ // `globalThis` exactly as it does for the plain single-realm tests.
96+ ownerDocument : realm ? { defaultView : realm } : undefined ,
6997 }
7098 return { iframe : iframe as unknown as HTMLIFrameElement , posted }
7199}
@@ -235,6 +263,80 @@ describe('shared-iframe soft navigation', () => {
235263 } )
236264} )
237265
266+ // Each dock shell (float, edge, popup) owns its own `IframePanes` manager and
267+ // creates panes in its own document. Popup mode is Document Picture-in-Picture,
268+ // so the anchor iframe lives in the popup's realm and its shim posts the ready
269+ // handshake there — an adapter listening on the main window never hears it, and
270+ // the group renders with no members.
271+ describe ( 'frame-nav across window realms' , ( ) => {
272+ let bus : ReturnType < typeof stubMessageBus > | undefined
273+
274+ afterEach ( ( ) => {
275+ bus ?. restore ( )
276+ bus = undefined
277+ } )
278+
279+ it ( 'listens on the iframe\'s own realm, not the main window' , async ( ) => {
280+ bus = stubMessageBus ( )
281+ const popup = makeFakeRealm ( )
282+ const context = await createDocksContext ( 'embedded' , createMockRpc ( [ anchorEntry ( ) ] ) )
283+ const { iframe } = makeFakeIframe ( ANCHOR_URL , popup )
284+
285+ context . docks . getStateById ( 'nuxt' ) ! . domElements . iframe = iframe
286+ await nextTick ( )
287+
288+ expect ( popup . size ) . toBe ( 1 )
289+ expect ( bus . size ) . toBe ( 0 )
290+
291+ // The main window hearing the manifest must change nothing.
292+ bus . emit ( frameMessage ( 'ready' , { tabs : READY_TABS , current : 'modules' } ) )
293+ expect ( context . docks . entries . map ( e => e . id ) ) . not . toContain ( 'nuxt:modules' )
294+
295+ popup . emit ( frameMessage ( 'ready' , { tabs : READY_TABS , current : 'modules' } ) )
296+
297+ const ids = context . docks . entries . map ( e => e . id )
298+ expect ( ids ) . toContain ( 'nuxt:modules' )
299+ expect ( ids ) . toContain ( 'nuxt:timeline' )
300+ expect ( context . docks . selectedId ) . toBe ( 'nuxt:modules' )
301+ } )
302+
303+ it ( 're-attaches to the new iframe when the dock remounts in another realm' , async ( ) => {
304+ bus = stubMessageBus ( )
305+ const first = makeFakeRealm ( )
306+ const context = await createDocksContext ( 'embedded' , createMockRpc ( [ anchorEntry ( ) ] ) )
307+ const { iframe : iframe1 , posted : posted1 } = makeFakeIframe ( ANCHOR_URL , first )
308+
309+ const state = context . docks . getStateById ( 'nuxt' ) !
310+ state . domElements . iframe = iframe1
311+ await nextTick ( )
312+ first . emit ( frameMessage ( 'ready' , { tabs : READY_TABS , current : 'modules' } ) )
313+ expect ( context . docks . selectedId ) . toBe ( 'nuxt:modules' )
314+
315+ // Switching shells mounts a brand-new iframe element in a different document.
316+ const second = makeFakeRealm ( )
317+ const { iframe : iframe2 , posted : posted2 } = makeFakeIframe ( ANCHOR_URL , second )
318+ posted1 . length = 0
319+ state . domElements . iframe = iframe2
320+ await nextTick ( )
321+
322+ // The stale adapter is torn down and a fresh handshake runs against iframe #2.
323+ expect ( first . size ) . toBe ( 0 )
324+ expect ( second . size ) . toBe ( 1 )
325+ expect ( posted2 . some ( p => p . msg . type === 'hello' ) ) . toBe ( true )
326+
327+ second . emit ( frameMessage ( 'ready' , { tabs : READY_TABS , current : 'modules' } ) )
328+ posted1 . length = 0
329+ posted2 . length = 0
330+ await context . docks . switchEntry ( 'nuxt:timeline' )
331+
332+ // Nav goes to the live iframe, not the unmounted one.
333+ const navigate = posted2 . find ( p => p . msg . type === 'navigate' )
334+ expect ( navigate ) . toBeDefined ( )
335+ expect ( navigate ! . msg . tabId ) . toBe ( 'timeline' )
336+ expect ( posted1 . some ( p => p . msg . type === 'navigate' ) ) . toBe ( false )
337+ } )
338+ } )
339+
238340// A hidden `subTabs` anchor (`visibility: 'false'`) exists only to boot the
239341// shared frame; its synthesized member tabs render the real buttons. A group's
240342// `defaultChildId` points at the anchor so opening the group boots the frame —
0 commit comments