Skip to content

Commit 1955064

Browse files
committed
feat(hub): shared-iframe docks with postMessage soft navigation
Implement the hub side of the shared-iframe soft-nav design: extend DevframeViewIframe with navTarget + a subTabs anchor flag (and NavTarget / FrameSubTabsConfig types), and ship a viewer-side frame-nav adapter (attachFrameNavClient) that the client host auto-attaches to a subTabs anchor iframe. The adapter runs a versioned, origin-locked host<->iframe postMessage handshake, materializes one client-only member dock per reported tab (via the client docks.register from #129, keyed <frameId>:<tabId>), and drives a bidirectional nav loop: selecting a member posts navigate; the app's navigated report moves the dock highlight. An idempotent guard breaks the echo, a declarative manifest snapshot reconciles add/update/remove (clearing selection when the active tab vanishes), and a handshake timeout degrades to no-shim (anchor stays a single plain iframe dock). Grouping stays orthogonal to frameId and follows the #130 category rule. Covered by client host + adapter tests; hub API snapshots updated.
1 parent 126a954 commit 1955064

10 files changed

Lines changed: 798 additions & 24 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import type { DevframeViewIframe } from '../../types/docks'
2+
import type { DockEntryState, DocksEntriesContext } from '../docks'
3+
import type { FrameTab } from '../frame-nav'
4+
import { createEventEmitter } from 'devframe/utils/events'
5+
import { describe, expect, it, vi } from 'vitest'
6+
import { attachFrameNavClient, FRAME_NAV_CHANNEL, FRAME_NAV_VERSION } from '../frame-nav'
7+
8+
const ORIGIN = 'https://app.example'
9+
10+
function anchorEntry(extra?: Partial<DevframeViewIframe>): DevframeViewIframe {
11+
return {
12+
id: 'nuxt',
13+
type: 'iframe',
14+
title: 'Nuxt',
15+
icon: 'i-logos:nuxt',
16+
url: `${ORIGIN}/__nuxt/`,
17+
frameId: 'nuxt',
18+
subTabs: { protocol: 'postmessage' },
19+
groupId: 'nuxt-group',
20+
...extra,
21+
}
22+
}
23+
24+
function createDocksStub() {
25+
const entries = new Map<string, DevframeViewIframe>()
26+
const states = new Map<string, DockEntryState>()
27+
let selectedId: string | null = null
28+
29+
function makeState(id: string): DockEntryState {
30+
return {
31+
get entryMeta() {
32+
return entries.get(id)!
33+
},
34+
set entryMeta(_v) {},
35+
get isActive() {
36+
return selectedId === id
37+
},
38+
domElements: {},
39+
events: createEventEmitter(),
40+
} as DockEntryState
41+
}
42+
43+
const docks: Pick<DocksEntriesContext, 'register' | 'switchEntry' | 'getStateById'> = {
44+
register(entry) {
45+
entries.set(entry.id, entry as DevframeViewIframe)
46+
states.set(entry.id, makeState(entry.id))
47+
return {
48+
update: (patch) => {
49+
entries.set(entry.id, { ...entries.get(entry.id)!, ...patch } as DevframeViewIframe)
50+
},
51+
dispose: () => {
52+
entries.delete(entry.id)
53+
states.delete(entry.id)
54+
if (selectedId === entry.id)
55+
selectedId = null
56+
},
57+
}
58+
},
59+
getStateById: id => states.get(id),
60+
async switchEntry(id) {
61+
const next = id ?? null
62+
if (next === selectedId)
63+
return false
64+
if (next !== null && !states.has(next))
65+
return false
66+
const prev = selectedId
67+
selectedId = next
68+
if (prev)
69+
states.get(prev)?.events.emit('entry:deactivated')
70+
if (next)
71+
states.get(next)?.events.emit('entry:activated')
72+
return true
73+
},
74+
}
75+
76+
return {
77+
docks,
78+
entries,
79+
get selectedId() {
80+
return selectedId
81+
},
82+
select: (id: string) => docks.switchEntry(id),
83+
}
84+
}
85+
86+
function createWindowStub() {
87+
const listeners = new Set<(ev: MessageEvent) => void>()
88+
return {
89+
target: {
90+
addEventListener: (_t: 'message', l: (ev: MessageEvent) => void) => listeners.add(l),
91+
removeEventListener: (_t: 'message', l: (ev: MessageEvent) => void) => listeners.delete(l),
92+
},
93+
emitFrame(data: unknown, origin = ORIGIN) {
94+
const ev = { data, origin } as MessageEvent
95+
for (const l of [...listeners]) l(ev)
96+
},
97+
count: () => listeners.size,
98+
}
99+
}
100+
101+
function createIframeStub() {
102+
const posted: Array<{ msg: any, origin: string }> = []
103+
const iframe = {
104+
src: `${ORIGIN}/__nuxt/`,
105+
contentWindow: {
106+
postMessage: (msg: any, origin: string) => posted.push({ msg, origin }),
107+
},
108+
} as unknown as Pick<HTMLIFrameElement, 'contentWindow' | 'src'>
109+
return { iframe, posted }
110+
}
111+
112+
function frameMsg(type: string, extra?: Record<string, unknown>) {
113+
return { channel: FRAME_NAV_CHANNEL, v: FRAME_NAV_VERSION, frameId: 'nuxt', from: 'frame', type, ...extra }
114+
}
115+
116+
const TABS: FrameTab[] = [
117+
{ id: 'modules', title: 'Modules', navTarget: { path: '/modules' } },
118+
{ id: 'timeline', title: 'Timeline', navTarget: { path: '/timeline' } },
119+
]
120+
121+
function boot(extra?: Partial<DevframeViewIframe>) {
122+
const docksStub = createDocksStub()
123+
const win = createWindowStub()
124+
const { iframe, posted } = createIframeStub()
125+
const adapter = attachFrameNavClient({
126+
frameId: 'nuxt',
127+
anchor: anchorEntry(extra),
128+
iframe,
129+
docks: docksStub.docks,
130+
window: win.target,
131+
})
132+
return { docksStub, win, iframe, posted, adapter }
133+
}
134+
135+
const navPosts = (posted: Array<{ msg: any }>) => posted.filter(p => p.msg.type === 'navigate')
136+
137+
describe('attachFrameNavClient', () => {
138+
it('greets the shim with a hello on the anchor origin', () => {
139+
const { posted } = boot()
140+
expect(posted).toHaveLength(1)
141+
expect(posted[0].msg).toMatchObject({ channel: FRAME_NAV_CHANNEL, v: 1, frameId: 'nuxt', from: 'host', type: 'hello' })
142+
expect(posted[0].origin).toBe(ORIGIN)
143+
})
144+
145+
it('materializes client-only member docks from ready, selecting current without echoing navigate', () => {
146+
const { docksStub, win, adapter, posted } = boot()
147+
win.emitFrame(frameMsg('ready', { tabs: TABS, current: 'modules' }))
148+
149+
expect(adapter.ready).toBe(true)
150+
expect([...docksStub.entries.keys()]).toEqual(['nuxt:modules', 'nuxt:timeline'])
151+
// Member inherits frameId, anchor icon + groupId, carries its own navTarget.
152+
expect(docksStub.entries.get('nuxt:timeline')).toMatchObject({
153+
type: 'iframe',
154+
frameId: 'nuxt',
155+
icon: 'i-logos:nuxt',
156+
groupId: 'nuxt-group',
157+
navTarget: { path: '/timeline' },
158+
})
159+
// The initial `current` selects its dock but does not post a navigate.
160+
expect(docksStub.selectedId).toBe('nuxt:modules')
161+
expect(adapter.currentTabId).toBe('modules')
162+
expect(navPosts(posted)).toHaveLength(0)
163+
})
164+
165+
it('posts navigate when the user selects a member, guarding the echo back', async () => {
166+
const { docksStub, win, adapter, posted } = boot()
167+
win.emitFrame(frameMsg('ready', { tabs: TABS, current: 'modules' }))
168+
169+
await docksStub.select('nuxt:timeline')
170+
const navs = navPosts(posted)
171+
expect(navs).toHaveLength(1)
172+
expect(navs[0].msg).toMatchObject({ type: 'navigate', tabId: 'timeline', navTarget: { path: '/timeline' } })
173+
expect(adapter.currentTabId).toBe('timeline')
174+
175+
// The shim's echoed `navigated` for the same tab must not re-post navigate.
176+
win.emitFrame(frameMsg('navigated', { tabId: 'timeline' }))
177+
expect(navPosts(posted)).toHaveLength(1)
178+
expect(docksStub.selectedId).toBe('nuxt:timeline')
179+
})
180+
181+
it('moves the dock highlight on internal navigation without posting navigate', () => {
182+
const { docksStub, win, posted, adapter } = boot()
183+
win.emitFrame(frameMsg('ready', { tabs: TABS, current: 'modules' }))
184+
185+
win.emitFrame(frameMsg('navigated', { tabId: 'timeline' }))
186+
expect(docksStub.selectedId).toBe('nuxt:timeline')
187+
expect(adapter.currentTabId).toBe('timeline')
188+
expect(navPosts(posted)).toHaveLength(0)
189+
})
190+
191+
it('reconciles a manifest snapshot: removes a vanished tab and clears its selection', () => {
192+
const { docksStub, win, adapter } = boot()
193+
win.emitFrame(frameMsg('ready', { tabs: TABS, current: 'timeline' }))
194+
expect(docksStub.selectedId).toBe('nuxt:timeline')
195+
196+
win.emitFrame(frameMsg('manifest', { tabs: [TABS[0]] }))
197+
expect([...docksStub.entries.keys()]).toEqual(['nuxt:modules'])
198+
expect(docksStub.selectedId).toBeNull()
199+
expect(adapter.currentTabId).toBeNull()
200+
})
201+
202+
it('ignores messages from a foreign origin', () => {
203+
const { docksStub, win, adapter } = boot()
204+
win.emitFrame(frameMsg('ready', { tabs: TABS }), 'https://evil.example')
205+
expect(adapter.ready).toBe(false)
206+
expect(docksStub.entries.size).toBe(0)
207+
})
208+
209+
it('ignores malformed / mis-tagged envelopes', () => {
210+
const { win, adapter } = boot()
211+
win.emitFrame({ channel: 'other', v: 1, frameId: 'nuxt', from: 'frame', type: 'ready', tabs: TABS })
212+
win.emitFrame({ channel: FRAME_NAV_CHANNEL, v: 2, frameId: 'nuxt', from: 'frame', type: 'ready', tabs: TABS })
213+
win.emitFrame({ channel: FRAME_NAV_CHANNEL, v: 1, frameId: 'other', from: 'frame', type: 'ready', tabs: TABS })
214+
win.emitFrame({ channel: FRAME_NAV_CHANNEL, v: 1, frameId: 'nuxt', from: 'host', type: 'ready', tabs: TABS })
215+
expect(adapter.ready).toBe(false)
216+
})
217+
218+
it('dispose detaches the listener and removes every member dock', () => {
219+
const { docksStub, win, adapter } = boot()
220+
win.emitFrame(frameMsg('ready', { tabs: TABS }))
221+
expect(docksStub.entries.size).toBe(2)
222+
expect(win.count()).toBe(1)
223+
224+
adapter.dispose()
225+
expect(win.count()).toBe(0)
226+
expect(docksStub.entries.size).toBe(0)
227+
})
228+
229+
it('arms a handshake timeout that leaves the adapter un-ready with no shim', () => {
230+
vi.useFakeTimers()
231+
try {
232+
const { adapter, docksStub } = boot()
233+
vi.advanceTimersByTime(3000)
234+
expect(adapter.ready).toBe(false)
235+
expect(docksStub.entries.size).toBe(0)
236+
}
237+
finally {
238+
vi.useRealTimers()
239+
}
240+
})
241+
})

0 commit comments

Comments
 (0)