diff --git a/packages/hub-ui/src/client/components/views/ViewDockRenderer.vue b/packages/hub-ui/src/client/components/views/ViewDockRenderer.vue index 5c6247f3..438da0dd 100644 --- a/packages/hub-ui/src/client/components/views/ViewDockRenderer.vue +++ b/packages/hub-ui/src/client/components/views/ViewDockRenderer.vue @@ -59,7 +59,9 @@ async function mount(): Promise { } } -onMounted(mount) +onMounted(() => { + void mount() +}) watch(() => props.entry.id, () => { void mount() }) diff --git a/packages/hub-ui/src/client/state/context.test.ts b/packages/hub-ui/src/client/state/context.test.ts new file mode 100644 index 00000000..7df400d9 --- /dev/null +++ b/packages/hub-ui/src/client/state/context.test.ts @@ -0,0 +1,154 @@ +import type { DevframeDockEntry } from '@devframes/hub' +import type { DevframeRpcClient, DockSessionStorage } 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 { describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' +import { createDocksContext } from './context' +import { executeSetupScript } from './setup-script' + +vi.mock('./setup-script', () => ({ + executeSetupScript: vi.fn(async () => {}), +})) + +const gitEntry = { + id: 'git', + type: 'custom-render', + title: 'Git', + icon: 'ph:git-branch-duotone', + renderer: { importFrom: '/git-client.js' }, +} satisfies DevframeDockEntry + +interface StubSharedState extends SharedState { + push: (value: Value) => void +} + +function createStubSharedState(initialValue: Value): StubSharedState { + const state = createSharedState({ initialValue }) as StubSharedState + state.push = value => state.mutate(() => value) + return state +} + +function createStubRpc() { + let isTrusted = false + const events = createEventEmitter() + const sharedStates = new Map>() + const rpc = { + get isTrusted() { + return isTrusted + }, + status: 'connected', + connectionError: null, + connectionMeta: { backend: 'live', configs: {} }, + connection: {}, + events, + sharedState: { + async get(key: string, options?: { initialValue?: object }) { + if (!sharedStates.has(key)) + sharedStates.set(key, createStubSharedState(options?.initialValue ?? {})) + return sharedStates.get(key)! + }, + }, + client: { + register: vi.fn(), + }, + call: vi.fn(), + } as unknown as DevframeRpcClient + + return { + rpc, + sharedStates, + trust() { + isTrusted = true + events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true) + }, + } +} + +async function flushRestore(): Promise { + await Promise.resolve() + await Promise.resolve() + await nextTick() +} + +describe('createDocksContext', () => { + it('mounts a restored dock once after all initial server state arrives', async () => { + expect.assertions(7) + + const { rpc, sharedStates, trust } = createStubRpc() + const executeSetupScriptMock = vi.mocked(executeSetupScript) + executeSetupScriptMock.mockClear() + const session = ref({ + open: true, + selectedDockId: 'git', + selectedDockRoute: null, + }) + const context = await createDocksContext('embedded', rpc, undefined, session) + + /** Mirrors the authorization gate temporarily closing the panel on reload. */ + session.value.open = false + trust() + + expect(session.value.open).toBe(false) + + sharedStates.get('devframe:docks')!.push([gitEntry]) + await flushRestore() + + expect(context.docks.selected).toBeNull() + expect(session.value.open).toBe(false) + expect(executeSetupScriptMock).not.toHaveBeenCalled() + + sharedStates.get('devframe:dock-renderers')!.push({}) + await flushRestore() + + expect(context.docks.selected?.id).toBe('git') + expect(session.value.open).toBe(true) + expect(executeSetupScriptMock).toHaveBeenCalledOnce() + }) + + it('keeps navigation performed before the initial server registry arrives', async () => { + expect.assertions(2) + + const { rpc, sharedStates, trust } = createStubRpc() + const session = ref({ + open: true, + selectedDockId: 'git', + selectedDockRoute: null, + }) + const context = await createDocksContext('embedded', rpc, undefined, session) + + session.value.open = false + trust() + await context.docks.switchEntry('~settings') + + sharedStates.get('devframe:docks')!.push([gitEntry]) + sharedStates.get('devframe:dock-renderers')!.push({}) + await flushRestore() + + expect(context.docks.selected?.id).toBe('~settings') + expect(session.value.open).toBe(true) + }) + + it('keeps a dock closed when the user closes it before initialization finishes', async () => { + expect.assertions(2) + + const { rpc, sharedStates, trust } = createStubRpc() + const session = ref({ + open: true, + selectedDockId: 'git', + selectedDockRoute: null, + }) + const context = await createDocksContext('embedded', rpc, undefined, session) + + trust() + await context.docks.switchEntry(null) + sharedStates.get('devframe:docks')!.push([gitEntry]) + sharedStates.get('devframe:dock-renderers')!.push({}) + await flushRestore() + + expect(context.docks.selected).toBeNull() + expect(session.value.open).toBe(false) + }) +}) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 6665e931..5819bc73 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -6,12 +6,13 @@ import type { Ref } from 'vue' import type { DevframeDocksUserSettings } from './dock-settings' import { attachFrameNavClient, createDockRenderersContext } from '@devframes/hub/client' import { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY, HUB_EVENTS } from '@devframes/hub/constants' +import { DEVFRAME_EVENTS } from 'devframe/constants' import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue' import { BUILTIN_ENTRIES, BUILTIN_ENTRY_SETTINGS, DEFAULT_CATEGORIES_ORDER, HUB_UI_HIDE_EVENT } from '../constants' import { useBranding } from './branding' import { createCommandsContext } from './commands' import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, getRegisteredGroupIds, resolveCommandIcon, resolveGroupDefaultChild } from './dock-settings' -import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE, sharedStateToRef, useDocksEntries } from './docks' +import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE, sharedStateToRef, useDocksEntries, waitForInitialSharedStateSync } from './docks' import { createClientMessagesClient } from './messages-client' import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup' import { executeSetupScript } from './setup-script' @@ -27,16 +28,22 @@ export async function createDocksContext( return docksContextByRpc.get(rpc)! } - const dockEntries = await useDocksEntries(rpc) + const { entries: dockEntries, initialSyncComplete: dockEntriesInitialSyncComplete } = await useDocksEntries(rpc) // The hub's renderer manifest (`initHub({ renderers })`): dock type → // prebuilt renderer-module entry. The registry below lazy-imports a module // the first time a dock of its type mounts; locally-registered renderers win. + /** Identity marker replaced by the first server response, including an empty manifest. */ + const pendingRendererManifest: DockRendererManifest = {} const rendererManifestState = await rpc.sharedState.get( DOCK_RENDERERS_STATE_KEY, - { initialValue: {} }, + { initialValue: pendingRendererManifest }, ) const rendererManifest = sharedStateToRef(rendererManifestState) + const rendererManifestInitialSyncComplete = waitForInitialSharedStateSync( + rendererManifestState, + pendingRendererManifest, + ) // Client-only dock registry (0.7.10 `DocksEntriesContext` API). Docks // registered here live in this page only, merged over the server-provided @@ -85,6 +92,10 @@ export async function createDocksContext( const restoreIntent = { ...sessionStore.value, } + /** Keep the persisted view unmounted until its server-backed registries are ready. */ + const initialRestorePending = ref( + restoreIntent.open && restoreIntent.selectedDockId != null, + ) // `selectedDockId` is backed by the session store so the current selection both // drives the UI and persists across reloads through one source of truth. @@ -101,11 +112,13 @@ export async function createDocksContext( }, }) - const selected = computed( - () => entries.value.find(entry => entry.id === selectedDockId.value) + const selected = computed(() => { + if (initialRestorePending.value) + return null + return entries.value.find(entry => entry.id === selectedDockId.value) ?? BUILTIN_ENTRIES.find(entry => entry.id === selectedDockId.value) - ?? null, - ) + ?? null + }) const dockEntryStateMap: Map = reactive(new Map()) watchEffect(() => { @@ -200,12 +213,14 @@ export async function createDocksContext( const switchEntry = async (id: string | null = null) => { if (id == null) { + initialRestorePending.value = false selectedDockId.value = null sessionStore.value.open = false sessionStore.value.selectedDockRoute = null return true } if (id === '~client-auth-notice') { + initialRestorePending.value = false selectedDockId.value = id sessionStore.value.open = true return true @@ -273,6 +288,7 @@ export async function createDocksContext( if (entry.type === 'iframe' && entry.frameId && !entry.subTabs) frameNavCurrentMember.set(entry.frameId, entry.id) + initialRestorePending.value = false selectedDockId.value = entry.id sessionStore.value.open = true // Only an iframe dock owns an address-bar route; ViewIframe keeps @@ -602,29 +618,48 @@ export async function createDocksContext( return switchEntry(entry.id) }) - // Restore the persisted selection once the RPC is trusted. A reload starts - // untrusted, and Dock.vue force-closes the panel during that window (and a - // revocation clears the selection), so the durable intent captured in - // `restoreIntent` is re-applied here after the handshake — re-running the - // dock's setup script and re-opening the panel on the dock the developer left - // open. `switchEntry` reads `session.selectedDockRoute` back through `consumeBootRoute` - // when the restored iframe boots. - const applyRestore = (): void => { - if (restoreIntent.open && restoreIntent.selectedDockId != null) - void switchEntry(restoreIntent.selectedDockId) - } - if (rpc.isTrusted) { - applyRestore() - } - else { - const off = rpc.events.on('rpc:is-trusted:updated', (isTrusted) => { - if (!isTrusted) - return - off() - applyRestore() + const waitUntilTrusted = async (): Promise => { + if (rpc.isTrusted) + return + await new Promise((resolve) => { + const stopListening = rpc.events.on(DEVFRAME_EVENTS.client.isTrustedUpdated, (isTrusted) => { + if (!isTrusted) + return + stopListening() + resolve() + }) }) } + // A reload starts untrusted, and Dock.vue temporarily closes the panel during + // that window. The trust event precedes the asynchronous `devframe:docks` + // and renderer-manifest responses, so wait for all three before re-applying + // the captured session intent. + // `switchEntry` then consumes the persisted iframe route when the view boots. + const restoreAfterInitialization = async (): Promise => { + const restoreDockId = restoreIntent.selectedDockId + if (!restoreIntent.open || restoreDockId == null) + return + + await Promise.all([ + waitUntilTrusted(), + dockEntriesInitialSyncComplete, + rendererManifestInitialSyncComplete, + ]) + + if (!initialRestorePending.value) + return + + if (selectedDockId.value !== restoreDockId) { + initialRestorePending.value = false + return + } + + initialRestorePending.value = false + await switchEntry(restoreDockId) + } + void restoreAfterInitialization() + docksContextByRpc.set(rpc, docksContext) return docksContext } diff --git a/packages/hub-ui/src/client/state/docks.ts b/packages/hub-ui/src/client/state/docks.ts index 56577424..d6c998e2 100644 --- a/packages/hub-ui/src/client/state/docks.ts +++ b/packages/hub-ui/src/client/state/docks.ts @@ -88,13 +88,38 @@ export function sharedStateToRef(sharedState: SharedState): ShallowRef return ref } -const docksEntriesRefByRpc = new WeakMap>() -export async function useDocksEntries(rpc: DevframeRpcClient): Promise> { - if (docksEntriesRefByRpc.has(rpc)) { - return docksEntriesRefByRpc.get(rpc)! +export function waitForInitialSharedStateSync( + sharedState: SharedState, + pendingValue: Value, +): Promise { + if (sharedState.value() !== pendingValue) + return Promise.resolve() + + return new Promise((resolve) => { + const stopListening = sharedState.on('updated', () => { + stopListening() + resolve() + }) + }) +} + +interface DocksEntriesState { + entries: ShallowRef + initialSyncComplete: Promise +} + +const docksEntriesStateByRpc = new WeakMap() +export async function useDocksEntries(rpc: DevframeRpcClient): Promise { + if (docksEntriesStateByRpc.has(rpc)) { + return docksEntriesStateByRpc.get(rpc)! } - const state = await rpc.sharedState.get('devframe:docks', { initialValue: [] }) - const docksEntriesRef = sharedStateToRef(state) - docksEntriesRefByRpc.set(rpc, docksEntriesRef) - return docksEntriesRef + + /** Identity marker replaced by the first server response, including an empty registry. */ + const pendingEntries: DevframeDockEntry[] = [] + const state = await rpc.sharedState.get('devframe:docks', { initialValue: pendingEntries }) + const entries = sharedStateToRef(state) + const initialSyncComplete = waitForInitialSharedStateSync(state, pendingEntries) + const docksEntriesState = { entries, initialSyncComplete } + docksEntriesStateByRpc.set(rpc, docksEntriesState) + return docksEntriesState } diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index 511d3649..4649b21d 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -7,6 +7,7 @@ import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { getPort } from 'get-port-please' import { describe, expect, it } from 'vitest' +import { DOCK_RENDERERS_STATE_KEY } from '../../constants' import { DEVFRAMES_HUB_BASE, initHub } from '../initiate' function makeDist(html: string): string { @@ -44,6 +45,23 @@ function connectWsClient(url: string) { } describe('initHub', () => { + it('publishes an empty renderer manifest when no renderers are registered', async () => { + expect.assertions(2) + const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false }) + + try { + await hub.ready + const context = await hub.context + expect(context.rpc.sharedState.keys()).toContain(DOCK_RENDERERS_STATE_KEY) + + const rendererManifest = await context.rpc.sharedState.get(DOCK_RENDERERS_STATE_KEY) + expect(rendererManifest.value()).toEqual({}) + } + finally { + await hub.close() + } + }) + it('connectionMeta() before ready throws DF8003', () => { const hub = initHub({ base: DEVFRAMES_HUB_BASE, auth: false }) expect(() => hub.connectionMeta()).toThrow(/DF8003|finished initializing/) diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index a27b7dec..6f6aa439 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -546,24 +546,22 @@ export function initHub(options: InitHubOptions): HubInstance { // into the connection meta right after this `init` returns. await options.ui?.setup?.(ctx) - // Publish the renderer manifest — one `ClientScriptEntry` per dock - // `type`, `importFrom` base-absolute so it resolves to the served module - // from any page depth. Clients read it from shared state and import a - // module lazily the first time a dock of that type mounts. - if (rendererRegistrations.length > 0) { - const manifest: Record = {} - for (const registration of rendererRegistrations) { - manifest[registration.type] = { - importFrom: joinURL(base, '__renderers', `${registration.type}.mjs`), - ...(registration.importName ? { importName: registration.importName } : {}), - } + // Publish the authoritative renderer manifest, including an empty one. + // Each `importFrom` is base-absolute so it resolves to the served module + // from any page depth. Clients import a module lazily the first time a + // dock of that type mounts. + const manifest: Record = {} + for (const registration of rendererRegistrations) { + manifest[registration.type] = { + importFrom: joinURL(base, '__renderers', `${registration.type}.mjs`), + ...(registration.importName ? { importName: registration.importName } : {}), } - const manifestState = await ctx.rpc.sharedState.get>( - DOCK_RENDERERS_STATE_KEY, - { initialValue: {} }, - ) - manifestState.mutate(() => manifest) } + const manifestState = await ctx.rpc.sharedState.get>( + DOCK_RENDERERS_STATE_KEY, + { initialValue: {} }, + ) + manifestState.mutate(() => manifest) // Aggregate MCP — one Streamable-HTTP endpoint over the shared // context's whole registry (tool ids are namespaced per plugin, and the diff --git a/vitest.config.ts b/vitest.config.ts index 4faf5a7b..281226ff 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ projects: [ 'packages/devframe', 'packages/hub', + 'packages/hub-ui', 'packages/json-render', 'packages/json-render-ui', 'plugins/code-server',