Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 118 additions & 16 deletions packages/core/src/client/webcomponents/state/__tests__/frame-nav.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,44 +28,72 @@ function createMockRpc(entries: DevToolsDockEntry[] = []): DevToolsRpcClient {
}

/**
* The frame-nav adapter listens for `message` events on `globalThis`. These
* tests run in the node environment (no DOM), so stub the listener registry and
* drive it with synthetic events — this exercises the viewer wiring we own
* (auto-attach, member materialization, the nav loop) without a DOM dependency.
* A `message` listener registry standing in for one window realm. Tests drive it
* with synthetic events instead of real `postMessage`.
*/
function stubMessageBus() {
function createMessageRegistry() {
const listeners = new Set<(ev: any) => void>()
const origAdd = (globalThis as any).addEventListener
const origRemove = (globalThis as any).removeEventListener;
(globalThis as any).addEventListener = (type: string, cb: any) => {
if (type === 'message')
listeners.add(cb)
}
;(globalThis as any).removeEventListener = (type: string, cb: any) => {
if (type === 'message')
listeners.delete(cb)
}
return {
addEventListener(type: string, cb: any) {
if (type === 'message')
listeners.add(cb)
},
removeEventListener(type: string, cb: any) {
if (type === 'message')
listeners.delete(cb)
},
emit(data: unknown, origin = 'http://localhost:5173') {
for (const cb of [...listeners]) cb({ origin, data })
},
get size() {
return listeners.size
},
}
}

/**
* The frame-nav adapter listens for `message` events on the iframe's own realm,
* falling back to `globalThis`. These tests run in the node environment (no DOM),
* so stub the global listener registry and drive it with synthetic events — this
* exercises the viewer wiring we own (auto-attach, member materialization, the
* nav loop) without a DOM dependency.
*/
function stubMessageBus() {
const registry = createMessageRegistry()
const origAdd = (globalThis as any).addEventListener
const origRemove = (globalThis as any).removeEventListener;
(globalThis as any).addEventListener = registry.addEventListener
;(globalThis as any).removeEventListener = registry.removeEventListener
return {
emit: registry.emit,
get size() {
return registry.size
},
restore() {
;(globalThis as any).addEventListener = origAdd
;(globalThis as any).removeEventListener = origRemove
},
}
}

function makeFakeIframe(src: string) {
/**
* A window realm other than the main one — what the dock sees when it is mounted
* inside a Document Picture-in-Picture popup.
*/
function makeFakeRealm() {
return createMessageRegistry()
}

function makeFakeIframe(src: string, realm?: ReturnType<typeof makeFakeRealm>) {
const posted: { msg: any, origin: string }[] = []
const iframe = {
src,
contentWindow: {
postMessage: (msg: any, origin: string) => posted.push({ msg, origin }),
},
// Left undefined unless a realm is given, so the adapter falls back to
// `globalThis` exactly as it does for the plain single-realm tests.
ownerDocument: realm ? { defaultView: realm } : undefined,
}
return { iframe: iframe as unknown as HTMLIFrameElement, posted }
}
Expand Down Expand Up @@ -235,6 +263,80 @@ describe('shared-iframe soft navigation', () => {
})
})

