Skip to content

Commit 6591da9

Browse files
committed
fix(hub-ui): initialize dock page scripts before activation
1 parent 1bd966f commit 6591da9

12 files changed

Lines changed: 396 additions & 68 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`. Every dock entry can carry a page-level `clientScript`, including JSON-render entries. By default, it runs inside the host page when the dock entry is first activated, before its activation script. An `action` entry also 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 a descriptor to initialize it as soon as the RPC connection is trusted, before opening a dock panel. This suits background subscriptions and page commands. Page setup and activation scripts initialize independently, even when they import the same export. 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, script role 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+
| Every user dock entry | `clientScript` (optional) | inside the host page on first activation, before the activation script |
135+
136+
`ClientScriptEntry.eager` defaults to `false`. Set it to `true` to initialize that script after RPC trust, before dock activation. Page setup and activation scripts have separate caches; action clicks execute on every activation.
135137

136138
## Frame-nav messages
137139

packages/devframe/src/types/devframe.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,11 @@ export interface DevframeDockDefaults {
329329
* host wiring; a URL or bare specifier passes through untouched.
330330
*/
331331
clientScript?: {
332+
/**
333+
* Initialize after RPC trust without waiting for dock activation.
334+
* @default false
335+
*/
336+
eager?: boolean
332337
/** An absolute filesystem path, a served URL, or a bare npm specifier. */
333338
importFrom: string
334339
/**

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

Lines changed: 164 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { DevframeDockEntry } from '@devframes/hub'
22
import type { DevframeRpcClient } from '@devframes/hub/client'
3+
import type {} from '@devframes/json-render/hub'
34
import type { SharedState } from 'devframe/utils/shared-state'
5+
import { DEVFRAME_EVENTS } from 'devframe/constants'
46
import { createEventEmitter } from 'devframe/utils/events'
57
import { createSharedState } from 'devframe/utils/shared-state'
68
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -42,7 +44,7 @@ function createStubRpc() {
4244

4345
declare global {
4446
// eslint-disable-next-line vars-on-top -- test hook called by the dynamically imported client module
45-
var __DEVFRAME_CLIENT_SCRIPT_ATTEMPT__: (() => void) | undefined
47+
var __DEVFRAME_CLIENT_SCRIPT_ATTEMPT__: (() => void | Promise<void>) | undefined
4648
}
4749

4850
afterEach(() => {
@@ -52,6 +54,7 @@ afterEach(() => {
5254

5355
describe('dock client scripts', () => {
5456
it('retries setup on a later activation after it fails', async () => {
57+
expect.assertions(3)
5558
vi.spyOn(console, 'error').mockImplementation(() => {})
5659
let attempts = 0
5760
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
@@ -63,11 +66,10 @@ describe('dock client scripts', () => {
6366
const context = await createDocksContext('embedded', rpc)
6467
const entry = {
6568
id: 'retry-client-script',
66-
type: 'iframe',
69+
type: 'custom-render',
6770
title: 'Retry client script',
6871
icon: 'ph:play',
69-
url: '/retry',
70-
clientScript: {
72+
renderer: {
7173
importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()',
7274
},
7375
} satisfies DevframeDockEntry
@@ -81,3 +83,161 @@ describe('dock client scripts', () => {
8183
expect(attempts).toBe(2)
8284
})
8385
})
86+
87+
it.each(['iframe', 'json-render'] as const)('starts a %s page script before dock activation, once per RPC client', async (type) => {
88+
expect.assertions(3)
89+
let attempts = 0
90+
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
91+
attempts++
92+
}
93+
const { rpc, sharedStates } = createStubRpc()
94+
const context = await createDocksContext('embedded', rpc)
95+
const clientScript = { eager: true, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' }
96+
const entry = { id: `background-${type}`, type, title: 'Background page script', icon: 'ph:browser', url: '/fixture', view: { stateKey: 'fixture:view' }, clientScript } satisfies DevframeDockEntry
97+
sharedStates.get('devframe:docks')!.push([entry])
98+
await expect.poll(() => attempts).toBe(1)
99+
expect(context.docks.selectedId).toBeNull()
100+
await context.docks.switchEntry(entry.id)
101+
expect(attempts).toBe(1)
102+
})
103+
104+
it('waits for trust and keeps the same dock script bound separately to each RPC client', async () => {
105+
expect.assertions(4)
106+
let attempts = 0
107+
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
108+
attempts++
109+
}
110+
const first = createStubRpc()
111+
const second = createStubRpc()
112+
Object.assign(first.rpc, { isTrusted: false })
113+
await createDocksContext('embedded', first.rpc)
114+
await createDocksContext('embedded', second.rpc)
115+
const entry = {
116+
id: 'per-rpc-page-script',
117+
type: 'iframe',
118+
title: 'Page commands',
119+
icon: 'ph:browser',
120+
url: '/fixture',
121+
clientScript: { eager: true, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' },
122+
} satisfies DevframeDockEntry
123+
first.sharedStates.get('devframe:docks')!.push([entry])
124+
await nextTick()
125+
expect(attempts).toBe(0)
126+
second.sharedStates.get('devframe:docks')!.push([entry])
127+
await expect.poll(() => attempts).toBe(1)
128+
Object.assign(first.rpc, { isTrusted: true })
129+
first.rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true)
130+
await expect.poll(() => attempts).toBe(2)
131+
first.sharedStates.get('devframe:docks')!.push([{ ...entry }])
132+
second.sharedStates.get('devframe:docks')!.push([{ ...entry }])
133+
await nextTick()
134+
expect(attempts).toBe(2)
135+
})
136+
137+
it('does not invoke action docks while initializing page scripts', async () => {
138+
expect.assertions(2)
139+
let attempts = 0
140+
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
141+
attempts++
142+
}
143+
const { rpc, sharedStates } = createStubRpc()
144+
const context = await createDocksContext('embedded', rpc)
145+
const entry = {
146+
id: 'explicit-action-script',
147+
type: 'action',
148+
title: 'Explicit action',
149+
icon: 'ph:play',
150+
action: { importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' },
151+
} satisfies DevframeDockEntry
152+
sharedStates.get('devframe:docks')!.push([entry])
153+
await nextTick()
154+
expect(attempts).toBe(0)
155+
await context.docks.switchEntry(entry.id)
156+
expect(attempts).toBe(1)
157+
})
158+
159+
it.each([undefined, false] as const)('keeps page setup lazy when eager is %s', async (eager) => {
160+
expect.assertions(3)
161+
const attempt = vi.fn()
162+
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = attempt
163+
const { rpc, sharedStates } = createStubRpc()
164+
const context = await createDocksContext('embedded', rpc)
165+
const entry = {
166+
id: 'lazy-page',
167+
type: 'iframe',
168+
title: 'Lazy page',
169+
icon: 'ph:browser',
170+
url: '/fixture',
171+
clientScript: { eager, 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+
expect(attempt).not.toHaveBeenCalled()
176+
await context.docks.switchEntry(entry.id)
177+
expect(attempt).toHaveBeenCalledOnce()
178+
await context.docks.switchEntry(null)
179+
await context.docks.switchEntry(entry.id)
180+
expect(attempt).toHaveBeenCalledOnce()
181+
})
182+
183+
it.each(['action', 'custom-render'] as const)('keeps the %s activation independent of its eager page script', async (type) => {
184+
expect.assertions(5)
185+
const attempt = vi.fn()
186+
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = attempt
187+
const { rpc, sharedStates } = createStubRpc()
188+
const context = await createDocksContext('embedded', rpc)
189+
const script = { importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' }
190+
const entry = {
191+
id: `two-scripts-${type}`,
192+
type,
193+
title: 'Independent scripts',
194+
icon: 'ph:play',
195+
action: script,
196+
renderer: script,
197+
clientScript: { ...script, eager: true },
198+
} satisfies DevframeDockEntry
199+
sharedStates.get('devframe:docks')!.push([entry])
200+
await expect.poll(() => attempt.mock.calls.length).toBe(1)
201+
expect(context.docks.selectedId).toBeNull()
202+
await context.docks.switchEntry(entry.id)
203+
expect(attempt).toHaveBeenCalledTimes(2)
204+
await context.docks.switchEntry(null)
205+
await context.docks.switchEntry(entry.id)
206+
expect(attempt).toHaveBeenCalledTimes(type === 'action' ? 3 : 2)
207+
sharedStates.get('devframe:docks')!.push([{ ...entry }])
208+
await nextTick()
209+
expect(attempt).toHaveBeenCalledTimes(type === 'action' ? 3 : 2)
210+
})
211+
212+
it('awaits an eager page setup before activation and retries it after failure', async () => {
213+
expect.assertions(5)
214+
vi.spyOn(console, 'error').mockImplementation(() => {})
215+
let complete!: () => void
216+
let attempts = 0
217+
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
218+
attempts++
219+
if (attempts === 1)
220+
throw new Error('page setup failed')
221+
if (attempts === 2)
222+
return new Promise<void>((resolve) => { complete = resolve })
223+
}
224+
const { rpc, sharedStates } = createStubRpc()
225+
const context = await createDocksContext('embedded', rpc)
226+
const script = { importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' }
227+
const entry = {
228+
id: 'retry-page-before-renderer',
229+
type: 'custom-render',
230+
title: 'Retry page',
231+
icon: 'ph:play',
232+
renderer: script,
233+
clientScript: { ...script, eager: true },
234+
} satisfies DevframeDockEntry
235+
sharedStates.get('devframe:docks')!.push([entry])
236+
await expect.poll(() => attempts).toBe(1)
237+
const activation = context.docks.switchEntry(entry.id)
238+
await expect.poll(() => attempts).toBe(2)
239+
expect(context.docks.selectedId).toBeNull()
240+
complete()
241+
await expect(activation).resolves.toBe(true)
242+
expect(attempts).toBe(3)
243+
})

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

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

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_ST
1717
import { createClientMessagesClient } from './messages-client'
1818
import { dockCommandId } from './palette'
1919
import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup'
20-
import { executeSetupScript } from './setup-script'
20+
import { dockScript, executeSetupScript } from './setup-script'
2121

2222
const docksContextByRpc = new WeakMap<DevframeRpcClient, DocksContext>()
2323
export async function createDocksContext(
@@ -229,17 +229,41 @@ export async function createDocksContext(
229229
return null
230230
}
231231

232-
const runDockSetupScript = async (entry: DevframeDockEntry) => {
233-
const hasScript = entry.type === 'action' || entry.type === 'custom-render' || (entry.type === 'iframe' && entry.clientScript)
234-
if (!hasScript)
235-
return
236-
const messagesClient = createClientMessagesClient(rpc)
237-
const scriptContext: DockClientScriptContext = reactive({
232+
function scriptContext(entry: DevframeDockEntry): DockClientScriptContext {
233+
return reactive({
238234
...toRefs(docksContext) as any,
239235
current: dockEntryStateMap.get(entry.id)!,
240-
messages: messagesClient,
236+
messages: createClientMessagesClient(rpc),
241237
})
242-
await executeSetupScript(entry, scriptContext)
238+
}
239+
240+
async function runPageScript(entry: DevframeDockEntry): Promise<void> {
241+
if (entry.type === '~builtin' || !entry.clientScript)
242+
return
243+
await executeSetupScript(entry, scriptContext(entry), 'clientScript')
244+
}
245+
246+
async function runActivationScript(entry: DevframeDockEntry): Promise<void> {
247+
if (entry.type === 'action')
248+
await executeSetupScript(entry, scriptContext(entry), 'action')
249+
else if (entry.type === 'custom-render')
250+
await executeSetupScript(entry, scriptContext(entry), 'renderer')
251+
}
252+
253+
/** Only explicitly eager descriptors run before activation, after the RPC connection is trusted. */
254+
function startPageScripts(): void {
255+
if (!rpc.isTrusted)
256+
return
257+
for (const entry of entries.value) {
258+
if (entry.type === '~builtin')
259+
continue
260+
for (const role of ['clientScript', 'action', 'renderer'] as const) {
261+
if (!dockScript(entry, role)?.eager)
262+
continue
263+
/** Setup reports failures and allows the next activation or publication to retry. */
264+
void executeSetupScript(entry, scriptContext(entry), role, true).catch(() => {})
265+
}
266+
}
243267
}
244268

245269
// Remember selection redirects: a member tab as its frame's live tab, and a
@@ -286,11 +310,15 @@ export async function createDocksContext(
286310
return false
287311
}
288312

313+
if (!rpc.isTrusted)
314+
return false
315+
await runPageScript(entry)
316+
289317
initialRestorePending.value = false
290318
selectedDockId.value = entry.id
291319
sessionStore.value.open = true
292320

293-
await runDockSetupScript(entry)
321+
await runActivationScript(entry)
294322
rememberEntrySelection(entry)
295323
return true
296324
}
@@ -707,6 +735,9 @@ export async function createDocksContext(
707735
)
708736
void restoreAfterInitialization()
709737

738+
watch(entries, startPageScripts, { immediate: true })
739+
rpc.events.on(DEVFRAME_EVENTS.client.isTrustedUpdated, startPageScripts)
740+
710741
docksContextByRpc.set(rpc, docksContext)
711742
return docksContext
712743
}

0 commit comments

Comments
 (0)