Skip to content
Merged
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
22 changes: 22 additions & 0 deletions docs/kit/dock-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,28 @@ The [File Explorer example](/kit/examples#file-explorer) is a complete iframe-do

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).

### Shared-iframe soft navigation

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.

Flag the iframe dock as an **anchor** with `subTabs` and give it a `frameId`:

```ts
ctx.docks.register({
id: 'nuxt-devtools',
type: 'iframe',
title: 'Nuxt DevTools',
icon: 'i-logos:nuxt-icon',
url: 'http://localhost:3000/__nuxt_devtools__/',
frameId: 'nuxt-devtools', // the shared iframe these docks render into
subTabs: { protocol: 'postmessage' }, // opt into the frame-nav adapter
})
```

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).

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).

## Action buttons

Action buttons run a client-side script when clicked. They suit:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,16 @@ const currentUrl = ref(props.entry.url)
const editingUrl = ref(props.entry.url)
const isEditing = ref(false)

// Shared-iframe soft navigation: an anchor iframe dock and each of its member
// docks share one `frameId`, so they must render into the *same* live pane.
// Keying on `frameId` (when present) keeps that single iframe alive across
// switches — its navigation/scroll/JS state is preserved and the hub's
// frame-nav adapter soft-navigates it — falling back to the entry id for plain
// iframe docks that own their frame exclusively.
const paneKey = computed(() => props.entry.frameId ?? props.entry.id)

const iframeElement = computed(() => {
return props.panes.get(props.entry.id)?.iframe
return props.panes.get(paneKey.value)?.iframe
})

