Skip to content
Merged
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 an `iframe` `clientScript` or an `action` to initialize it as soon as the RPC connection is trusted, before opening a dock panel. This suits background subscriptions and page commands, such as an `action` that registers page commands or subscribes to `entry:activated` before its first click. A `custom-render` `renderer` needs its mounted panel, so it always initializes on activation. 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` on an `iframe` `clientScript` or an `action` to initialize it after RPC trust, before dock activation. A `custom-render` `renderer` needs its mounted panel, so it always initializes on activation regardless of `eager`. Setup is cached per RPC connection and dock; action clicks execute on every activation.

## Frame-nav messages

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,8 @@ export async function nextDevframeHub(

// The demo dock-client script - the same package the Vite reference
// host loads via a bare specifier - mounted statically and attached as
// a momentary `action` dock by its served URL.
// a momentary `action` dock by its served URL. `eager: true` runs it on
// trust so it subscribes to `entry:activated` before the first click.
if (demoDockClient) {
await ctx.host.mountStatic(DEMO_CLIENT_MOUNT_BASE, demoDockClient.dir)
ctx.docks.register({
Expand All @@ -296,7 +297,7 @@ export async function nextDevframeHub(
title: 'Client Script Demo',
icon: 'ph:plugs-connected-duotone',
category: 'app',
action: { importFrom: demoDockClient.importFrom },
action: { importFrom: demoDockClient.importFrom, eager: true },
})
}

Expand Down
7 changes: 4 additions & 3 deletions examples/custom-hub-vite/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,15 +176,16 @@ export default defineConfig({
// Bare-specifier client script demo: `importFrom` names the npm
// package itself, imported through Vite's own module graph via the
// host's `clientModuleResolution` (`'/@id/{specifier}'`). The Next
// reference host consumes the same package as a prebuilt
// self-contained bundle instead (see examples/demo-dock-client).
// host uses the same package as a prebuilt bundle (see
// examples/demo-dock-client). `eager: true` runs it on trust so it
// subscribes to `entry:activated` before the first click.
context.docks.register({
type: 'action',
id: 'example:demo-client-script',
title: 'Client Script Demo',
icon: 'ph:plugs-connected-duotone',
category: 'app',
action: { importFrom: 'demo-dock-client' },
action: { importFrom: 'demo-dock-client', eager: true },
})

// Witness the missing-renderer path: a dock type nothing covers, so
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
239 changes: 235 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,233 @@ 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('keeps an eager custom-render renderer activation-gated so it mounts into its panel', 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: 'eager-renderer',
type: 'custom-render',
title: 'Eager renderer',
icon: 'ph:play',
renderer: { eager: true, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' },
} satisfies DevframeDockEntry
sharedStates.get('devframe:docks')!.push([entry])
await nextTick()
// A renderer needs its mounted panel, so `eager` must not run it before activation.
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
Loading
Loading