Skip to content

Commit 171b8bf

Browse files
authored
fix(hub-ui): restore docks after initialization (#276)
1 parent ee07645 commit 171b8bf

7 files changed

Lines changed: 285 additions & 52 deletions

File tree

packages/hub-ui/src/client/components/views/ViewDockRenderer.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ async function mount(): Promise<void> {
5959
}
6060
}
6161
62-
onMounted(mount)
62+
onMounted(() => {
63+
void mount()
64+
})
6365
watch(() => props.entry.id, () => {
6466
void mount()
6567
})
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import type { DevframeDockEntry } from '@devframes/hub'
2+
import type { DevframeRpcClient, DockSessionStorage } from '@devframes/hub/client'
3+
import type { SharedState } from 'devframe/utils/shared-state'
4+
import { DEVFRAME_EVENTS } from 'devframe/constants'
5+
import { createEventEmitter } from 'devframe/utils/events'
6+
import { createSharedState } from 'devframe/utils/shared-state'
7+
import { describe, expect, it, vi } from 'vitest'
8+
import { nextTick, ref } from 'vue'
9+
import { createDocksContext } from './context'
10+
import { executeSetupScript } from './setup-script'
11+
12+
vi.mock('./setup-script', () => ({
13+
executeSetupScript: vi.fn(async () => {}),
14+
}))
15+
16+
const gitEntry = {
17+
id: 'git',
18+
type: 'custom-render',
19+
title: 'Git',
20+
icon: 'ph:git-branch-duotone',
21+
renderer: { importFrom: '/git-client.js' },
22+
} satisfies DevframeDockEntry
23+
24+
interface StubSharedState<Value extends object> extends SharedState<Value> {
25+
push: (value: Value) => void
26+
}
27+
28+
function createStubSharedState<Value extends object>(initialValue: Value): StubSharedState<Value> {
29+
const state = createSharedState({ initialValue }) as StubSharedState<Value>
30+
state.push = value => state.mutate(() => value)
31+
return state
32+
}
33+
34+
function createStubRpc() {
35+
let isTrusted = false
36+
const events = createEventEmitter<any>()
37+
const sharedStates = new Map<string, StubSharedState<any>>()
38+
const rpc = {
39+
get isTrusted() {
40+
return isTrusted
41+
},
42+
status: 'connected',
43+
connectionError: null,
44+
connectionMeta: { backend: 'live', configs: {} },
45+
connection: {},
46+
events,
47+
sharedState: {
48+
async get(key: string, options?: { initialValue?: object }) {
49+
if (!sharedStates.has(key))
50+
sharedStates.set(key, createStubSharedState(options?.initialValue ?? {}))
51+
return sharedStates.get(key)!
52+
},
53+
},
54+
client: {
55+
register: vi.fn(),
56+
},
57+
call: vi.fn(),
58+
} as unknown as DevframeRpcClient
59+
60+
return {
61+
rpc,
62+
sharedStates,
63+
trust() {
64+
isTrusted = true
65+
events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true)
66+
},
67+
}
68+
}
69+
70+
async function flushRestore(): Promise<void> {
71+
await Promise.resolve()
72+
await Promise.resolve()
73+
await nextTick()
74+
}
75+
76+
describe('createDocksContext', () => {
77+
it('mounts a restored dock once after all initial server state arrives', async () => {
78+
expect.assertions(7)
79+
80+
const { rpc, sharedStates, trust } = createStubRpc()
81+
const executeSetupScriptMock = vi.mocked(executeSetupScript)
82+
executeSetupScriptMock.mockClear()
83+
const session = ref<DockSessionStorage>({
84+
open: true,
85+
selectedDockId: 'git',
86+
selectedDockRoute: null,
87+
})
88+
const context = await createDocksContext('embedded', rpc, undefined, session)
89+
90+
/** Mirrors the authorization gate temporarily closing the panel on reload. */
91+
session.value.open = false
92+
trust()
93+
94+
expect(session.value.open).toBe(false)
95+
96+
sharedStates.get('devframe:docks')!.push([gitEntry])
97+
await flushRestore()
98+
99+
expect(context.docks.selected).toBeNull()
100+
expect(session.value.open).toBe(false)
101+
expect(executeSetupScriptMock).not.toHaveBeenCalled()
102+
103+
sharedStates.get('devframe:dock-renderers')!.push({})
104+
await flushRestore()
105+
106+
expect(context.docks.selected?.id).toBe('git')
107+
expect(session.value.open).toBe(true)
108+
expect(executeSetupScriptMock).toHaveBeenCalledOnce()
109+
})
110+
111+
it('keeps navigation performed before the initial server registry arrives', async () => {
112+
expect.assertions(2)
113+
114+
const { rpc, sharedStates, trust } = createStubRpc()
115+
const session = ref<DockSessionStorage>({
116+
open: true,
117+
selectedDockId: 'git',
118+
selectedDockRoute: null,
119+
})
120+
const context = await createDocksContext('embedded', rpc, undefined, session)
121+
122+
session.value.open = false
123+
trust()
124+
await context.docks.switchEntry('~settings')
125+
126+
sharedStates.get('devframe:docks')!.push([gitEntry])
127+
sharedStates.get('devframe:dock-renderers')!.push({})
128+
await flushRestore()
129+
130+
expect(context.docks.selected?.id).toBe('~settings')
131+
expect(session.value.open).toBe(true)
132+
})
133+
134+
it('keeps a dock closed when the user closes it before initialization finishes', async () => {
135+
expect.assertions(2)
136+
137+
const { rpc, sharedStates, trust } = createStubRpc()
138+
const session = ref<DockSessionStorage>({
139+
open: true,
140+
selectedDockId: 'git',
141+
selectedDockRoute: null,
142+
})
143+
const context = await createDocksContext('embedded', rpc, undefined, session)
144+
145+
trust()
146+
await context.docks.switchEntry(null)
147+
sharedStates.get('devframe:docks')!.push([gitEntry])
148+
sharedStates.get('devframe:dock-renderers')!.push({})
149+
await flushRestore()
150+
151+
expect(context.docks.selected).toBeNull()
152+
expect(session.value.open).toBe(false)
153+
})
154+
})

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

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import type { Ref } from 'vue'
66
import type { DevframeDocksUserSettings } from './dock-settings'
77
import { attachFrameNavClient, createDockRenderersContext } from '@devframes/hub/client'
88
import { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY, HUB_EVENTS } from '@devframes/hub/constants'
9+
import { DEVFRAME_EVENTS } from 'devframe/constants'
910
import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue'
1011
import { BUILTIN_ENTRIES, BUILTIN_ENTRY_SETTINGS, DEFAULT_CATEGORIES_ORDER, HUB_UI_HIDE_EVENT } from '../constants'
1112
import { useBranding } from './branding'
1213
import { createCommandsContext } from './commands'
1314
import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, getRegisteredGroupIds, resolveCommandIcon, resolveGroupDefaultChild } from './dock-settings'
14-
import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE, sharedStateToRef, useDocksEntries } from './docks'
15+
import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE, sharedStateToRef, useDocksEntries, waitForInitialSharedStateSync } from './docks'
1516
import { createClientMessagesClient } from './messages-client'
1617
import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup'
1718
import { executeSetupScript } from './setup-script'
@@ -27,16 +28,22 @@ export async function createDocksContext(
2728
return docksContextByRpc.get(rpc)!
2829
}
2930