// Get current page's origin for comparison
Expand Down Expand Up @@ -186,10 +194,12 @@ function refresh() {
let onIframeLoad: (() => void) | undefined

onMounted(() => {
const existed = props.panes.has(props.entry.id)
const existed = props.panes.has(paneKey.value)
// `src` is only assigned when the pane is first created, so re-mounting an
// existing iframe (tab switch) preserves its navigation/scroll/JS state.
const pane = props.panes.ensure(props.entry.id, {
// existing iframe (tab switch) preserves its navigation/scroll/JS state. For
// a shared frame this is also the boot deep-link: the first member (or the
// anchor) to become visible seeds the src, and every later switch soft-navs.
const pane = props.panes.ensure(paneKey.value, {
src: props.entry.url,
style: { boxShadow: 'none', outline: 'none' },
})
Expand Down Expand Up @@ -236,10 +246,16 @@ onMounted(() => {
})

onUnmounted(() => {
const pane = props.panes.get(props.entry.id)
const pane = props.panes.get(paneKey.value)
if (pane && onIframeLoad)
pane.iframe?.removeEventListener('load', onIframeLoad)
pane?.unmount()
// Only unmount if this view still owns the pane. When switching between two
// docks sharing a `frameId`, the incoming view may re-mount the shared pane
// onto its own container before this outgoing view tears down — unmounting
// then would wrongly hide the just-revealed iframe. Guarding on the current
// target makes the handoff order-independent.
if (pane && pane.target === viewFrame.value)
pane.unmount()
})
</script>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import type { DevToolsDockEntry } from '@vitejs/devtools-kit'
import type { DevToolsRpcClient } from '@vitejs/devtools-kit/client'
import { DEFAULT_STATE_USER_SETTINGS } from '@vitejs/devtools-kit/constants'
import { createSharedState } from 'devframe/utils/shared-state'
import { afterEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createDocksContext } from '../context'

function createMockRpc(entries: DevToolsDockEntry[] = []): DevToolsRpcClient {
const docksState = createSharedState({ initialValue: entries, enablePatches: false })
const settingsState = createSharedState({ initialValue: DEFAULT_STATE_USER_SETTINGS(), enablePatches: false })
const commandsState = createSharedState({ initialValue: [] as any[], enablePatches: false })

return {
client: { register: () => () => {} },
sharedState: {
get: async (key: string) => {
if (key === 'devframe:docks')
return docksState as any
if (key === 'devframe:user-settings')
return settingsState as any
if (key === 'devframe:commands')
return commandsState as any
throw new Error(`Unexpected shared state key: ${key}`)
},
},
} as unknown as 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.
*/
function stubMessageBus() {
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 {
emit(data: unknown, origin = 'http://localhost:5173') {
for (const cb of [...listeners]) cb({ origin, data })
},
get size() {
return listeners.size
},
restore() {
;(globalThis as any).addEventListener = origAdd
;(globalThis as any).removeEventListener = origRemove
},
}
}

function makeFakeIframe(src: string) {
const posted: { msg: any, origin: string }[] = []
const iframe = {
src,
contentWindow: {
postMessage: (msg: any, origin: string) => posted.push({ msg, origin }),
},
}
return { iframe: iframe as unknown as HTMLIFrameElement, posted }
}

const ANCHOR_URL = 'http://localhost:5173/__nuxt__/'

function anchorEntry(): DevToolsDockEntry {
return {
id: 'nuxt',
type: 'iframe',
title: 'Nuxt DevTools',
icon: 'i-logos:nuxt-icon',
url: ANCHOR_URL,
frameId: 'nuxt',
subTabs: { protocol: 'postmessage' },
} as DevToolsDockEntry
}

const READY_TABS = [
{ id: 'modules', title: 'Modules', navTarget: { path: '/modules' } },
{ id: 'timeline', title: 'Timeline', navTarget: { path: '/timeline' } },
]

function frameMessage(type: string, payload: Record<string, unknown> = {}) {
return { channel: 'devframe:frame-nav', v: 1, frameId: 'nuxt', from: 'frame', type, ...payload }
}

describe('client-only dock registration', () => {
it('materializes an entry state synchronously so getStateById works right after register', async () => {
const context = await createDocksContext('embedded', createMockRpc())

const handle = context.docks.register({
id: 'tab:one',
type: 'iframe',
title: 'One',
icon: 'i',
url: '/one',
} as DevToolsDockEntry)

// The adapter subscribes to activation immediately after registering — the
// state must exist synchronously, not on the next reactive flush.
const state = context.docks.getStateById('tab:one')
expect(state).toBeDefined()

let activated = false
state!.events.on('entry:activated', () => {
activated = true
})
await context.docks.switchEntry('tab:one')
expect(activated).toBe(true)

handle.dispose()
})

it('clears the selection and drops the state when the active client dock is disposed', async () => {
const context = await createDocksContext('embedded', createMockRpc())
const handle = context.docks.register({
id: 'tab:two',
type: 'iframe',
title: 'Two',
icon: 'i',
url: '/two',
} as DevToolsDockEntry)

await context.docks.switchEntry('tab:two')
expect(context.docks.selectedId).toBe('tab:two')

handle.dispose()
expect(context.docks.getStateById('tab:two')).toBeUndefined()
expect(context.docks.selectedId).toBeNull()
})
})

describe('shared-iframe soft navigation', () => {
let bus: ReturnType<typeof stubMessageBus> | undefined

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

it('attaches the frame-nav adapter and posts hello when the anchor iframe mounts', async () => {
bus = stubMessageBus()
const context = await createDocksContext('embedded', createMockRpc([anchorEntry()]))
const { iframe, posted } = makeFakeIframe(ANCHOR_URL)

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

expect(bus.size).toBe(1)
expect(posted.some(p => p.msg.type === 'hello')).toBe(true)
expect(posted.every(p => p.origin === 'http://localhost:5173')).toBe(true)
})

it('materializes one member dock per reported tab and selects the current one', async () => {
bus = stubMessageBus()
const context = await createDocksContext('embedded', createMockRpc([anchorEntry()]))
const { iframe } = makeFakeIframe(ANCHOR_URL)

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

bus.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')
// Member docks share the anchor's frame + carry their own nav target.
const modules = context.docks.entries.find(e => e.id === 'nuxt:modules') as any
expect(modules.frameId).toBe('nuxt')
expect(modules.navTarget).toEqual({ path: '/modules' })
expect(context.docks.selectedId).toBe('nuxt:modules')
})

it('posts navigate when a member is selected in the dock bar', async () => {
bus = stubMessageBus()
const context = await createDocksContext('embedded', createMockRpc([anchorEntry()]))
const { iframe, posted } = makeFakeIframe(ANCHOR_URL)

context.docks.getStateById('nuxt')!.domElements.iframe = iframe
await nextTick()
bus.emit(frameMessage('ready', { tabs: READY_TABS, current: 'modules' }))

posted.length = 0
await context.docks.switchEntry('nuxt:timeline')

const navigate = posted.find(p => p.msg.type === 'navigate')
expect(navigate).toBeDefined()
expect(navigate!.msg.tabId).toBe('timeline')
expect(navigate!.msg.navTarget).toEqual({ path: '/timeline' })
})

it('moves the dock highlight when the frame reports internal navigation', async () => {
bus = stubMessageBus()
const context = await createDocksContext('embedded', createMockRpc([anchorEntry()]))
const { iframe, posted } = makeFakeIframe(ANCHOR_URL)

context.docks.getStateById('nuxt')!.domElements.iframe = iframe
await nextTick()
bus.emit(frameMessage('ready', { tabs: READY_TABS, current: 'modules' }))

posted.length = 0
bus.emit(frameMessage('navigated', { tabId: 'timeline' }))

expect(context.docks.selectedId).toBe('nuxt:timeline')
// The highlight-only update must not echo a navigate back to the frame.
expect(posted.some(p => p.msg.type === 'navigate')).toBe(false)
})

it('removes member docks and clears selection when the manifest empties', async () => {
bus = stubMessageBus()
const context = await createDocksContext('embedded', createMockRpc([anchorEntry()]))
const { iframe } = makeFakeIframe(ANCHOR_URL)

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

bus.emit(frameMessage('manifest', { tabs: [] }))

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