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
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ async function mount(): Promise<void> {
}
}

onMounted(mount)
onMounted(() => {
void mount()
})
watch(() => props.entry.id, () => {
void mount()
})
Expand Down
154 changes: 154 additions & 0 deletions packages/hub-ui/src/client/state/context.test.ts
Original file line number Diff line number Diff line change
@@ -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<Value extends object> extends SharedState<Value> {
push: (value: Value) => void
}

function createStubSharedState<Value extends object>(initialValue: Value): StubSharedState<Value> {
const state = createSharedState({ initialValue }) as StubSharedState<Value>
state.push = value => state.mutate(() => value)
return state
}

function createStubRpc() {
let isTrusted = false
const events = createEventEmitter<any>()
const sharedStates = new Map<string, StubSharedState<any>>()
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<void> {
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<DockSessionStorage>({
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()
})
Comment thread
dvcolomban marked this conversation as resolved.

it('keeps navigation performed before the initial server registry arrives', async () => {
expect.assertions(2)

const { rpc, sharedStates, trust } = createStubRpc()
const session = ref<DockSessionStorage>({
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<DockSessionStorage>({
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)
})
})
89 changes: 62 additions & 27 deletions packages/hub-ui/src/client/state/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<DockRendererManifest>(
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
Expand Down Expand Up @@ -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.
Expand All @@ -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<string, DockEntryState> = reactive(new Map())
watchEffect(() => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<void> => {
if (rpc.isTrusted)
return
await new Promise<void>((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<void> => {
const restoreDockId = restoreIntent.selectedDockId
if (!restoreIntent.open || restoreDockId == null)
return

await Promise.all([
waitUntilTrusted(),
dockEntriesInitialSyncComplete,
rendererManifestInitialSyncComplete,
])
Comment thread
dvcolomban marked this conversation as resolved.

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
}
41 changes: 33 additions & 8 deletions packages/hub-ui/src/client/state/docks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,38 @@ export function sharedStateToRef<T>(sharedState: SharedState<T>): ShallowRef<T>
return ref
}

const docksEntriesRefByRpc = new WeakMap<DevframeRpcClient, ShallowRef<DevframeDockEntry[]>>()
export async function useDocksEntries(rpc: DevframeRpcClient): Promise<Ref<DevframeDockEntry[]>> {
if (docksEntriesRefByRpc.has(rpc)) {
return docksEntriesRefByRpc.get(rpc)!
export function waitForInitialSharedStateSync<Value extends object>(
sharedState: SharedState<Value>,
pendingValue: Value,
): Promise<void> {
if (sharedState.value() !== pendingValue)
return Promise.resolve()

return new Promise<void>((resolve) => {
const stopListening = sharedState.on('updated', () => {
stopListening()
resolve()
})
})
}

interface DocksEntriesState {
entries: ShallowRef<DevframeDockEntry[]>
initialSyncComplete: Promise<void>
}

const docksEntriesStateByRpc = new WeakMap<DevframeRpcClient, DocksEntriesState>()
export async function useDocksEntries(rpc: DevframeRpcClient): Promise<DocksEntriesState> {
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
}
Loading
Loading