30-
const dockEntries = await useDocksEntries(rpc)
31+
const { entries: dockEntries, initialSyncComplete: dockEntriesInitialSyncComplete } = await useDocksEntries(rpc)
3132

3233
// The hub's renderer manifest (`initHub({ renderers })`): dock type →
3334
// prebuilt renderer-module entry. The registry below lazy-imports a module
3435
// the first time a dock of its type mounts; locally-registered renderers win.
36+
/** Identity marker replaced by the first server response, including an empty manifest. */
37+
const pendingRendererManifest: DockRendererManifest = {}
3538
const rendererManifestState = await rpc.sharedState.get<DockRendererManifest>(
3639
DOCK_RENDERERS_STATE_KEY,
37-
{ initialValue: {} },
40+
{ initialValue: pendingRendererManifest },
3841
)
3942
const rendererManifest = sharedStateToRef(rendererManifestState)
43+
const rendererManifestInitialSyncComplete = waitForInitialSharedStateSync(
44+
rendererManifestState,
45+
pendingRendererManifest,
46+
)
4047

4148
// Client-only dock registry (0.7.10 `DocksEntriesContext` API). Docks
4249
// registered here live in this page only, merged over the server-provided
@@ -85,6 +92,10 @@ export async function createDocksContext(
8592
const restoreIntent = {
8693
...sessionStore.value,
8794
}
95+
/** Keep the persisted view unmounted until its server-backed registries are ready. */
96+
const initialRestorePending = ref(
97+
restoreIntent.open && restoreIntent.selectedDockId != null,
98+
)
8899

89100
// `selectedDockId` is backed by the session store so the current selection both
90101
// drives the UI and persists across reloads through one source of truth.
@@ -101,11 +112,13 @@ export async function createDocksContext(
101112
},
102113
})
103114

