Skip to content

Commit 1fd6ca9

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

7 files changed

Lines changed: 127 additions & 33 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ 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` and renderer-backed dock entries | `clientScript` (optional) | inside the host page, after trust and before dock activation |
135135

136136
## Frame-nav messages
137137

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

Lines changed: 77 additions & 3 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'
@@ -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,75 @@ 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 = { 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: { 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+
})

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: 25 additions & 9 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 { clientScriptOf, executeSetupScript } from './setup-script'
2121

2222
const docksContextByRpc = new WeakMap<DevframeRpcClient, DocksContext>()
2323
export async function createDocksContext(
@@ -229,17 +229,30 @@ 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 runDockSetupScript(entry: DevframeDockEntry): Promise<void> {
241+
if (entry.type === '~builtin' || !clientScriptOf(entry))
242+
return
243+
await executeSetupScript(entry, scriptContext(entry))
244+
}
245+
246+
/** Page scripts register commands before any dock panel opens; actions and renderers remain activation-driven. */
247+
function startPageScripts(): void {
248+
if (!rpc.isTrusted)
249+
return
250+
for (const entry of entries.value) {
251+
if (entry.type === '~builtin' || entry.type === 'action' || !entry.clientScript)
252+
continue
253+
// executeSetupScript reports failures and permits a later activation or publication to retry.
254+
void executeSetupScript(entry, scriptContext(entry), entry.clientScript).catch(() => {})
255+
}
243256
}
244257

245258
// Remember selection redirects: a member tab as its frame's live tab, and a
@@ -707,6 +720,9 @@ export async function createDocksContext(
707720
)
708721
void restoreAfterInitialization()
709722

723+
watch(entries, startPageScripts, { immediate: true })
724+
rpc.events.on(DEVFRAME_EVENTS.client.isTrustedUpdated, startPageScripts)
725+
710726
docksContextByRpc.set(rpc, docksContext)
711727
return docksContext
712728
}

packages/hub-ui/src/client/state/setup-script.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,27 @@
11
import type { ClientScriptEntry, DevframeDockUserEntry } from '@devframes/hub'
2-
import type { DockClientScriptContext } from '@devframes/hub/client'
2+
import type { DevframeRpcClient, DockClientScriptContext } from '@devframes/hub/client'
33
import { clientScriptFailureHint, resolveClientModuleSpecifier } from '@devframes/hub/client'
44

55
/**
66
* Resolve the {@link ClientScriptEntry} a dock entry carries: an `action`'s
7-
* `action`, a `custom-render`'s `renderer`, or an iframe's `clientScript`.
7+
* `action`, a `custom-render`'s `renderer`, or another dock entry's `clientScript`.
88
*/
9-
function clientScriptOf(entry: DevframeDockUserEntry): ClientScriptEntry | undefined {
9+
export function clientScriptOf(entry: DevframeDockUserEntry): ClientScriptEntry | undefined {
1010
switch (entry.type) {
1111
case 'action':
1212
return entry.action
1313
case 'custom-render':
1414
return entry.renderer
15-
case 'iframe':
16-
return entry.clientScript
1715
default:
18-
return undefined
16+
return entry.clientScript
1917
}
2018
}
2119

2220
async function _executeSetupScript(
2321
entry: DevframeDockUserEntry,
2422
context: DockClientScriptContext,
23+
script: ClientScriptEntry | undefined,
2524
): Promise<void> {
26-
const script = clientScriptOf(entry)
2725
if (!script?.importFrom)
2826
throw new Error(`[@devframes/hub-ui] Dock entry "${entry.id}" carries no client script to run`)
2927
// A bare specifier resolves through the host-advertised template; URL
@@ -53,22 +51,29 @@ async function _executeSetupScript(
5351
throw error
5452
}
5553
}
56-
const _setupPromises = new Map<string, Promise<void>>()
54+
const setupPromisesByRpc = new WeakMap<DevframeRpcClient, Map<string, Promise<void>>>()
5755
export function executeSetupScript(
5856
entry: DevframeDockUserEntry,
5957
context: DockClientScriptContext,
58+
script = clientScriptOf(entry),
6059
): Promise<void> {
60+
let setupPromises = setupPromisesByRpc.get(context.rpc)
61+
if (!setupPromises) {
62+
setupPromises = new Map()
63+
setupPromisesByRpc.set(context.rpc, setupPromises)
64+
}
65+
const key = JSON.stringify([entry.id, script?.importFrom, script?.importName ?? 'default'])
6166
// Actions should re-execute on every click; only cache non-action scripts
62-
if (entry.type !== 'action' && _setupPromises.has(entry.id))
63-
return _setupPromises.get(entry.id)!
64-
const promise = _executeSetupScript(entry, context)
67+
if (entry.type !== 'action' && setupPromises.has(key))
68+
return setupPromises.get(key)!
69+
const promise = _executeSetupScript(entry, context, script)
6570
if (entry.type !== 'action') {
66-
_setupPromises.set(entry.id, promise)
71+
setupPromises.set(key, promise)
6772
promise.catch(() => {
6873
// A failed setup must not poison this entry permanently. The caller still
6974
// receives the rejection, while a later activation or update may retry.
70-
if (_setupPromises.get(entry.id) === promise)
71-
_setupPromises.delete(entry.id)
75+
if (setupPromises.get(key) === promise)
76+
setupPromises.delete(key)
7277
})
7378
}
7479
return promise

packages/hub/src/types/docks.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ export type DevframeDockEntryIcon = string | { light: string, dark: string }
8181
export type DevframeDockBadgeVariant = 'default' | 'info' | 'success' | 'warning' | 'danger'
8282

8383
export interface DevframeDockEntryBase {
84+
/** Page script initialized in the host page independently of dock activation or renderer type. */
85+
clientScript?: ClientScriptEntry
8486
id: string
8587
title: string
8688
icon: DevframeDockEntryIcon
@@ -231,10 +233,6 @@ export interface DevframeViewIframe extends DevframeDockEntryBase {
231233
* share a `frameId` may live in one group, several groups, or none.
232234
*/
233235
frameId?: string
234-
/**
235-
* Optional client script to import into the iframe
236-
*/
237-
clientScript?: ClientScriptEntry
238236
/**
239237
* Soft-navigation target within a shared frame. Set on a **member** dock
240238
* (one of several docks sharing a {@link frameId}) to describe which internal

tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export interface DevframeDockActivation {
8282
params?: Record<string, unknown>;
8383
}
8484
export interface DevframeDockEntryBase {
85+
clientScript?: ClientScriptEntry;
8586
id: string;
8687
title: string;
8788
icon: DevframeDockEntryIcon;
@@ -315,7 +316,6 @@ export interface DevframeViewIframe extends DevframeDockEntryBase {
315316
openExternal?: boolean;
316317
};
317318
frameId?: string;
318-
clientScript?: ClientScriptEntry;
319319
navTarget?: NavTarget;
320320
subTabs?: FrameSubTabsConfig;
321321
remote?: boolean | RemoteDockOptions;

0 commit comments

Comments
 (0)