Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/content/1.guide/17.client-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,16 @@ A client-only dock can also carry `type: 'json-render'` with an inline [JSON-ren

## Dock client scripts

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

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

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

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

A failed import retries on the next dock update.
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.

### Shipping a client script

Expand Down
4 changes: 3 additions & 1 deletion docs/content/8.references/6.hub-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ Which `ClientScriptEntry` field carries an entry's client script, and when it ru
|---|---|---|
| `action` | `action` | when the dock button is activated |
| `custom-render` | `renderer` | to render the entry's panel |
| `iframe` | `clientScript` (optional) | alongside the iframe panel, inside the host page |
| `iframe` | `clientScript` (optional) | inside the host page on first activation |

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

## Frame-nav messages

Expand Down
5 changes: 5 additions & 0 deletions packages/devframe/src/types/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,11 @@ export interface DevframeDockDefaults {
* host wiring; a URL or bare specifier passes through untouched.
*/
clientScript?: {
/**
* Initialize after RPC trust without waiting for dock activation.
* @default false
*/
eager?: boolean
/** An absolute filesystem path, a served URL, or a bare npm specifier. */
importFrom: string
/**
Expand Down
138 changes: 134 additions & 4 deletions packages/hub-ui/src/client/state/client-script.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { DevframeDockEntry } from '@devframes/hub'
import type { DevframeRpcClient } from '@devframes/hub/client'
import type { SharedState } from 'devframe/utils/shared-state'
import { DEVFRAME_EVENTS } from 'devframe/constants'
import { createEventEmitter } from 'devframe/utils/events'
import { createSharedState } from 'devframe/utils/shared-state'
import { afterEach, describe, expect, it, vi } from 'vitest'
Expand Down Expand Up @@ -42,7 +43,7 @@ function createStubRpc() {

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

afterEach(() => {
Expand All @@ -52,6 +53,7 @@ afterEach(() => {

describe('dock client scripts', () => {
it('retries setup on a later activation after it fails', async () => {
expect.assertions(3)
vi.spyOn(console, 'error').mockImplementation(() => {})
let attempts = 0
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
Expand All @@ -63,11 +65,10 @@ describe('dock client scripts', () => {
const context = await createDocksContext('embedded', rpc)
const entry = {
id: 'retry-client-script',
type: 'iframe',
type: 'custom-render',
title: 'Retry client script',
icon: 'ph:play',
url: '/retry',
clientScript: {
renderer: {
importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()',
},
} satisfies DevframeDockEntry
Expand All @@ -81,3 +82,132 @@ describe('dock client scripts', () => {
expect(attempts).toBe(2)
})
})

it('starts an eager iframe script before dock activation, once per RPC client', async () => {
expect.assertions(3)
let attempts = 0
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
attempts++
}
const { rpc, sharedStates } = createStubRpc()
const context = await createDocksContext('embedded', rpc)
const clientScript = { eager: true, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' }
const entry = { id: 'background-iframe', type: 'iframe', title: 'Background page script', icon: 'ph:browser', url: '/fixture', clientScript } satisfies DevframeDockEntry
sharedStates.get('devframe:docks')!.push([entry])
await expect.poll(() => attempts).toBe(1)
expect(context.docks.selectedId).toBeNull()
await context.docks.switchEntry(entry.id)
expect(attempts).toBe(1)
})

it('waits for trust and keeps the same dock script bound separately to each RPC client', async () => {
expect.assertions(4)
let attempts = 0
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
attempts++
}
const first = createStubRpc()
const second = createStubRpc()
Object.assign(first.rpc, { isTrusted: false })
await createDocksContext('embedded', first.rpc)
await createDocksContext('embedded', second.rpc)
const entry = {
id: 'per-rpc-page-script',
type: 'iframe',
title: 'Page commands',
icon: 'ph:browser',
url: '/fixture',
clientScript: { eager: true, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' },
} satisfies DevframeDockEntry
first.sharedStates.get('devframe:docks')!.push([entry])
await nextTick()
expect(attempts).toBe(0)
second.sharedStates.get('devframe:docks')!.push([entry])
await expect.poll(() => attempts).toBe(1)
Object.assign(first.rpc, { isTrusted: true })
first.rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true)
await expect.poll(() => attempts).toBe(2)
first.sharedStates.get('devframe:docks')!.push([{ ...entry }])
second.sharedStates.get('devframe:docks')!.push([{ ...entry }])
await nextTick()
expect(attempts).toBe(2)
})

it('does not invoke action docks while initializing page scripts', async () => {
expect.assertions(2)
let attempts = 0
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
attempts++
}
const { rpc, sharedStates } = createStubRpc()
const context = await createDocksContext('embedded', rpc)
const entry = {
id: 'explicit-action-script',
type: 'action',
title: 'Explicit action',
icon: 'ph:play',
action: { importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' },
} satisfies DevframeDockEntry
sharedStates.get('devframe:docks')!.push([entry])
await nextTick()
expect(attempts).toBe(0)
await context.docks.switchEntry(entry.id)
expect(attempts).toBe(1)
})

it.each([undefined, false] as const)('keeps page setup lazy when eager is %s', async (eager) => {
expect.assertions(3)
const attempt = vi.fn()
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = attempt
const { rpc, sharedStates } = createStubRpc()
const context = await createDocksContext('embedded', rpc)
const entry = {
id: 'lazy-page',
type: 'iframe',
title: 'Lazy page',
icon: 'ph:browser',
url: '/fixture',
clientScript: { eager, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' },
} satisfies DevframeDockEntry
sharedStates.get('devframe:docks')!.push([entry])
await nextTick()
expect(attempt).not.toHaveBeenCalled()
await context.docks.switchEntry(entry.id)
expect(attempt).toHaveBeenCalledOnce()
await context.docks.switchEntry(null)
await context.docks.switchEntry(entry.id)
expect(attempt).toHaveBeenCalledOnce()
})

it('awaits an eager page setup before activation and retries it after failure', async () => {
expect.assertions(5)
vi.spyOn(console, 'error').mockImplementation(() => {})
let complete!: () => void
let attempts = 0
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
attempts++
if (attempts === 1)
throw new Error('page setup failed')
if (attempts === 2)
return new Promise<void>((resolve) => { complete = resolve })
}
const { rpc, sharedStates } = createStubRpc()
const context = await createDocksContext('embedded', rpc)
const script = { importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' }
const entry = {
id: 'retry-page-before-activation',
type: 'iframe',
title: 'Retry page',
icon: 'ph:play',
url: '/fixture',
clientScript: { ...script, eager: true },
} satisfies DevframeDockEntry
sharedStates.get('devframe:docks')!.push([entry])
await expect.poll(() => attempts).toBe(1)
const activation = context.docks.switchEntry(entry.id)
await expect.poll(() => attempts).toBe(2)
expect(context.docks.selectedId).toBeNull()
complete()
await expect(activation).resolves.toBe(true)
expect(attempts).toBe(2)
})
3 changes: 2 additions & 1 deletion packages/hub-ui/src/client/state/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { nextTick, ref } from 'vue'
import { createDocksContext } from './context'
import { executeSetupScript } from './setup-script'

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

Expand Down
47 changes: 37 additions & 10 deletions packages/hub-ui/src/client/state/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_ST
import { createClientMessagesClient } from './messages-client'
import { dockCommandId } from './palette'
import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup'
import { executeSetupScript } from './setup-script'
import { clientScriptOf, executeSetupScript } from './setup-script'

const docksContextByRpc = new WeakMap<DevframeRpcClient, DocksContext>()
export async function createDocksContext(
Expand Down Expand Up @@ -229,17 +229,37 @@ export async function createDocksContext(
return null
}

const runDockSetupScript = async (entry: DevframeDockEntry) => {
const hasScript = entry.type === 'action' || entry.type === 'custom-render' || (entry.type === 'iframe' && entry.clientScript)
if (!hasScript)
return
const messagesClient = createClientMessagesClient(rpc)
const scriptContext: DockClientScriptContext = reactive({
function scriptContext(entry: DevframeDockEntry): DockClientScriptContext {
return reactive({
...toRefs(docksContext) as any,
current: dockEntryStateMap.get(entry.id)!,
messages: messagesClient,
messages: createClientMessagesClient(rpc),
})
await executeSetupScript(entry, scriptContext)
}

async function runPageScript(entry: DevframeDockEntry): Promise<void> {
if (entry.type !== 'iframe' || !entry.clientScript)
return
await executeSetupScript(entry, scriptContext(entry))
}

async function runActivationScript(entry: DevframeDockEntry): Promise<void> {
if (entry.type === 'action' || entry.type === 'custom-render')
await executeSetupScript(entry, scriptContext(entry))
}

/** Only explicitly eager descriptors run before activation, after the RPC connection is trusted. */
function startPageScripts(): void {
if (!rpc.isTrusted)
return
for (const entry of entries.value) {
if (entry.type === '~builtin')
continue
if (!clientScriptOf(entry)?.eager)
continue
/** Setup reports failures and allows the next activation or publication to retry. */
void executeSetupScript(entry, scriptContext(entry), true).catch(() => {})
Comment thread
Copilot marked this conversation as resolved.
Resolved
}
}

// Remember selection redirects: a member tab as its frame's live tab, and a
Expand Down Expand Up @@ -286,11 +306,15 @@ export async function createDocksContext(
return false
}

if (!rpc.isTrusted)
return false
await runPageScript(entry)

initialRestorePending.value = false
selectedDockId.value = entry.id
sessionStore.value.open = true

await runDockSetupScript(entry)
await runActivationScript(entry)
rememberEntrySelection(entry)
return true
}
Expand Down Expand Up @@ -707,6 +731,9 @@ export async function createDocksContext(
)
void restoreAfterInitialization()

watch(entries, startPageScripts, { immediate: true })
rpc.events.on(DEVFRAME_EVENTS.client.isTrustedUpdated, startPageScripts)

docksContextByRpc.set(rpc, docksContext)
return docksContext
}
62 changes: 32 additions & 30 deletions packages/hub-ui/src/client/state/setup-script.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,22 @@
import type { ClientScriptEntry, DevframeDockUserEntry } from '@devframes/hub'
import type { DockClientScriptContext } from '@devframes/hub/client'
import type { DevframeRpcClient, DockClientScriptContext } from '@devframes/hub/client'
import { clientScriptFailureHint, resolveClientModuleSpecifier } from '@devframes/hub/client'

/**
* Resolve the {@link ClientScriptEntry} a dock entry carries: an `action`'s
* `action`, a `custom-render`'s `renderer`, or an iframe's `clientScript`.
*/
function clientScriptOf(entry: DevframeDockUserEntry): ClientScriptEntry | undefined {
switch (entry.type) {
case 'action':
return entry.action
case 'custom-render':
return entry.renderer
case 'iframe':
return entry.clientScript
default:
return undefined
}
/** Resolve the existing script field for this dock kind. */
export function clientScriptOf(entry: DevframeDockUserEntry): ClientScriptEntry | undefined {
if (entry.type === 'iframe')
return entry.clientScript
if (entry.type === 'action')
return entry.action
if (entry.type === 'custom-render')
return entry.renderer
}

async function _executeSetupScript(
entry: DevframeDockUserEntry,
context: DockClientScriptContext,
script: ClientScriptEntry | undefined,
): Promise<void> {
const script = clientScriptOf(entry)
if (!script?.importFrom)
throw new Error(`[@devframes/hub-ui] Dock entry "${entry.id}" carries no client script to run`)
// A bare specifier resolves through the host-advertised template; URL
Expand Down Expand Up @@ -53,23 +46,32 @@ async function _executeSetupScript(
throw error
}
}
const _setupPromises = new Map<string, Promise<void>>()
const setupPromisesByRpc = new WeakMap<DevframeRpcClient, Map<string, Promise<void>>>()

/** Cache setup per RPC connection and dock; explicit action clicks always run again. */
export function executeSetupScript(
entry: DevframeDockUserEntry,
context: DockClientScriptContext,
cache = entry.type !== 'action',
): Promise<void> {
// Actions should re-execute on every click; only cache non-action scripts
if (entry.type !== 'action' && _setupPromises.has(entry.id))
return _setupPromises.get(entry.id)!
const promise = _executeSetupScript(entry, context)
if (entry.type !== 'action') {
_setupPromises.set(entry.id, promise)
promise.catch(() => {
// A failed setup must not poison this entry permanently. The caller still
// receives the rejection, while a later activation or update may retry.
if (_setupPromises.get(entry.id) === promise)
_setupPromises.delete(entry.id)
})
const script = clientScriptOf(entry)
let setupPromises = setupPromisesByRpc.get(context.rpc)
if (!setupPromises) {
setupPromises = new Map()
setupPromisesByRpc.set(context.rpc, setupPromises)
}
const key = JSON.stringify([entry.id, script?.importFrom, script?.importName ?? 'default'])
const existing = setupPromises.get(key)
if (cache && existing)
return existing
const promise = _executeSetupScript(entry, context, script)
Comment thread
Copilot marked this conversation as resolved.
Resolved
if (!cache)
return promise
setupPromises.set(key, promise)
void promise.catch(() => {
/** Failed setup can retry on activation or a later dock publication. */
if (setupPromises.get(key) === promise)
setupPromises.delete(key)
})
return promise
}
Loading
Loading