Skip to content

Commit 3d23cf2

Browse files
authored
fix(core): rebind frame-nav adapter when iframe realm changes (#503)
1 parent e6b4fef commit 3d23cf2

2 files changed

Lines changed: 137 additions & 21 deletions

File tree

packages/core/src/client/webcomponents/state/__tests__/frame-nav.test.ts

Lines changed: 118 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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 —

packages/core/src/client/webcomponents/state/context.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -246,25 +246,39 @@ export async function createDocksContext(
246246
// Our shell runs its own dock machinery instead of hub's `createDevframeClientHost`,
247247
// so we replicate the host's `maybeAttachFrameNav`: one adapter per `frameId`,
248248
// torn down when the anchor is removed.
249-
const frameNavAdapters = new Map<string, () => void>()
249+
//
250+
// The adapter is bound to a *mounted iframe element*, not just to the `frameId`.
251+
// Each dock shell (float, edge, popup) owns its own `IframePanes` manager and
252+
// creates panes in its own document, so switching shells hands us a different
253+
// iframe in a different realm — the old adapter is disposed and a fresh one
254+
// attached. For the same reason the adapter must listen on the iframe's own
255+
// window: in popup mode the frame lives in a Document-PiP document and posts
256+
// its handshake to *that* window, not to the main one.
257+
const frameNavAdapters = new Map<string, { iframe: HTMLIFrameElement, dispose: () => void }>()
250258
const frameNavAnchors = new Map<string, string>()
251259

252260
const attachFrameNav = (anchor: DevToolsViewIframe, state: DockEntryState) => {
253261
const frameId = anchor.frameId ?? anchor.id
254262
const start = (iframe: HTMLIFrameElement) => {
255-
if (frameNavAdapters.has(frameId))
256-
return
263+
const existing = frameNavAdapters.get(frameId)
264+
if (existing) {
265+
if (existing.iframe === iframe)
266+
return
267+
existing.dispose()
268+
frameNavAdapters.delete(frameId)
269+
}
257270
const adapter = attachDevToolsFrameNav({
258271
frameId,
259272
anchor,
260273
iframe,
274+
window: iframe.ownerDocument?.defaultView ?? globalThis,
261275
docks: {
262276
register: registerClientDock,
263277
switchEntry,
264278
getStateById: (id: string) => dockEntryStateMap.get(id),
265279
},
266280
})
267-
frameNavAdapters.set(frameId, adapter.dispose)
281+
frameNavAdapters.set(frameId, { iframe, dispose: adapter.dispose })
268282
}
269283
if (state.domElements.iframe)
270284
start(state.domElements.iframe)
@@ -293,7 +307,7 @@ export async function createDocksContext(
293307
if (seen.has(anchorId))
294308
continue
295309
frameNavAnchors.delete(anchorId)
296-
frameNavAdapters.get(frameId)?.()
310+
frameNavAdapters.get(frameId)?.dispose()
297311
frameNavAdapters.delete(frameId)
298312
}
299313
},

0 commit comments

Comments
 (0)