Skip to content

Commit 00e2bdb

Browse files
committed
fix(hub-ui): restore docks after initialization
1 parent 4fcf5dc commit 00e2bdb

5 files changed

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

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

Lines changed: 61 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { BUILTIN_ENTRIES, BUILTIN_ENTRY_SETTINGS, DEFAULT_CATEGORIES_ORDER, HUB_
1111
import { useBranding } from './branding'
1212
import { createCommandsContext } from './commands'
1313
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'
14+
import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE, sharedStateToRef, useDocksEntries, waitForInitialSharedStateSync } from './docks'
1515
import { createClientMessagesClient } from './messages-client'
1616
import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup'
1717
import { executeSetupScript } from './setup-script'
@@ -27,16 +27,22 @@ export async function createDocksContext(
2727
return docksContextByRpc.get(rpc)!
2828
}
2929

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

3232
// The hub's renderer manifest (`initHub({ renderers })`): dock type →
3333
// prebuilt renderer-module entry. The registry below lazy-imports a module
3434
// the first time a dock of its type mounts; locally-registered renderers win.
35+
/** Identity marker replaced by the first server response, including an empty manifest. */
36+
const pendingRendererManifest: DockRendererManifest = {}
3537
const rendererManifestState = await rpc.sharedState.get<DockRendererManifest>(
3638
DOCK_RENDERERS_STATE_KEY,
37-
{ initialValue: {} },
39+
{ initialValue: pendingRendererManifest },
3840
)
3941
const rendererManifest = sharedStateToRef(rendererManifestState)
42+
const rendererManifestInitialSyncComplete = waitForInitialSharedStateSync(
43+
rendererManifestState,
44+
pendingRendererManifest,
45+
)
4046

4147
// Client-only dock registry (0.7.10 `DocksEntriesContext` API). Docks
4248
// registered here live in this page only, merged over the server-provided
@@ -85,6 +91,10 @@ export async function createDocksContext(
8591
const restoreIntent = {
8692
...sessionStore.value,
8793
}
94+
/** Keep the persisted view unmounted until its server-backed registries are ready. */
95+
const initialRestorePending = ref(
96+
restoreIntent.open && restoreIntent.selectedDockId != null,
97+
)
8898

8999
// `selectedDockId` is backed by the session store so the current selection both
90100
// drives the UI and persists across reloads through one source of truth.
@@ -101,11 +111,13 @@ export async function createDocksContext(
101111
},
102112
})
103113

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

110122
const dockEntryStateMap: Map<string, DockEntryState> = reactive(new Map())
111123
watchEffect(() => {
@@ -200,12 +212,14 @@ export async function createDocksContext(
200212

201213
const switchEntry = async (id: string | null = null) => {
202214
if (id == null) {
215+
initialRestorePending.value = false
203216
selectedDockId.value = null
204217
sessionStore.value.open = false
205218
sessionStore.value.selectedDockRoute = null
206219
return true
207220
}
208221
if (id === '~client-auth-notice') {
222+
initialRestorePending.value = false
209223
selectedDockId.value = id
210224
sessionStore.value.open = true
211225
return true
@@ -273,6 +287,7 @@ export async function createDocksContext(
273287
if (entry.type === 'iframe' && entry.frameId && !entry.subTabs)
274288
frameNavCurrentMember.set(entry.frameId, entry.id)
275289

290+
initialRestorePending.value = false
276291
selectedDockId.value = entry.id
277292
sessionStore.value.open = true
278293
// Only an iframe dock owns an address-bar route; ViewIframe keeps
@@ -602,29 +617,48 @@ export async function createDocksContext(
602617
return switchEntry(entry.id)
603618
})
604619

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()
620+
const waitUntilTrusted = async (): Promise<void> => {
621+
if (rpc.isTrusted)
622+
return
623+
await new Promise<void>((resolve) => {
624+
const stopListening = rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
625+
if (!isTrusted)
626+
return
627+
stopListening()
628+
resolve()
629+
})
625630
})
626631
}
627632

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

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>(
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
}

vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default defineConfig({
1515
projects: [
1616
'packages/devframe',
1717
'packages/hub',
18+
'packages/hub-ui',
1819
'packages/json-render',
1920
'packages/json-render-ui',
2021
'plugins/code-server',

0 commit comments

Comments
 (0)