Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
216 changes: 212 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,210 @@ 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)
})

it.each([false, true])('retries setup after trust is revoked during import (eager: %s)', async (eager) => {
expect.assertions(6)
const reportError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { rpc, sharedStates: states } = createStubRpc()
const context = await createDocksContext('embedded', rpc)
const docks = context.docks
const fixture = globalThis as typeof globalThis & { __DF_IMPORT_GATE_UI__?: () => Promise<void>, __DF_IMPORT_SETUP_UI__?: () => void }
let releaseImport!: () => void
const importGate = new Promise<void>((resolve) => {
releaseImport = resolve
})
const importing = vi.fn(() => importGate)
const setup = vi.fn()
fixture.__DF_IMPORT_GATE_UI__ = importing
fixture.__DF_IMPORT_SETUP_UI__ = setup
const entry = {
id: `revoked-import-${eager}`,
type: 'iframe',
title: 'Revoked import',
icon: 'ph:browser',
url: '/fixture',
clientScript: {
eager,
importFrom: `data:text/javascript,await globalThis.__DF_IMPORT_GATE_UI__(); export default () => globalThis.__DF_IMPORT_SETUP_UI__(); // ${eager}`,
},
} satisfies DevframeDockEntry
try {
states.get('devframe:docks')!.push([entry])
const activation = docks.switchEntry(entry.id)
const rejected = expect(activation).rejects.toThrow('no longer trusted')
await expect.poll(() => importing.mock.calls.length).toBe(1)
Object.assign(rpc, { isTrusted: false })
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false)
releaseImport()
await rejected
expect(setup).not.toHaveBeenCalled()
expect(docks.selectedId).toBeNull()
Object.assign(rpc, { isTrusted: true })
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true)
await expect(docks.switchEntry(entry.id)).resolves.toBe(true)
expect(setup).toHaveBeenCalledOnce()
}
finally {
releaseImport()
delete fixture.__DF_IMPORT_GATE_UI__
delete fixture.__DF_IMPORT_SETUP_UI__
reportError.mockRestore()
}
})

it('does not activate an iframe when trust is lost while its setup completes', async () => {
expect.assertions(2)
const { rpc, sharedStates: states } = createStubRpc()
const context = await createDocksContext('embedded', rpc)
const docks = context.docks
const fixture = globalThis as typeof globalThis & { __DF_SETUP_REVOKE_UI__?: () => void }
fixture.__DF_SETUP_REVOKE_UI__ = () => {
Object.assign(rpc, { isTrusted: false })
rpc.events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false)
}
const entry = {
id: 'revoked-during-setup',
type: 'iframe',
title: 'Revoked setup',
icon: 'ph:browser',
url: '/fixture',
clientScript: { importFrom: 'data:text/javascript,export default async () => globalThis.__DF_SETUP_REVOKE_UI__()' },
} satisfies DevframeDockEntry
try {
states.get('devframe:docks')!.push([entry])
await expect(docks.switchEntry(entry.id)).resolves.toBe(false)
expect(docks.selectedId).toBeNull()
}
finally {
delete fixture.__DF_SETUP_REVOKE_UI__
}
})
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
49 changes: 39 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.
}
}

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

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

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 +733,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
}
Loading
Loading