Skip to content

Commit 51d4100

Browse files
committed
fix(hub): limit eager scripts to existing dock entry points
1 parent e2b7d82 commit 51d4100

9 files changed

Lines changed: 46 additions & 117 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +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?, 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.
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.
7171

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)).
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. Both the reference hub UI and `createDevframeClientRuntime()` honor these settings ([Hub API reference](/references/hub-api#dock-client-script-fields)).
7373

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

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

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

8181
### Shipping a client script
8282

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +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-
| Every user dock entry | `clientScript` (optional) | inside the host page on first activation, before the activation script |
134+
| `iframe` | `clientScript` (optional) | inside the host page on first activation |
135135

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.
136+
`ClientScriptEntry.eager` defaults to `false`. Set it to `true` to initialize that script after RPC trust, before dock activation. Setup is cached per RPC connection and dock; action clicks execute on every activation.
137137

138138
## Frame-nav messages
139139

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

Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { DevframeDockEntry } from '@devframes/hub'
22
import type { DevframeRpcClient } from '@devframes/hub/client'
3-
import type {} from '@devframes/json-render/hub'
43
import type { SharedState } from 'devframe/utils/shared-state'
54
import { DEVFRAME_EVENTS } from 'devframe/constants'
65
import { createEventEmitter } from 'devframe/utils/events'
@@ -84,7 +83,7 @@ describe('dock client scripts', () => {
8483
})
8584
})
8685