// Each dock shell (float, edge, popup) owns its own `IframePanes` manager and
// creates panes in its own document. Popup mode is Document Picture-in-Picture,
// so the anchor iframe lives in the popup's realm and its shim posts the ready
// handshake there — an adapter listening on the main window never hears it, and
// the group renders with no members.
describe('frame-nav across window realms', () => {
let bus: ReturnType<typeof stubMessageBus> | undefined

afterEach(() => {
bus?.restore()
bus = undefined
})

it('listens on the iframe\'s own realm, not the main window', async () => {
bus = stubMessageBus()
const popup = makeFakeRealm()
const context = await createDocksContext('embedded', createMockRpc([anchorEntry()]))
const { iframe } = makeFakeIframe(ANCHOR_URL, popup)

context.docks.getStateById('nuxt')!.domElements.iframe = iframe
await nextTick()

expect(popup.size).toBe(1)
expect(bus.size).toBe(0)

// The main window hearing the manifest must change nothing.
bus.emit(frameMessage('ready', { tabs: READY_TABS, current: 'modules' }))
expect(context.docks.entries.map(e => e.id)).not.toContain('nuxt:modules')

popup.emit(frameMessage('ready', { tabs: READY_TABS, current: 'modules' }))

const ids = context.docks.entries.map(e => e.id)
expect(ids).toContain('nuxt:modules')
expect(ids).toContain('nuxt:timeline')
expect(context.docks.selectedId).toBe('nuxt:modules')
})

it('re-attaches to the new iframe when the dock remounts in another realm', async () => {
bus = stubMessageBus()
const first = makeFakeRealm()
const context = await createDocksContext('embedded', createMockRpc([anchorEntry()]))
const { iframe: iframe1, posted: posted1 } = makeFakeIframe(ANCHOR_URL, first)

const state = context.docks.getStateById('nuxt')!
state.domElements.iframe = iframe1
await nextTick()
first.emit(frameMessage('ready', { tabs: READY_TABS, current: 'modules' }))
expect(context.docks.selectedId).toBe('nuxt:modules')

// Switching shells mounts a brand-new iframe element in a different document.
const second = makeFakeRealm()
const { iframe: iframe2, posted: posted2 } = makeFakeIframe(ANCHOR_URL, second)
posted1.length = 0
state.domElements.iframe = iframe2
await nextTick()

// The stale adapter is torn down and a fresh handshake runs against iframe #2.
expect(first.size).toBe(0)
expect(second.size).toBe(1)
expect(posted2.some(p => p.msg.type === 'hello')).toBe(true)

second.emit(frameMessage('ready', { tabs: READY_TABS, current: 'modules' }))
posted1.length = 0
posted2.length = 0
await context.docks.switchEntry('nuxt:timeline')

// Nav goes to the live iframe, not the unmounted one.
const navigate = posted2.find(p => p.msg.type === 'navigate')
expect(navigate).toBeDefined()
expect(navigate!.msg.tabId).toBe('timeline')
expect(posted1.some(p => p.msg.type === 'navigate')).toBe(false)
})
})

// A hidden `subTabs` anchor (`visibility: 'false'`) exists only to boot the
// shared frame; its synthesized member tabs render the real buttons. A group's
// `defaultChildId` points at the anchor so opening the group boots the frame —
Expand Down
24 changes: 19 additions & 5 deletions packages/core/src/client/webcomponents/state/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,25 +244,39 @@ export async function createDocksContext(
// Our shell runs its own dock machinery instead of hub's `createDevframeClientHost`,
// so we replicate the host's `maybeAttachFrameNav`: one adapter per `frameId`,
// torn down when the anchor is removed.
const frameNavAdapters = new Map<string, () => void>()
//
// The adapter is bound to a *mounted iframe element*, not just to the `frameId`.
// Each dock shell (float, edge, popup) owns its own `IframePanes` manager and
// creates panes in its own document, so switching shells hands us a different
// iframe in a different realm — the old adapter is disposed and a fresh one
// attached. For the same reason the adapter must listen on the iframe's own
// window: in popup mode the frame lives in a Document-PiP document and posts
// its handshake to *that* window, not to the main one.
const frameNavAdapters = new Map<string, { iframe: HTMLIFrameElement, dispose: () => void }>()
const frameNavAnchors = new Map<string, string>()

const attachFrameNav = (anchor: DevToolsViewIframe, state: DockEntryState) => {
const frameId = anchor.frameId ?? anchor.id
const start = (iframe: HTMLIFrameElement) => {
if (frameNavAdapters.has(frameId))
return
const existing = frameNavAdapters.get(frameId)
if (existing) {
if (existing.iframe === iframe)
return
existing.dispose()
frameNavAdapters.delete(frameId)
}
const adapter = attachDevToolsFrameNav({
frameId,
anchor,
iframe,
window: iframe.ownerDocument?.defaultView ?? globalThis,
docks: {
register: registerClientDock,
switchEntry,
getStateById: (id: string) => dockEntryStateMap.get(id),
},
})
frameNavAdapters.set(frameId, adapter.dispose)
frameNavAdapters.set(frameId, { iframe, dispose: adapter.dispose })
}
if (state.domElements.iframe)
start(state.domElements.iframe)
Expand Down Expand Up @@ -291,7 +305,7 @@ export async function createDocksContext(
if (seen.has(anchorId))
continue
frameNavAnchors.delete(anchorId)
frameNavAdapters.get(frameId)?.()
frameNavAdapters.get(frameId)?.dispose()
frameNavAdapters.delete(frameId)
}
},
Expand Down
Loading