Skip to content

Commit ae7e638

Browse files
committed
feat(hub): client-registered client-only docks via docks.register/update
Let a client host contribute dock entries that live only in the page, merged with the server docks from shared state without syncing back to the hub or other viewers — mirroring the client commands.register pattern.
1 parent 6ee61dd commit ae7e638

5 files changed

Lines changed: 172 additions & 4 deletions

File tree

‎docs/guide/client-context.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Boot the host once per page: a second boot replaces the published context and lo
5050
|----------|-------------|
5151
| `rpc` | The [RPC client](./client) — call server functions, register client-side functions, access shared state. |
5252
| `clientType` | `'embedded'` (runtime inside your app) or `'standalone'` (independent hub page). |
53-
| `docks` | Dock entries and selection — `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`. |
53+
| `docks` | Dock entries and selection — `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, plus `register()` / `update()` for [client-only docks](#client-only-docks). |
5454
| `panel` | Dock panel state: position, size, drag/resize flags. |
5555
| `commands` | The command palette: `register()`, `execute()`, `getKeybindings()`. |
5656
| `renderers` | Dock-renderer registry — `register()`, `get()`, `has()`, `mount(entry, container)`. Routes a dock `type` to a host-registered renderer (e.g. [JSON-Render](./json-render)); the hub ships none. |
@@ -71,6 +71,25 @@ if (ctx) {
7171
}
7272
```
7373

74+
### Client-only docks
75+
76+
The [node hub context](./hub) registers docks that flow into the `devframe:docks` shared state and reach every connected viewer. A client host can also register a dock that lives only in this page, for a view a host page synthesizes itself:
77+
78+
```ts
79+
const handle = ctx.docks.register({
80+
id: 'my-local-view',
81+
title: 'Local',
82+
icon: 'ph:cube-duotone',
83+
type: 'custom-render',
84+
renderer: { importFrom: '/my-view.mjs' },
85+
})
86+
87+
handle.update({ badge: '3' }) // patch it in place (the id is immutable)
88+
handle.dispose() // remove it
89+
```
90+
91+
Client-only docks merge into the same `docks.entries` list, group, select, and load their client scripts exactly like server docks — they just never sync to the hub or other viewers. A client dock sharing an id with a server dock overrides it locally. `ctx.docks.update(entry)` replaces a previously registered client dock wholesale. Registering an id that a client dock already owns throws unless you pass `register(entry, true)`.
92+
7493
## Dock client scripts
7594

7695
A dock entry declares its client script as a `ClientScriptEntry` — `{ importFrom, importName? }`, where `importName` defaults to `'default'`. The field depends on the entry kind:

‎packages/hub/src/client/__tests__/host.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,60 @@ describe('createDevframeClientHost', () => {
139139
host.dispose()
140140
})
141141