104-
const selected = computed(
105-
() => entries.value.find(entry => entry.id === selectedDockId.value)
115+
const selected = computed(() => {
116+
if (initialRestorePending.value)
117+
return null
118+
return entries.value.find(entry => entry.id === selectedDockId.value)
106119
?? BUILTIN_ENTRIES.find(entry => entry.id === selectedDockId.value)
107-
?? null,
108-
)
120+
?? null
121+
})
109122

110123
const dockEntryStateMap: Map<string, DockEntryState> = reactive(new Map())
111124
watchEffect(() => {
@@ -200,12 +213,14 @@ export async function createDocksContext(
200213

201214
const switchEntry = async (id: string | null = null) => {
202215
if (id == null) {
216+
initialRestorePending.value = false
203217
selectedDockId.value = null
204218
sessionStore.value.open = false
205219
sessionStore.value.selectedDockRoute = null
206220
return true
207221
}
208222
if (id === '~client-auth-notice') {
223+
initialRestorePending.value = false
209224
selectedDockId.value = id
210225
sessionStore.value.open = true
211226
return true
@@ -273,6 +288,7 @@ export async function createDocksContext(
273288
if (entry.type === 'iframe' && entry.frameId && !entry.subTabs)
274289
frameNavCurrentMember.set(entry.frameId, entry.id)
275290

291+
initialRestorePending.value = false
276292
selectedDockId.value = entry.id
277293
sessionStore.value.open = true
278294
// Only an iframe dock owns an address-bar route; ViewIframe keeps
@@ -602,29 +618,48 @@ export async function createDocksContext(
602618
return switchEntry(entry.id)
603619
})
604620

605-
// Restore the persisted selection once the RPC is trusted. A reload starts
606-
// untrusted, and Dock.vue force-closes the panel during that window (and a
607-
// revocation clears the selection), so the durable intent captured in
608-
// `restoreIntent` is re-applied here after the handshake — re-running the
609-
// dock's setup script and re-opening the panel on the dock the developer left
610-
// open. `switchEntry` reads `session.selectedDockRoute` back through `consumeBootRoute`
611-
// when the restored iframe boots.
612-
const applyRestore = (): void => {
613-
if (restoreIntent.open && restoreIntent.selectedDockId != null)
614-
void switchEntry(restoreIntent.selectedDockId)
615-
}
616-
if (rpc.isTrusted) {
617-
applyRestore()
618-
}
619-
else {
620-
const off = rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
621-
if (!isTrusted)
622-
return
623-
off()
624-
applyRestore()
621+
const waitUntilTrusted = async (): Promise<void> => {
622+
if (rpc.isTrusted)
623+
return
624+
await new Promise<void>((resolve) => {
625+
const stopListening = rpc.events.on(DEVFRAME_EVENTS.client.isTrustedUpdated, (isTrusted) => {
626+
if (!isTrusted)
627+
return
628+
stopListening()
629+
resolve()
630+
})
625631
})
626632
}
627633

634+
// A reload starts untrusted, and Dock.vue temporarily closes the panel during
635+
// that window. The trust event precedes the asynchronous `devframe:docks`
636+
// and renderer-manifest responses, so wait for all three before re-applying
637+
// the captured session intent.
638+
// `switchEntry` then consumes the persisted iframe route when the view boots.
639+
const restoreAfterInitialization = async (): Promise<void> => {
640+
const restoreDockId = restoreIntent.selectedDockId
641+
if (!restoreIntent.open || restoreDockId == null)
642+
return
643+
644+
await Promise.all([
645+
waitUntilTrusted(),
646+
dockEntriesInitialSyncComplete,
647+
rendererManifestInitialSyncComplete,
648+
])
649+
650+
if (!initialRestorePending.value)
651+
return
652+
653+
if (selectedDockId.value !== restoreDockId) {
654+
initialRestorePending.value = false
655+
return
656+
}
657+
658+
initialRestorePending.value = false
659+
await switchEntry(restoreDockId)
660+
}
661+
void restoreAfterInitialization()
662+
628663
docksContextByRpc.set(rpc, docksContext)
629664
return docksContext
630665
}

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

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,38 @@ export function sharedStateToRef<T>(sharedState: SharedState<T>): ShallowRef<T>
8888
return ref
8989
}
9090

91-
const docksEntriesRefByRpc = new WeakMap<DevframeRpcClient, ShallowRef<DevframeDockEntry[]>>()
92-
export async function useDocksEntries(rpc: DevframeRpcClient): Promise<Ref<DevframeDockEntry[]>> {
93-
if (docksEntriesRefByRpc.has(rpc)) {
94-
return docksEntriesRefByRpc.get(rpc)!
91+
export function waitForInitialSharedStateSync<Value extends object>(
92+
sharedState: SharedState<Value>,
93+
pendingValue: Value,
94+
): Promise<void> {
95+
if (sharedState.value() !== pendingValue)
96+
return Promise.resolve()
97+
98+
return new Promise<void>((resolve) => {
99+
const stopListening = sharedState.on('updated', () => {
100+
stopListening()
101+
resolve()
102+
})
103+
})
104+
}
105+
106+
interface DocksEntriesState {
107+
entries: ShallowRef<DevframeDockEntry[]>
108+
initialSyncComplete: Promise<void>
109+
}
110+
111+
const docksEntriesStateByRpc = new WeakMap<DevframeRpcClient, DocksEntriesState>()
112+
export async function useDocksEntries(rpc: DevframeRpcClient): Promise<DocksEntriesState> {
113+
if (docksEntriesStateByRpc.has(rpc)) {
114+
return docksEntriesStateByRpc.get(rpc)!
95115
}
96-
const state = await rpc.sharedState.get('devframe:docks', { initialValue: [] })
97-
const docksEntriesRef = sharedStateToRef(state)
98-
docksEntriesRefByRpc.set(rpc, docksEntriesRef)
99-
return docksEntriesRef
116+
117+
/** Identity marker replaced by the first server response, including an empty registry. */
118+
const pendingEntries: DevframeDockEntry[] = []
119+
const state = await rpc.sharedState.get('devframe:docks', { initialValue: pendingEntries })
120+
const entries = sharedStateToRef(state)
121+
const initialSyncComplete = waitForInitialSharedStateSync(state, pendingEntries)
122+
const docksEntriesState = { entries, initialSyncComplete }
123+
docksEntriesStateByRpc.set(rpc, docksEntriesState)
124+
return docksEntriesState
100125
}

0 commit comments

Comments
 (0)