Skip to content

Commit 5cb6e65

Browse files
authored
feat(core): shared-iframe soft navigation (devframe 0.7.11) (#464)
1 parent 2107d63 commit 5cb6e65

17 files changed

Lines changed: 812 additions & 151 deletions

File tree

docs/kit/dock-system.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,28 @@ The [File Explorer example](/kit/examples#file-explorer) is a complete iframe-do
130130

131131
To skip bundling a dist with your plugin, an iframe dock can point at a hosted website that connects back to the local dev server over WebSocket. See [Remote Client](./remote-client).
132132

133+
### Shared-iframe soft navigation
134+
135+
A multi-tab integration — say a devtool with its own Modules / Timeline / Plugins views inside one SPA — can surface each of its tabs as its own DevTools dock while all of them share **one** live iframe and switch views by client-side (soft) navigation, with no reload.
136+
137+
Flag the iframe dock as an **anchor** with `subTabs` and give it a `frameId`:
138+
139+
```ts
140+
ctx.docks.register({
141+
id: 'nuxt-devtools',
142+
type: 'iframe',
143+
title: 'Nuxt DevTools',
144+
icon: 'i-logos:nuxt-icon',
145+
url: 'http://localhost:3000/__nuxt_devtools__/',
146+
frameId: 'nuxt-devtools', // the shared iframe these docks render into
147+
subTabs: { protocol: 'postmessage' }, // opt into the frame-nav adapter
148+
})
149+
```
150+
151+
When the anchor's iframe mounts, Vite DevTools attaches the hub's frame-nav adapter. It runs a versioned, origin-locked `postMessage` handshake with the embedded app, turns the tab manifest the app reports into one **member dock** per tab (id `<frameId>:<tabId>`), and drives the loop both ways: selecting a member soft-navigates the shared frame, and the app's own navigation moves the DevTools highlight to match. Members are first-class docks — they honor `title`, `icon`, `order`, `category`, `when`, `badge`, and grouping (`frameId` and `groupId` are independent axes).
152+
153+
The embedded app stays decoupled: it ships a small `postMessage` nav shim and takes no hub or RPC dependency, so this works cross-origin and in static builds. When no shim answers within the handshake window, the anchor renders as a single plain iframe dock. The protocol, the member-dock data model, and the shim contract live in devframe's [shared-iframe soft-navigation design](https://github.com/devframes/devframe/blob/main/plans/shared-iframe-soft-nav.md).
154+
133155
## Action buttons
134156

135157
Action buttons run a client-side script when clicked. They suit:

packages/core/src/client/webcomponents/components/views/ViewIframe.vue

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,16 @@ const currentUrl = ref(props.entry.url)
5757
const editingUrl = ref(props.entry.url)
5858
const isEditing = ref(false)
5959
60+
// Shared-iframe soft navigation: an anchor iframe dock and each of its member
61+
// docks share one `frameId`, so they must render into the *same* live pane.
62+
// Keying on `frameId` (when present) keeps that single iframe alive across
63+
// switches — its navigation/scroll/JS state is preserved and the hub's
64+
// frame-nav adapter soft-navigates it — falling back to the entry id for plain
65+
// iframe docks that own their frame exclusively.
66+
const paneKey = computed(() => props.entry.frameId ?? props.entry.id)
67+
6068
const iframeElement = computed(() => {
61-
return props.panes.get(props.entry.id)?.iframe
69+
return props.panes.get(paneKey.value)?.iframe
6270
})
6371
6472
// Get current page's origin for comparison
@@ -186,10 +194,12 @@ function refresh() {
186194
let onIframeLoad: (() => void) | undefined
187195
188196
onMounted(() => {
189-
const existed = props.panes.has(props.entry.id)
197+
const existed = props.panes.has(paneKey.value)
190198
// `src` is only assigned when the pane is first created, so re-mounting an
191-
// existing iframe (tab switch) preserves its navigation/scroll/JS state.
192-
const pane = props.panes.ensure(props.entry.id, {
199+
// existing iframe (tab switch) preserves its navigation/scroll/JS state. For
200+
// a shared frame this is also the boot deep-link: the first member (or the
201+
// anchor) to become visible seeds the src, and every later switch soft-navs.
202+
const pane = props.panes.ensure(paneKey.value, {
193203
src: props.entry.url,
194204
style: { boxShadow: 'none', outline: 'none' },
195205
})
@@ -236,10 +246,16 @@ onMounted(() => {
236246
})
237247
238248
onUnmounted(() => {
239-
const pane = props.panes.get(props.entry.id)
249+
const pane = props.panes.get(paneKey.value)
240250
if (pane && onIframeLoad)
241251
pane.iframe?.removeEventListener('load', onIframeLoad)
242-
pane?.unmount()
252+
// Only unmount if this view still owns the pane. When switching between two
253+
// docks sharing a `frameId`, the incoming view may re-mount the shared pane
254+
// onto its own container before this outgoing view tears down — unmounting
255+
// then would wrongly hide the just-revealed iframe. Guarding on the current
256+
// target makes the handoff order-independent.
257+
if (pane && pane.target === viewFrame.value)
258+
pane.unmount()
243259
})
244260
</script>
245261

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import type { DevToolsDockEntry } from '@vitejs/devtools-kit'
2+
import type { DevToolsRpcClient } from '@vitejs/devtools-kit/client'
3+
import { DEFAULT_STATE_USER_SETTINGS } from '@vitejs/devtools-kit/constants'
4+
import { createSharedState } from 'devframe/utils/shared-state'
5+
import { afterEach, describe, expect, it } from 'vitest'
6+
import { nextTick } from 'vue'
7+
import { createDocksContext } from '../context'
8+
9+
function createMockRpc(entries: DevToolsDockEntry[] = []): DevToolsRpcClient {
10+
const docksState = createSharedState({ initialValue: entries, enablePatches: false })
11+
const settingsState = createSharedState({ initialValue: DEFAULT_STATE_USER_SETTINGS(), enablePatches: false })
12+
const commandsState = createSharedState({ initialValue: [] as any[], enablePatches: false })
13+
14+
return {
15+
client: { register: () => () => {} },
16+
sharedState: {
17+
get: async (key: string) => {
18+
if (key === 'devframe:docks')
19+
return docksState as any
20+
if (key === 'devframe:user-settings')
21+
return settingsState as any
22+
if (key === 'devframe:commands')
23+
return commandsState as any
24+
throw new Error(`Unexpected shared state key: ${key}`)
25+
},
26+
},
27+
} as unknown as DevToolsRpcClient
28+
}
29+
30+
/**
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.
35+
*/
36+
function stubMessageBus() {
37+
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+
}
48+
return {
49+
emit(data: unknown, origin = 'http://localhost:5173') {
50+
for (const cb of [...listeners]) cb({ origin, data })
51+
},
52+
get size() {
53+
return listeners.size
54+
},
55+
restore() {
56+
;(globalThis as any).addEventListener = origAdd
57+
;(globalThis as any).removeEventListener = origRemove
58+
},
59+
}
60+
}
61+
62+
function makeFakeIframe(src: string) {
63+
const posted: { msg: any, origin: string }[] = []
64+
const iframe = {
65+
src,
66+
contentWindow: {
67+
postMessage: (msg: any, origin: string) => posted.push({ msg, origin }),
68+
},
69+
}
70+
return { iframe: iframe as unknown as HTMLIFrameElement, posted }
71+
}
72+
73+
const ANCHOR_URL = 'http://localhost:5173/__nuxt__/'
74+
75+
function anchorEntry(): DevToolsDockEntry {
76+
return {
77+
id: 'nuxt',
78+
type: 'iframe',
79+
title: 'Nuxt DevTools',
80+
icon: 'i-logos:nuxt-icon',
81+
url: ANCHOR_URL,
82+
frameId: 'nuxt',
83+
subTabs: { protocol: 'postmessage' },
84+
} as DevToolsDockEntry
85+
}
86+
87+
const READY_TABS = [
88+
{ id: 'modules', title: 'Modules', navTarget: { path: '/modules' } },
89+
{ id: 'timeline', title: 'Timeline', navTarget: { path: '/timeline' } },
90+
]
91+
92+
function frameMessage(type: string, payload: Record<string, unknown> = {}) {
93+
return { channel: 'devframe:frame-nav', v: 1, frameId: 'nuxt', from: 'frame', type, ...payload }
94+
}
95+
96+
describe('client-only dock registration', () => {
97+
it('materializes an entry state synchronously so getStateById works right after register', async () => {
98+
const context = await createDocksContext('embedded', createMockRpc())
99+
100+
const handle = context.docks.register({
101+
id: 'tab:one',
102+
type: 'iframe',
103+
title: 'One',
104+
icon: 'i',
105+
url: '/one',
106+
} as DevToolsDockEntry)
107+
108+
// The adapter subscribes to activation immediately after registering — the
109+
// state must exist synchronously, not on the next reactive flush.
110+
const state = context.docks.getStateById('tab:one')
111+
expect(state).toBeDefined()
112+
113+
let activated = false
114+
state!.events.on('entry:activated', () => {
115+
activated = true
116+
})
117+
await context.docks.switchEntry('tab:one')
118+
expect(activated).toBe(true)
119+
120+
handle.dispose()
121+
})
122+
123+
it('clears the selection and drops the state when the active client dock is disposed', async () => {
124+
const context = await createDocksContext('embedded', createMockRpc())
125+
const handle = context.docks.register({
126+
id: 'tab:two',
127+
type: 'iframe',
128+
title: 'Two',
129+
icon: 'i',
130+
url: '/two',
131+
} as DevToolsDockEntry)
132+
133+
await context.docks.switchEntry('tab:two')
134+
expect(context.docks.selectedId).toBe('tab:two')
135+
136+
handle.dispose()
137+
expect(context.docks.getStateById('tab:two')).toBeUndefined()
138+
expect(context.docks.selectedId).toBeNull()
139+
})
140+
})
141+
142+
describe('shared-iframe soft navigation', () => {
143+
let bus: ReturnType<typeof stubMessageBus> | undefined
144+
145+
afterEach(() => {
146+
bus?.restore()
147+
bus = undefined
148+
})
149+
150+
it('attaches the frame-nav adapter and posts hello when the anchor iframe mounts', async () => {
151+
bus = stubMessageBus()
152+
const context = await createDocksContext('embedded', createMockRpc([anchorEntry()]))
153+
const { iframe, posted } = makeFakeIframe(ANCHOR_URL)
154+
155+
const state = context.docks.getStateById('nuxt')!
156+
state.domElements.iframe = iframe
157+
await nextTick()
158+
159+
expect(bus.size).toBe(1)
160+
expect(posted.some(p => p.msg.type === 'hello')).toBe(true)
161+
expect(posted.every(p => p.origin === 'http://localhost:5173')).toBe(true)
162+
})
163+
164+
it('materializes one member dock per reported tab and selects the current one', async () => {
165+
bus = stubMessageBus()
166+
const context = await createDocksContext('embedded', createMockRpc([anchorEntry()]))
167+
const { iframe } = makeFakeIframe(ANCHOR_URL)
168+
169+
context.docks.getStateById('nuxt')!.domElements.iframe = iframe
170+
await nextTick()
171+
172+
bus.emit(frameMessage('ready', { tabs: READY_TABS, current: 'modules' }))
173+
174+
const ids = context.docks.entries.map(e => e.id)
175+
expect(ids).toContain('nuxt:modules')
176+
expect(ids).toContain('nuxt:timeline')
177+
// Member docks share the anchor's frame + carry their own nav target.
178+
const modules = context.docks.entries.find(e => e.id === 'nuxt:modules') as any
179+
expect(modules.frameId).toBe('nuxt')
180+
expect(modules.navTarget).toEqual({ path: '/modules' })
181+
expect(context.docks.selectedId).toBe('nuxt:modules')
182+
})
183+
184+
it('posts navigate when a member is selected in the dock bar', async () => {
185+
bus = stubMessageBus()
186+
const context = await createDocksContext('embedded', createMockRpc([anchorEntry()]))
187+
const { iframe, posted } = makeFakeIframe(ANCHOR_URL)
188+
189+
context.docks.getStateById('nuxt')!.domElements.iframe = iframe
190+
await nextTick()
191+
bus.emit(frameMessage('ready', { tabs: READY_TABS, current: 'modules' }))
192+
193+
posted.length = 0
194+
await context.docks.switchEntry('nuxt:timeline')
195+
196+
const navigate = posted.find(p => p.msg.type === 'navigate')
197+
expect(navigate).toBeDefined()
198+
expect(navigate!.msg.tabId).toBe('timeline')
199+
expect(navigate!.msg.navTarget).toEqual({ path: '/timeline' })
200+
})
201+
202+
it('moves the dock highlight when the frame reports internal navigation', async () => {
203+
bus = stubMessageBus()
204+
const context = await createDocksContext('embedded', createMockRpc([anchorEntry()]))
205+
const { iframe, posted } = makeFakeIframe(ANCHOR_URL)
206+
207+
context.docks.getStateById('nuxt')!.domElements.iframe = iframe
208+
await nextTick()
209+
bus.emit(frameMessage('ready', { tabs: READY_TABS, current: 'modules' }))
210+
211+
posted.length = 0
212+
bus.emit(frameMessage('navigated', { tabId: 'timeline' }))
213+
214+
expect(context.docks.selectedId).toBe('nuxt:timeline')
215+
// The highlight-only update must not echo a navigate back to the frame.
216+
expect(posted.some(p => p.msg.type === 'navigate')).toBe(false)
217+
})
218+
219+
it('removes member docks and clears selection when the manifest empties', async () => {
220+
bus = stubMessageBus()
221+
const context = await createDocksContext('embedded', createMockRpc([anchorEntry()]))
222+
const { iframe } = makeFakeIframe(ANCHOR_URL)
223+
224+
context.docks.getStateById('nuxt')!.domElements.iframe = iframe
225+
await nextTick()
226+
bus.emit(frameMessage('ready', { tabs: READY_TABS, current: 'modules' }))
227+
expect(context.docks.selectedId).toBe('nuxt:modules')
228+
229+
bus.emit(frameMessage('manifest', { tabs: [] }))
230+
231+
const ids = context.docks.entries.map(e => e.id)
232+
expect(ids).not.toContain('nuxt:modules')
233+
expect(ids).not.toContain('nuxt:timeline')
234+
expect(context.docks.selectedId).toBeNull()
235+
})
236+
})

0 commit comments

Comments
 (0)