Skip to content

Commit 3e7f0fe

Browse files
dvcolombanagentantfubot
authored
fix(hub-ui): initialize dock page scripts before activation (#387)
Co-authored-by: agent <agent@opencode> Co-authored-by: Anthony Fu (via agent) <reg-github-bot@antfu.me>
1 parent 4ecaff9 commit 3e7f0fe

15 files changed

Lines changed: 560 additions & 75 deletions

File tree

docs/content/1.guide/17.client-context.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,16 @@ A client-only dock can also carry `type: 'json-render'` with an inline [JSON-ren
6767

6868
## Dock client scripts
6969

70-
A client script is a `ClientScriptEntry`: `{ importFrom, importName? }` (`importName` defaults `'default'`). The field varies by entry kind: an `action` entry's `action` runs when the dock button is activated, a `custom-render` entry's `renderer` renders its panel, and an `iframe` entry's optional `clientScript` runs alongside the iframe panel inside the host page ([Hub API reference](/references/hub-api#dock-client-script-fields)).
70+
A client script is a `ClientScriptEntry`: `{ importFrom, importName?, eager? }`. `importName` defaults to `'default'` and `eager` defaults to `false`. An `iframe` entry's optional `clientScript` runs inside the host page when the dock entry is first activated. An `action` entry runs its `action` on each activation, while a `custom-render` entry initializes its `renderer` after selection so it can mount into the panel.
71+
72+
Set `eager: true` on an `iframe` `clientScript` or an `action` to initialize it as soon as the RPC connection is trusted, before opening a dock panel. This suits background subscriptions and page commands, such as an `action` that registers page commands or subscribes to `entry:activated` before its first click. A `custom-render` `renderer` needs its mounted panel, so it always initializes on activation. Both the reference hub UI and `createDevframeClientRuntime()` honor these settings ([Hub API reference](/references/hub-api#dock-client-script-fields)).
7173

7274
The exported function (`DockClientScriptContext`) receives the client context and two dock-scoped extras:
7375

7476
- **`current`** holds this entry's state: `entryMeta`, `isActive`, `domElements`, `events` (`entry:activated`, `entry:deactivated`, `entry:updated`, `dom:panel:mounted`, `dom:iframe:mounted`).
7577
- **`messages`**: an entry-scoped messages client (`category` defaults to the entry id; `info`/`warn`/`error`/`success`/`debug` shortcuts for `add()`).
7678

77-
A failed import retries on the next dock update.
79+
Failed setup retries on the next activation, or on a dock update for eager scripts. Setup is cached per RPC connection, dock and import descriptor. Action clicks always execute again.
7880

7981
### Shipping a client script
8082

docs/content/8.references/6.hub-api.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,9 @@ Which `ClientScriptEntry` field carries an entry's client script, and when it ru
131131
|---|---|---|
132132
| `action` | `action` | when the dock button is activated |
133133
| `custom-render` | `renderer` | to render the entry's panel |
134-
| `iframe` | `clientScript` (optional) | alongside the iframe panel, inside the host page |
134+
| `iframe` | `clientScript` (optional) | inside the host page on first activation |
135+
136+
`ClientScriptEntry.eager` defaults to `false`. Set it to `true` on an `iframe` `clientScript` or an `action` to initialize it after RPC trust, before dock activation. A `custom-render` `renderer` needs its mounted panel, so it always initializes on activation regardless of `eager`. Setup is cached per RPC connection and dock; action clicks execute on every activation.
135137

136138
## Frame-nav messages
137139

examples/custom-hub-next/src/client/devframe/next-devframe-hub.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,8 @@ export async function nextDevframeHub(
287287

288288
// The demo dock-client script - the same package the Vite reference
289289
// host loads via a bare specifier - mounted statically and attached as
290-
// a momentary `action` dock by its served URL.
290+
// a momentary `action` dock by its served URL. `eager: true` runs it on
291+
// trust so it subscribes to `entry:activated` before the first click.
291292
if (demoDockClient) {
292293
await ctx.host.mountStatic(DEMO_CLIENT_MOUNT_BASE, demoDockClient.dir)
293294
ctx.docks.register({
@@ -296,7 +297,7 @@ export async function nextDevframeHub(
296297
title: 'Client Script Demo',
297298
icon: 'ph:plugs-connected-duotone',
298299
category: 'app',
299-
action: { importFrom: demoDockClient.importFrom },
300+
action: { importFrom: demoDockClient.importFrom, eager: true },
300301
})
301302
}
302303

examples/custom-hub-vite/vite.config.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,15 +176,16 @@ export default defineConfig({
176176
// Bare-specifier client script demo: `importFrom` names the npm
177177
// package itself, imported through Vite's own module graph via the
178178
// host's `clientModuleResolution` (`'/@id/{specifier}'`). The Next
179-
// reference host consumes the same package as a prebuilt
180-
// self-contained bundle instead (see examples/demo-dock-client).
179+
// host uses the same package as a prebuilt bundle (see
180+
// examples/demo-dock-client). `eager: true` runs it on trust so it
181+
// subscribes to `entry:activated` before the first click.
181182
context.docks.register({
182183
type: 'action',
183184
id: 'example:demo-client-script',
184185
title: 'Client Script Demo',
185186
icon: 'ph:plugs-connected-duotone',
186187
category: 'app',
187-
action: { importFrom: 'demo-dock-client' },
188+
action: { importFrom: 'demo-dock-client', eager: true },
188189
})
189190

190191
// Witness the missing-renderer path: a dock type nothing covers, so

packages/devframe/src/types/devframe.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,11 @@ export interface DevframeDockDefaults {
302302
* host wiring; a URL or bare specifier passes through untouched.
303303
*/
304304
clientScript?: {
305+
/**
306+
* Initialize after RPC trust without waiting for dock activation.
307+
* @default false
308+
*/
309+
eager?: boolean
305310
/** An absolute filesystem path, a served URL, or a bare npm specifier. */
306311
importFrom: string
307312
/**

packages/hub-ui/src/client/state/client-script.integration.test.ts

Lines changed: 235 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DevframeDockEntry } from '@devframes/hub'
22
import type { DevframeRpcClient } from '@devframes/hub/client'
33
import type { SharedState } from 'devframe/utils/shared-state'
4+
import { DEVFRAME_EVENTS } from 'devframe/constants'
45
import { createEventEmitter } from 'devframe/utils/events'
56
import { createSharedState } from 'devframe/utils/shared-state'
67
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -42,7 +43,7 @@ function createStubRpc() {
4243

4344
declare global {
4445
// eslint-disable-next-line vars-on-top -- test hook called by the dynamically imported client module
45-
var __DEVFRAME_CLIENT_SCRIPT_ATTEMPT__: (() => void) | undefined
46+
var __DEVFRAME_CLIENT_SCRIPT_ATTEMPT__: (() => void | Promise<void>) | undefined
4647
}
4748

4849
afterEach(() => {
@@ -52,6 +53,7 @@ afterEach(() => {
5253

5354
describe('dock client scripts', () => {
5455
it('retries setup on a later activation after it fails', async () => {
56+
expect.assertions(3)
5557
vi.spyOn(console, 'error').mockImplementation(() => {})
5658
let attempts = 0
5759
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
@@ -63,11 +65,10 @@ describe('dock client scripts', () => {
6365
const context = await createDocksContext('embedded', rpc)
6466
const entry = {
6567
id: 'retry-client-script',
66-
type: 'iframe',
68+
type: 'custom-render',
6769
title: 'Retry client script',
6870
icon: 'ph:play',
69-
url: '/retry',
70-
clientScript: {
71+
renderer: {
7172
importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()',
7273
},
7374
} satisfies DevframeDockEntry
@@ -81,3 +82,233 @@ describe('dock client scripts', () => {
8182
expect(attempts).toBe(2)
8283
})
8384
})
85+
86+
it('starts an eager iframe script before dock activation, once per RPC client', async () => {
87+
expect.assertions(3)
88+
let attempts = 0
89+
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
90+
attempts++
91+
}
92+
const { rpc, sharedStates } = createStubRpc()
93+
const context = await createDocksContext('embedded', rpc)
94+
const clientScript = { eager: true, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' }
95+
const entry = { id: 'background-iframe', type: 'iframe', title: 'Background page script', icon: 'ph:browser', url: '/fixture', clientScript } satisfies DevframeDockEntry
96+
sharedStates.get('devframe:docks')!.push([entry])
97+
await expect.poll(() => attempts).toBe(1)
98+
expect(context.docks.selectedId).toBeNull()
99+
await context.docks.switchEntry(entry.id)
100+
expect(attempts).toBe(1)
101+
})
102+
103+
it('waits for trust and keeps the same dock script bound separately to each RPC client', async () => {
104+
expect.assertions(4)
105+
let attempts = 0
106+
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
107+
attempts++
108+
}
109+
const first = createStubRpc()
110+
const second = createStubRpc()
111+
Object.assign(first.rpc, { isTrusted: false })
112+
await createDocksContext('embedded', first.rpc)
113+
await createDocksContext('embedded', second.rpc)
114+
const entry = {
115+
id: 'per-rpc-page-script',
116+
type: 'iframe',
117+
title: 'Page commands',
118+
icon: 'ph:browser',
119+
url: '/fixture',
120+
clientScript: { eager: true, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' },
121+
} satisfies DevframeDockEntry
122+
first.sharedStates.get('devframe:docks')!.push([entry])
123+
await nextTick()
124+
expect(attempts).toBe(0)
125+
second.sharedStates.get('devframe:docks')!.push([entry])
126+
await expect.poll(() => attempts).toBe(1)
127+
Object.assign(first.rpc, { isTrusted: true })
128+
first.rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true)
129+
await expect.poll(() => attempts).toBe(2)
130+
first.sharedStates.get('devframe:docks')!.push([{ ...entry }])
131+
second.sharedStates.get('devframe:docks')!.push([{ ...entry }])
132+
await nextTick()
133+
expect(attempts).toBe(2)
134+
})
135+
136+
it('does not invoke action docks while initializing page scripts', async () => {
137+
expect.assertions(2)
138+
let attempts = 0
139+
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
140+
attempts++
141+
}
142+
const { rpc, sharedStates } = createStubRpc()
143+
const context = await createDocksContext('embedded', rpc)
144+
const entry = {
145+
id: 'explicit-action-script',
146+
type: 'action',
147+
title: 'Explicit action',
148+
icon: 'ph:play',
149+
action: { importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' },
150+
} satisfies DevframeDockEntry
151+
sharedStates.get('devframe:docks')!.push([entry])
152+
await nextTick()
153+
expect(attempts).toBe(0)
154+
await context.docks.switchEntry(entry.id)
155+
expect(attempts).toBe(1)
156+
})
157+
158+
it('keeps an eager custom-render renderer activation-gated so it mounts into its panel', async () => {
159+
expect.assertions(2)
160+
let attempts = 0
161+
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
162+
attempts++
163+
}
164+
const { rpc, sharedStates } = createStubRpc()
165+
const context = await createDocksContext('embedded', rpc)
166+
const entry = {
167+
id: 'eager-renderer',
168+
type: 'custom-render',
169+
title: 'Eager renderer',
170+
icon: 'ph:play',
171+
renderer: { eager: true, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' },
172+
} satisfies DevframeDockEntry
173+
sharedStates.get('devframe:docks')!.push([entry])
174+
await nextTick()
175+
// A renderer needs its mounted panel, so `eager` must not run it before activation.
176+
expect(attempts).toBe(0)
177+
await context.docks.switchEntry(entry.id)
178+
expect(attempts).toBe(1)
179+
})
180+
181+
it.each([undefined, false] as const)('keeps page setup lazy when eager is %s', async (eager) => {
182+
expect.assertions(3)
183+
const attempt = vi.fn()
184+
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = attempt
185+
const { rpc, sharedStates } = createStubRpc()
186+
const context = await createDocksContext('embedded', rpc)
187+
const entry = {
188+
id: 'lazy-page',
189+
type: 'iframe',
190+
title: 'Lazy page',
191+
icon: 'ph:browser',
192+
url: '/fixture',
193+
clientScript: { eager, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' },
194+
} satisfies DevframeDockEntry
195+
sharedStates.get('devframe:docks')!.push([entry])
196+
await nextTick()
197+
expect(attempt).not.toHaveBeenCalled()
198+
await context.docks.switchEntry(entry.id)
199+
expect(attempt).toHaveBeenCalledOnce()
200+
await context.docks.switchEntry(null)
201+
await context.docks.switchEntry(entry.id)
202+
expect(attempt).toHaveBeenCalledOnce()
203+
})
204+
205+
it('awaits an eager page setup before activation and retries it after failure', async () => {
206+
expect.assertions(5)
207+
vi.spyOn(console, 'error').mockImplementation(() => {})
208+
let complete!: () => void
209+
let attempts = 0
210+
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
211+
attempts++
212+
if (attempts === 1)
213+
throw new Error('page setup failed')
214+
if (attempts === 2)
215+
return new Promise<void>((resolve) => { complete = resolve })
216+
}
217+
const { rpc, sharedStates } = createStubRpc()
218+
const context = await createDocksContext('embedded', rpc)
219+
const script = { importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' }
220+
const entry = {
221+
id: 'retry-page-before-activation',
222+
type: 'iframe',
223+
title: 'Retry page',
224+
icon: 'ph:play',
225+
url: '/fixture',
226+
clientScript: { ...script, eager: true },
227+
} satisfies DevframeDockEntry
228+
sharedStates.get('devframe:docks')!.push([entry])
229+
await expect.poll(() => attempts).toBe(1)
230+
const activation = context.docks.switchEntry(entry.id)
231+
await expect.poll(() => attempts).toBe(2)
232+
expect(context.docks.selectedId).toBeNull()
233+
complete()
234+
await expect(activation).resolves.toBe(true)
235+
expect(attempts).toBe(2)
236+
})
237+
238+
it.each([false, true])('retries setup after trust is revoked during import (eager: %s)', async (eager) => {
239+
expect.assertions(6)
240+
const reportError = vi.spyOn(console, 'error').mockImplementation(() => {})
241+
const { rpc, sharedStates: states } = createStubRpc()
242+
const context = await createDocksContext('embedded', rpc)
243+
const docks = context.docks
244+
const fixture = globalThis as typeof globalThis & { __DF_IMPORT_GATE_UI__?: () => Promise<void>, __DF_IMPORT_SETUP_UI__?: () => void }
245+
let releaseImport!: () => void
246+
const importGate = new Promise<void>((resolve) => {
247+
releaseImport = resolve
248+
})
249+
const importing = vi.fn(() => importGate)
250+
const setup = vi.fn()
251+
fixture.__DF_IMPORT_GATE_UI__ = importing
252+
fixture.__DF_IMPORT_SETUP_UI__ = setup
253+
const entry = {
254+
id: `revoked-import-${eager}`,
255+
type: 'iframe',
256+
title: 'Revoked import',
257+
icon: 'ph:browser',
258+
url: '/fixture',
259+
clientScript: {
260+
eager,
261+
importFrom: `data:text/javascript,await globalThis.__DF_IMPORT_GATE_UI__(); export default () => globalThis.__DF_IMPORT_SETUP_UI__(); // ${eager}`,
262+
},
263+
} satisfies DevframeDockEntry
264+
try {
265+
states.get('devframe:docks')!.push([entry])
266+
const activation = docks.switchEntry(entry.id)
267+
const rejected = expect(activation).rejects.toThrow('no longer trusted')
268+
await expect.poll(() => importing.mock.calls.length).toBe(1)
269+
Object.assign(rpc, { isTrusted: false })
270+
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false)
271+
releaseImport()
272+
await rejected
273+
expect(setup).not.toHaveBeenCalled()
274+
expect(docks.selectedId).toBeNull()
275+
Object.assign(rpc, { isTrusted: true })
276+
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true)
277+
await expect(docks.switchEntry(entry.id)).resolves.toBe(true)
278+
expect(setup).toHaveBeenCalledOnce()
279+
}
280+
finally {
281+
releaseImport()
282+
delete fixture.__DF_IMPORT_GATE_UI__
283+
delete fixture.__DF_IMPORT_SETUP_UI__
284+
reportError.mockRestore()
285+
}
286+
})
287+
288+
it('does not activate an iframe when trust is lost while its setup completes', async () => {
289+
expect.assertions(2)
290+
const { rpc, sharedStates: states } = createStubRpc()
291+
const context = await createDocksContext('embedded', rpc)
292+
const docks = context.docks
293+
const fixture = globalThis as typeof globalThis & { __DF_SETUP_REVOKE_UI__?: () => void }
294+
fixture.__DF_SETUP_REVOKE_UI__ = () => {
295+
Object.assign(rpc, { isTrusted: false })
296+
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false)
297+
}
298+
const entry = {
299+
id: 'revoked-during-setup',
300+
type: 'iframe',
301+
title: 'Revoked setup',
302+
icon: 'ph:browser',
303+
url: '/fixture',
304+
clientScript: { importFrom: 'data:text/javascript,export default async () => globalThis.__DF_SETUP_REVOKE_UI__()' },
305+
} satisfies DevframeDockEntry
306+
try {
307+
states.get('devframe:docks')!.push([entry])
308+
await expect(docks.switchEntry(entry.id)).resolves.toBe(false)
309+
expect(docks.selectedId).toBeNull()
310+
}
311+
finally {
312+
delete fixture.__DF_SETUP_REVOKE_UI__
313+
}
314+
})

packages/hub-ui/src/client/state/context.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import { nextTick, ref } from 'vue'
1010
import { createDocksContext } from './context'
1111
import { executeSetupScript } from './setup-script'
1212

13-
vi.mock('./setup-script', () => ({
13+
vi.mock('./setup-script', async importOriginal => ({
14+
...await importOriginal<typeof import('./setup-script')>(),
1415
executeSetupScript: vi.fn(async () => {}),
1516
}))
1617

0 commit comments

Comments
 (0)