Skip to content

Commit b3ff169

Browse files
antfubotagent
andcommitted
fix(hub): keep custom-render renderers activation-gated and enable eager demo docks
A `custom-render` renderer needs its mounted panel, so running it eagerly before the panel exists left it cached against a missing container and never re-ran on activation. Restrict eager execution to panel-independent iframe page scripts and action scripts in both the headless runtime and the reference hub UI. Guard the fire-and-forget switchEntry call sites so a failed lazy client script no longer surfaces as an unhandled rejection while its cache entry stays retryable. Opt the demo dock-client into eager initialization in both reference hosts so it subscribes to entry:activated before the first click, and document that a renderer always initializes on activation regardless of eager. Co-authored-by: agent <agent@opencode>
1 parent 91291de commit b3ff169

8 files changed

Lines changed: 76 additions & 16 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ A client-only dock can also carry `type: 'json-render'` with an inline [JSON-ren
6969

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

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

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ Which `ClientScriptEntry` field carries an entry's client script, and when it ru
133133
| `custom-render` | `renderer` | to render the entry's panel |
134134
| `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. Setup is cached per RPC connection and dock; action clicks execute on every activation.
136+
`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.
137137

138138
## Frame-nav messages
139139

examples/custom-hub-next/src/client/devframe/next-devframe-hub.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,8 @@ export async function nextDevframeHub(
287287

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

examples/custom-hub-vite/vite.config.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,15 +176,16 @@ export default defineConfig({
176176
// Bare-specifier client script demo: `importFrom` names the npm
177177
// package itself, imported through Vite's own module graph via the
178178
// host's `clientModuleResolution` (`'/@id/{specifier}'`). The Next
179-
// reference host consumes the same package as a prebuilt
180-
// self-contained bundle instead (see examples/demo-dock-client).
179+
// host uses the same package as a prebuilt bundle (see
180+
// examples/demo-dock-client). `eager: true` runs it on trust so it
181+
// subscribes to `entry:activated` before the first click.
181182
context.docks.register({
182183
type: 'action',
183184
id: 'example:demo-client-script',
184185
title: 'Client Script Demo',
185186
icon: 'ph:plugs-connected-duotone',
186187
category: 'app',
187-
action: { importFrom: 'demo-dock-client' },
188+
action: { importFrom: 'demo-dock-client', eager: true },
188189
})
189190

190191
// Witness the missing-renderer path: a dock type nothing covers, so

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,29 @@ it('does not invoke action docks while initializing page scripts', async () => {
155155
expect(attempts).toBe(1)
156156
})
157157

158+
it('keeps an eager custom-render renderer activation-gated so it mounts into its panel', async () => {
159+
expect.assertions(2)
160+
let attempts = 0
161+
globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = () => {
162+
attempts++
163+
}
164+
const { rpc, sharedStates } = createStubRpc()
165+
const context = await createDocksContext('embedded', rpc)
166+
const entry = {
167+
id: 'eager-renderer',
168+
type: 'custom-render',
169+
title: 'Eager renderer',
170+
icon: 'ph:play',
171+
renderer: { eager: true, importFrom: 'data:text/javascript,export default () => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__()' },
172+
} satisfies DevframeDockEntry
173+
sharedStates.get('devframe:docks')!.push([entry])
174+
await nextTick()
175+
// A renderer needs its mounted panel, so `eager` must not run it before activation.
176+
expect(attempts).toBe(0)
177+
await context.docks.switchEntry(entry.id)
178+
expect(attempts).toBe(1)
179+
})
180+
158181
it.each([undefined, false] as const)('keeps page setup lazy when eager is %s', async (eager) => {
159182
expect.assertions(3)
160183
const attempt = vi.fn()

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,9 @@ export async function createDocksContext(
253253
if (!rpc.isTrusted)
254254
return
255255
for (const entry of entries.value) {
256-
if (entry.type === '~builtin')
256+
// A `custom-render` renderer needs its mounted panel, so it stays
257+
// activation-gated; only panel-independent page and action scripts run eagerly.
258+
if (entry.type !== 'iframe' && entry.type !== 'action')
257259
continue
258260
if (!clientScriptOf(entry)?.eager)
259261
continue
@@ -398,8 +400,10 @@ export async function createDocksContext(
398400
name: HUB_EVENTS.broadcast.docksActivate satisfies keyof DevframeRpcClientFunctions,
399401
type: 'action',
400402
handler: (activation: { dockId: string, params?: Record<string, unknown> }) => {
403+
// `switchEntry` rejects when a lazy client script fails so its cache entry
404+
// stays retryable; the failure is already logged, so swallow it here.
401405
if (activation?.dockId)
402-
switchEntry(activation.dockId)
406+
void switchEntry(activation.dockId).catch(() => {})
403407
},
404408
})
405409

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,35 @@ it('waits for trust for eager setup and activation for lazy setup in the headles
455455
}
456456
})
457457

458+
it('keeps an eager custom-render renderer activation-gated in the headless runtime', async () => {
459+
expect.assertions(2)
460+
const { rpc, states } = createStubRpc()
461+
const runtime = await createDevframeClientRuntime({ rpc })
462+
const attempt = vi.fn()
463+
const fixture = globalThis as typeof globalThis & { __DF_RENDERER_TEST__?: () => void }
464+
fixture.__DF_RENDERER_TEST__ = attempt
465+
const entry = {
466+
id: 'eager-renderer',
467+
title: 'Eager renderer',
468+
icon: 'ph:cube',
469+
type: 'custom-render',
470+
renderer: { eager: true, importFrom: 'data:text/javascript,export default () => globalThis.__DF_RENDERER_TEST__()' },
471+
} satisfies DevframeDockEntry
472+
try {
473+
states.get('devframe:docks')!.push([entry])
474+
// Give any eager import a chance to resolve; a renderer needs its mounted
475+
// panel, so `eager` must not run it before activation.
476+
await new Promise(resolve => setTimeout(resolve, 10))
477+
expect(attempt).not.toHaveBeenCalled()
478+
await runtime.context.docks.switchEntry(entry.id)
479+
expect(attempt).toHaveBeenCalledTimes(1)
480+
}
481+
finally {
482+
runtime.dispose()
483+
delete fixture.__DF_RENDERER_TEST__
484+
}
485+
})
486+
458487
it.each([false, true])('retries setup after trust is revoked during import (eager: %s)', async (eager) => {
459488
expect.assertions(6)
460489
const reportError = vi.spyOn(console, 'error').mockImplementation(() => {})

packages/hub/src/client/host.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,10 @@ export async function createDevframeClientRuntime(
200200
// unknown ids. Chain onto any handler a co-consumer already registered on
201201
// this rpc client rather than replacing it.
202202
const activateHandler = (activation: { dockId?: string } | undefined): void => {
203+
// `switchEntry` rejects when a lazy client script fails so its cache entry
204+
// stays retryable; the failure is already logged, so swallow it here.
203205
if (activation?.dockId)
204-
void switchEntry(activation.dockId)
206+
void switchEntry(activation.dockId).catch(() => {})
205207
}
206208
const existingActivate = rpc.client.definitions.get(DOCKS_ACTIVATE_EVENT)
207209
if (existingActivate) {
@@ -367,7 +369,9 @@ export async function createDevframeClientRuntime(
367369
return selectedId
368370
},
369371
set selectedId(id: string | null) {
370-
void switchEntry(id)
372+
// Setter can't surface a rejection; `switchEntry` already logs a failed
373+
// client script and keeps its cache entry retryable.
374+
void switchEntry(id).catch(() => {})
371375
},
372376
/**
373377
* A mirror of the session field, so a persisting host reads and writes the
@@ -554,15 +558,13 @@ export async function createDevframeClientRuntime(
554558
function loadClientScripts(): void {
555559
if (disposed || !rpc.isTrusted)
556560
return
561+
// A `custom-render` renderer needs its mounted panel, so it stays
562+
// activation-gated; only panel-independent page and action scripts run eagerly.
557563
for (const entry of currentEntries()) {
558-
if (entry.type === '~builtin')
559-
continue
560564
if (entry.type === 'iframe')
561565
startEagerScript(entry.id, entry.clientScript)
562-
if (entry.type === 'action')
566+
else if (entry.type === 'action')
563567
startEagerScript(entry.id, entry.action)
564-
else if (entry.type === 'custom-render')
565-
startEagerScript(entry.id, entry.renderer)
566568
}
567569
}
568570

0 commit comments

Comments
 (0)