142+
it('registers, updates, and disposes client-only docks merged with server entries', async () => {
143+
const { rpc, states } = createStubRpc()
144+
const host = await createDevframeClientHost({ rpc })
145+
const docks = host.context.docks
146+
states.get('devframe:docks')!.push([iframeEntry('server')])
147+
148+
// Client-only registration is merged with the server entries.
149+
const handle = docks.register(iframeEntry('client'))
150+
expect(docks.entries.map(e => e.id)).toEqual(['server', 'client'])
151+
expect(docks.getStateById('client')?.entryMeta.id).toBe('client')
152+
153+
// It survives a server-driven reconcile and is switchable.
154+
states.get('devframe:docks')!.push([iframeEntry('server'), iframeEntry('server2')])
155+
expect(docks.entries.map(e => e.id)).toEqual(['server', 'server2', 'client'])
156+
expect(await docks.switchEntry('client')).toBe(true)
157+
expect(docks.selected?.id).toBe('client')
158+
159+
// A registered client dock is never pushed into shared state (client-only).
160+
expect((states.get('devframe:docks')!.value() as DevframeDockEntry[]).map(e => e.id))
161+
.toEqual(['server', 'server2'])
162+
163+
// Patch in place; id is immutable.
164+
handle.update({ title: 'Renamed' })
165+
expect(docks.getStateById('client')?.entryMeta.title).toBe('Renamed')
166+
expect(() => handle.update({ id: 'other' } as any)).toThrow()
167+
168+
// Duplicate id throws unless forced; update() requires a prior registration.
169+
expect(() => docks.register(iframeEntry('client'))).toThrow()
170+
expect(() => docks.register(iframeEntry('client'), true)).not.toThrow()
171+
expect(() => docks.update(iframeEntry('ghost'))).toThrow()
172+
173+
// Disposing removes it from the merge.
174+
handle.dispose()
175+
expect(docks.entries.map(e => e.id)).toEqual(['server', 'server2'])
176+
expect(docks.getStateById('client')).toBeUndefined()
177+
host.dispose()
178+
})
179+
180+
it('imports the client script of a client-registered dock', async () => {
181+
const { rpc } = createStubRpc()
182+
const host = await createDevframeClientHost({ rpc })
183+
184+
const received: any[] = []
185+
;(globalThis as any).__DF_TEST_CLIENT_DOCK__ = (ctx: any) => received.push(ctx)
186+
const dataUrl = `data:text/javascript,export default ctx => globalThis.__DF_TEST_CLIENT_DOCK__(ctx)`
187+
host.context.docks.register(iframeEntry('local', { clientScript: { importFrom: dataUrl } }))
188+
189+
await vi.waitFor(() => expect(received).toHaveLength(1))
190+
expect(received[0].current.entryMeta.id).toBe('local')
191+
192+
delete (globalThis as any).__DF_TEST_CLIENT_DOCK__
193+
host.dispose()
194+
})
195+
142196
it('executes client commands locally and server commands over hub:commands:execute', async () => {
143197
const { rpc, calls } = createStubRpc()
144198
const host = await createDevframeClientHost({ rpc })

‎packages/hub/src/client/docks.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,35 @@ export interface DocksEntriesContext {
112112
* @returns Whether the selection was changed successfully
113113
*/
114114
toggleEntry: (id: string) => Promise<boolean>
115+
/**
116+
* Register a **client-only** dock entry, live in this page and merged with
117+
* the server-provided docks (`devframe:docks` shared state) into
118+
* {@link entries}. Unlike a dock registered on the node
119+
* {@link import('../types/docks').DevframeDocksHost}, it never flows into
120+
* shared state, so it stays local to this client instead of syncing to the
121+
* hub or other viewers — for a view a client host synthesizes itself.
122+
*
123+
* Throws when `id` already names a client dock, unless `force` is set. A
124+
* client dock sharing an id with a server dock overrides it in the local
125+
* merge. Returns a handle to {@link DockRegistration.update patch} or
126+
* {@link DockRegistration.dispose remove} it.
127+
*/
128+
register: <T extends DevframeDockEntry>(entry: T, force?: boolean) => DockRegistration<T>
129+
/**
130+
* Replace a previously {@link register client-registered} dock entry, keyed
131+
* by `id`. Throws when no client dock owns that id.
132+
*/
133+
update: (entry: DevframeDockUserEntry) => void
134+
}
135+
136+
export interface DockRegistration<T extends DevframeDockEntry = DevframeDockEntry> {
137+
/**
138+
* Patch the registered client dock in place. The `id` is immutable — passing
139+
* a differing `id` throws.
140+
*/
141+
update: (patch: Partial<T>) => void
142+
/** Remove the client dock from the local merge. */
143+
dispose: () => void
115144
}
116145

117146
export interface DockEntryState {

‎packages/hub/src/client/host.ts‎

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ export async function createDevframeClientHost(
9898

9999
let selectedId: string | null = null
100100
const entryToStateMap = new Map<string, DockEntryState>()
101+
// Docks registered live in this page via `docks.register()`. They never flow
102+
// into the `devframe:docks` shared state (client-only), and are merged with
103+
// the server entries — a client dock overriding a server one of the same id.
104+
const clientDocks = new Map<string, DevframeDockEntry>()
105+
const loadScriptsEnabled = options.loadClientScripts ?? true
101106

102107
const panel = createPanelContext(clientType)
103108
const docks = createDocksContext()
@@ -172,7 +177,7 @@ export async function createDevframeClientHost(
172177
setDevframeClientContext(context)
173178

174179
const loadedScripts = new Set<string>()
175-
if (options.loadClientScripts ?? true) {
180+
if (loadScriptsEnabled) {
176181
loadClientScripts()
177182
disposers.push(docksState.on('updated', loadClientScripts))
178183
}
@@ -202,8 +207,36 @@ export async function createDevframeClientHost(
202207
}
203208
}
204209

210+
// The merged dock list: server entries from shared state overlaid with any
211+
// client-registered docks (client wins on id collision, new ids appended).
212+
function currentEntries(): DevframeDockEntry[] {
213+
const server = docksState.value() as DevframeDockEntry[]
214+
if (clientDocks.size === 0)
215+
return server
216+
const merged: DevframeDockEntry[] = []
217+
const seen = new Set<string>()
218+
for (const entry of server) {
219+
merged.push(clientDocks.get(entry.id) ?? entry)
220+
seen.add(entry.id)
221+
}
222+
for (const [id, entry] of clientDocks) {
223+
if (!seen.has(id))
224+
merged.push(entry)
225+
}
226+
return merged
227+
}
228+
229+
// Re-run the reconcile + client-script load after a local dock mutation
230+
// (register/update/dispose), which doesn't emit the shared-state `updated`
231+
// event that the server path relies on.
232+
function refreshEntries(): void {
233+
reconcileEntries()
234+
if (loadScriptsEnabled)
235+
loadClientScripts()
236+
}
237+
205238
function reconcileEntries(): void {
206-
const entries = docksState.value() as DevframeDockEntry[]
239+
const entries = currentEntries()
207240
const seen = new Set<string>()
208241

209242
for (const meta of entries) {
@@ -246,6 +279,33 @@ export async function createDevframeClientHost(
246279
getStateById: id => entryToStateMap.get(id),
247280
switchEntry,
248281
toggleEntry: id => (selectedId === id ? switchEntry(null) : switchEntry(id)),
282+
register(entry, force) {
283+
if (clientDocks.has(entry.id) && !force)
284+
throw new Error(`[@devframes/hub] a client dock "${entry.id}" is already registered — pass force to overwrite`)
285+
clientDocks.set(entry.id, entry)
286+
refreshEntries()
287+
return {
288+
update: (patch) => {
289+
if (patch.id && patch.id !== entry.id)
290+
throw new Error(`[@devframes/hub] cannot change a dock id ("${entry.id}" → "${patch.id}")`)
291+
const existing = clientDocks.get(entry.id)
292+
if (!existing)
293+
throw new Error(`[@devframes/hub] client dock "${entry.id}" was removed — register it again to update`)
294+
clientDocks.set(entry.id, { ...existing, ...patch } as DevframeDockEntry)
295+
refreshEntries()
296+
},
297+
dispose: () => {
298+
if (clientDocks.delete(entry.id))
299+
refreshEntries()
300+
},
301+
}
302+
},
303+
update(entry) {
304+
if (!clientDocks.has(entry.id))
305+
throw new Error(`[@devframes/hub] no client dock "${entry.id}" to update — register it first`)
306+
clientDocks.set(entry.id, entry)
307+
refreshEntries()
308+
},
249309
}
250310
return ctx
251311
}
@@ -361,7 +421,7 @@ export async function createDevframeClientHost(
361421
}
362422

363423
function loadClientScripts(): void {
364-
for (const entry of docksState.value() as DevframeDockEntry[]) {
424+
for (const entry of currentEntries()) {
365425
const script = clientScriptOf(entry)
366426
if (!script?.importFrom || loadedScripts.has(entry.id))
367427
continue

‎tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ export interface DockPanelStorage {
5252
open: boolean;
5353
inactiveTimeout: number;
5454
}
55+
export interface DockRegistration<T extends DevframeDockEntry = DevframeDockEntry> {
56+
update: (_: Partial<T>) => void;
57+
dispose: () => void;
58+
}
5559
export interface DockRendererInstance {
5660
dispose?: () => void;
5761
}
@@ -90,6 +94,8 @@ export interface DocksEntriesContext {
9094
getStateById: (_: string) => DockEntryState | undefined;
9195
switchEntry: (_?: string | null) => Promise<boolean>;
9296
toggleEntry: (_: string) => Promise<boolean>;
97+
register: <T extends DevframeDockEntry>(_: T, _?: boolean) => DockRegistration<T>;
98+
update: (_: DevframeDockUserEntry) => void;
9399
}
94100
export interface DocksPanelContext {
95101
store: DockPanelStorage;

0 commit comments

Comments
 (0)