87-
it.each(['iframe', 'json-render'] as const)('starts a %s page script before dock activation, once per RPC client', async (type) => {
86+
it('starts an eager iframe script before dock activation, once per RPC client', async () => {
8887
expect.assertions(3)
8988
let attempts = 0
9089
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
@@ -93,7 +92,7 @@ it.each(['iframe', 'json-render'] as const)('starts a %s page script before dock
9392
const { rpc, sharedStates } = createStubRpc()
9493
const context = await createDocksContext('embedded', rpc)
9594
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
95+
const entry = { id: 'background-iframe', type: 'iframe', title: 'Background page script', icon: 'ph:browser', url: '/fixture', clientScript } satisfies DevframeDockEntry
9796
sharedStates.get('devframe:docks')!.push([entry])
9897
await expect.poll(() => attempts).toBe(1)
9998
expect(context.docks.selectedId).toBeNull()
@@ -180,35 +179,6 @@ it.each([undefined, false] as const)('keeps page setup lazy when eager is %s', a
180179
expect(attempt).toHaveBeenCalledOnce()
181180
})
182181

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-
212182
it('awaits an eager page setup before activation and retries it after failure', async () => {
213183
expect.assertions(5)
214184
vi.spyOn(console, 'error').mockImplementation(() => {})
@@ -225,11 +195,11 @@ it('awaits an eager page setup before activation and retries it after failure',
225195
const context = await createDocksContext('embedded', rpc)
226196
const script = { importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' }
227197
const entry = {
228-
id: 'retry-page-before-renderer',
229-
type: 'custom-render',
198+
id: 'retry-page-before-activation',
199+
type: 'iframe',
230200
title: 'Retry page',
231201
icon: 'ph:play',
232-
renderer: script,
202+
url: '/fixture',
233203
clientScript: { ...script, eager: true },
234204
} satisfies DevframeDockEntry
235205
sharedStates.get('devframe:docks')!.push([entry])
@@ -239,5 +209,5 @@ it('awaits an eager page setup before activation and retries it after failure',
239209
expect(context.docks.selectedId).toBeNull()
240210
complete()
241211
await expect(activation).resolves.toBe(true)
242-
expect(attempts).toBe(3)
212+
expect(attempts).toBe(2)
243213
})

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

Lines changed: 9 additions & 13 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 { dockScript, executeSetupScript } from './setup-script'
20+
import { clientScriptOf, executeSetupScript } from './setup-script'
2121

2222
const docksContextByRpc = new WeakMap<DevframeRpcClient, DocksContext>()
2323
export async function createDocksContext(
@@ -238,16 +238,14 @@ export async function createDocksContext(
238238
}
239239

240240
async function runPageScript(entry: DevframeDockEntry): Promise<void> {
241-
if (entry.type === '~builtin' || !entry.clientScript)
241+
if (entry.type !== 'iframe' || !entry.clientScript)
242242
return
243-
await executeSetupScript(entry, scriptContext(entry), 'clientScript')
243+
await executeSetupScript(entry, scriptContext(entry))
244244
}
245245

246246
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')
247+
if (entry.type === 'action' || entry.type === 'custom-render')
248+
await executeSetupScript(entry, scriptContext(entry))
251249
}
252250

253251
/** Only explicitly eager descriptors run before activation, after the RPC connection is trusted. */
@@ -257,12 +255,10 @@ export async function createDocksContext(
257255
for (const entry of entries.value) {
258256
if (entry.type === '~builtin')
259257
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-
}
258+
if (!clientScriptOf(entry)?.eager)
259+
continue
260+
/** Setup reports failures and allows the next activation or publication to retry. */
261+
void executeSetupScript(entry, scriptContext(entry), true).catch(() => {})
266262
}
267263
}
268264

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

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,13 @@ import type { ClientScriptEntry, DevframeDockUserEntry } from '@devframes/hub'
22
import type { DevframeRpcClient, DockClientScriptContext } from '@devframes/hub/client'
33
import { clientScriptFailureHint, resolveClientModuleSpecifier } from '@devframes/hub/client'
44

5-
export type DockScriptRole = 'clientScript' | 'action' | 'renderer'
6-
7-
/** Page setup and activation scripts have independent initialization lifetimes. */
8-
export function dockScript(entry: DevframeDockUserEntry, role: DockScriptRole): ClientScriptEntry | undefined {
9-
if (role === 'clientScript')
5+
/** Resolve the existing script field for this dock kind. */
6+
export function clientScriptOf(entry: DevframeDockUserEntry): ClientScriptEntry | undefined {
7+
if (entry.type === 'iframe')
108
return entry.clientScript
11-
if (role === 'action' && entry.type === 'action')
9+
if (entry.type === 'action')
1210
return entry.action
13-
if (role === 'renderer' && entry.type === 'custom-render')
11+
if (entry.type === 'custom-render')
1412
return entry.renderer
1513
}
1614

@@ -50,20 +48,19 @@ async function _executeSetupScript(
5048
}
5149
const setupPromisesByRpc = new WeakMap<DevframeRpcClient, Map<string, Promise<void>>>()
5250

53-
/** Cache setup per RPC connection, dock and role; explicit action clicks always run again. */
51+
/** Cache setup per RPC connection and dock; explicit action clicks always run again. */
5452
export function executeSetupScript(
5553
entry: DevframeDockUserEntry,
5654
context: DockClientScriptContext,
57-
role: DockScriptRole,
58-
cache = role !== 'action',
55+
cache = entry.type !== 'action',
5956
): Promise<void> {
60-
const script = dockScript(entry, role)
57+
const script = clientScriptOf(entry)
6158
let setupPromises = setupPromisesByRpc.get(context.rpc)
6259
if (!setupPromises) {
6360
setupPromises = new Map()
6461
setupPromisesByRpc.set(context.rpc, setupPromises)
6562
}
66-
const key = JSON.stringify([entry.id, role, script?.importFrom, script?.importName ?? 'default'])
63+
const key = JSON.stringify([entry.id, script?.importFrom, script?.importName ?? 'default'])
6764
const existing = setupPromises.get(key)
6865
if (cache && existing)
6966
return existing

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

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -425,41 +425,6 @@ describe('createDevframeClientRuntime', () => {
425425
})
426426
})
427427

428-
it.each(['action', 'custom-render'] as const)('loads independent page and activation scripts in the headless %s runtime', async (type) => {
429-
expect.assertions(5)
430-
const { rpc, states } = createStubRpc()
431-
const runtime = await createDevframeClientRuntime({ rpc })
432-
const attempt = vi.fn()
433-
const fixture = globalThis as typeof globalThis & { __DF_ROLE_TEST__?: () => void }
434-
fixture.__DF_ROLE_TEST__ = attempt
435-
const script = { importFrom: 'data:text/javascript,export default () => globalThis.__DF_ROLE_TEST__()' }
436-
const entry = {
437-
id: 'independent-scripts',
438-
type,
439-
title: 'Independent scripts',
440-
icon: 'ph:play',
441-
clientScript: { ...script, eager: true },
442-
action: script,
443-
renderer: script,
444-
} as DevframeDockEntry
445-
try {
446-
states.get('devframe:docks')!.push([entry])
447-
await expect.poll(() => attempt.mock.calls.length).toBe(1)
448-
expect(runtime.context.docks.selectedId).toBeNull()
449-
await runtime.context.docks.switchEntry(entry.id)
450-
expect(attempt).toHaveBeenCalledTimes(2)
451-
await runtime.context.docks.switchEntry(null)
452-
await runtime.context.docks.switchEntry(entry.id)
453-
expect(attempt).toHaveBeenCalledTimes(type === 'action' ? 3 : 2)
454-
states.get('devframe:docks')!.push([{ ...entry }])
455-
expect(attempt).toHaveBeenCalledTimes(type === 'action' ? 3 : 2)
456-
}
457-
finally {
458-
runtime.dispose()
459-
delete fixture.__DF_ROLE_TEST__
460-
}
461-
})
462-
463428
it('waits for trust for eager setup and activation for lazy setup in the headless runtime', async () => {
464429
expect.assertions(5)
465430
const { rpc, states } = createStubRpc()

packages/hub/src/client/host.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -424,16 +424,16 @@ export async function createDevframeClientRuntime(
424424
async function preparePageScript(entry: DevframeDockEntry): Promise<boolean> {
425425
if (!rpc.isTrusted)
426426
return false
427-
if (entry.type !== '~builtin' && entry.clientScript)
428-
await setupClientScript(entry.id, entry.clientScript, 'clientScript')
427+
if (entry.type === 'iframe' && entry.clientScript)
428+
await setupClientScript(entry.id, entry.clientScript)
429429
return !disposed && entryToStateMap.get(entry.id)?.entryMeta === entry
430430
}
431431

432432
async function runActivationScript(entry: DevframeDockEntry): Promise<void> {
433433
if (entry.type === 'action')
434-
await setupClientScript(entry.id, entry.action, 'action', false)
434+
await setupClientScript(entry.id, entry.action, false)
435435
else if (entry.type === 'custom-render')
436-
await setupClientScript(entry.id, entry.renderer, 'renderer')
436+
await setupClientScript(entry.id, entry.renderer)
437437
}
438438

439439
async function switchEntry(id?: string | null): Promise<boolean> {
@@ -446,7 +446,7 @@ export async function createDevframeClientRuntime(
446446
const entry = entryToStateMap.get(next ?? '')?.entryMeta
447447
if (entry && loadScriptsEnabled && !rpc.isTrusted)
448448
return false
449-
if (entry?.type !== '~builtin' && entry?.clientScript && loadScriptsEnabled && !await preparePageScript(entry))
449+
if (entry?.type === 'iframe' && entry.clientScript && loadScriptsEnabled && !await preparePageScript(entry))
450450
return false
451451

452452
const previous = selectedId
@@ -557,22 +557,23 @@ export async function createDevframeClientRuntime(
557557
for (const entry of currentEntries()) {
558558
if (entry.type === '~builtin')
559559
continue
560-
startEagerScript(entry.id, entry.clientScript, 'clientScript')
560+
if (entry.type === 'iframe')
561+
startEagerScript(entry.id, entry.clientScript)
561562
if (entry.type === 'action')
562-
startEagerScript(entry.id, entry.action, 'action')
563+
startEagerScript(entry.id, entry.action)
563564
else if (entry.type === 'custom-render')
564-
startEagerScript(entry.id, entry.renderer, 'renderer')
565+
startEagerScript(entry.id, entry.renderer)
565566
}
566567
}
567568

568-
function startEagerScript(entryId: string, script: ClientScriptEntry | undefined, role: string): void {
569+
function startEagerScript(entryId: string, script: ClientScriptEntry | undefined): void {
569570
if (script?.eager)
570-
void setupClientScript(entryId, script, role).catch(() => {})
571+
void setupClientScript(entryId, script).catch(() => {})
571572
}
572573

573-
/** Keep page and activation setup separate even when they import the same export. */
574-
function setupClientScript(entryId: string, script: ClientScriptEntry, role: string, cache = true): Promise<void> {
575-
const key = JSON.stringify([entryId, role, script.importFrom, script.importName ?? 'default'])
574+
/** Share eager and activation setup; explicit action invocations bypass the cache. */
575+
function setupClientScript(entryId: string, script: ClientScriptEntry, cache = true): Promise<void> {
576+
const key = JSON.stringify([entryId, script.importFrom, script.importName ?? 'default'])
576577
const existing = loadedScripts.get(key)
577578
if (cache && existing)
578579
return existing

packages/hub/src/types/docks.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,6 @@ 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 run in the host page when activated, or after trust when `eager: true`. */
85-
clientScript?: ClientScriptEntry
8684
id: string
8785
title: string
8886
icon: DevframeDockEntryIcon
@@ -238,6 +236,8 @@ export interface DevframeViewIframe extends DevframeDockEntryBase {
238236
* share a `frameId` may live in one group, several groups, or none.
239237
*/
240238
frameId?: string
239+
/** Optional page script, initialized on activation or after trust when `eager: true`. */
240+
clientScript?: ClientScriptEntry
241241
/**
242242
* Soft-navigation target within a shared frame. Set on a **member** dock
243243
* (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
@@ -83,7 +83,6 @@ export interface DevframeDockActivation {
8383
params?: Record<string, unknown>;
8484
}
8585
export interface DevframeDockEntryBase {
86-
clientScript?: ClientScriptEntry;
8786
id: string;
8887
title: string;
8988
icon: DevframeDockEntryIcon;
@@ -317,6 +316,7 @@ export interface DevframeViewIframe extends DevframeDockEntryBase {
317316
openExternal?: boolean;
318317
};
319318
frameId?: string;
319+
clientScript?: ClientScriptEntry;
320320
navTarget?: NavTarget;
321321
subTabs?: FrameSubTabsConfig;
322322
remote?: boolean | RemoteDockOptions;

0 commit comments

Comments
 (0)