Skip to content

Need: a way to know the dock panel is inactive, and to keep its UI state across a reload #229

Description

@dvcolomban

The issue

1. No way for Node-side code to know the panel is inactive

Selecting a dock, or opening/closing the panel, is pure client-side Vue state — never reported to the Node side:

const switchEntry = async (id: string | null = null) => {
if (id == null) {
selectedId.value = null
panelStore.value.open = false
return true
}
if (id === '~client-auth-notice') {
selectedId.value = id
panelStore.value.open = true
return true
}
const entry = entries.value.find(e => e.id === id)
if (!entry)
return false
// A group has no view of its own — resolve to the member it represents.
// Prefer the author's `defaultChildId` (honoring its `when` clause but
// ignoring its render-only `visibility` — see `resolveGroupDefaultChild`),
// otherwise the first member. With neither, the group is popover-only and
// selecting it is a no-op here (the dock-bar group button opens the
// member popover instead).
if (entry.type === 'group') {
const target = resolveGroupDefaultChild(entries.value, entry.id, entry.defaultChildId, getWhenContext())?.id
?? getGroupMembers(entries.value, entry.id)[0]?.id
if (!target)
return false
return switchEntry(target)
}
// A `subTabs` anchor owns the shared frame but has no view of its own apart
// from its synthesized member tabs, and is usually hidden from the bar
// (`visibility: 'false'`). Once the frame has reported a current tab,
// selecting the anchor — via a group `defaultChildId` boot, the command
// palette, or an RPC activation — redirects to that live member so a visible
// dock is highlighted instead of the invisible anchor. Before any tab exists
// (first boot) there is no current member, so we fall through and select the
// anchor itself to mount its iframe and boot the frame.
if (entry.type === 'iframe' && entry.subTabs) {
const frameId = entry.frameId ?? entry.id
const currentMemberId = frameNavCurrentMember.get(frameId)
if (currentMemberId && currentMemberId !== id && entries.value.some(e => e.id === currentMemberId))
return switchEntry(currentMemberId)
}
// If the action is in a popup, delegate to the main frame
if (entry.type === 'action') {
const delegated = await triggerMainFrameDockAction(clientType, entry.id)
if (delegated != null)
return false
}
// If has import script, run it
if (
(entry.type === 'action')
|| (entry.type === 'custom-render')
|| (entry.type === 'iframe' && entry.clientScript)
) {
const current = dockEntryStateMap.get(id)!
const messagesClient = createClientMessagesClient(rpc)
const scriptContext: DockClientScriptContext = reactive({
...toRefs(docksContext) as any,
current,
messages: messagesClient,
logs: messagesClient,
})
await executeSetupScript(entry, scriptContext)
}
// Remember the shared frame's current member tab (a member carries its
// anchor's `frameId` but is not itself a `subTabs` anchor) so re-selecting
// the usually-hidden anchor later lands back on this visible tab.
if (entry.type === 'iframe' && entry.frameId && !entry.subTabs)
frameNavCurrentMember.set(entry.frameId, entry.id)
selectedId.value = entry.id
panelStore.value.open = true
return true
}
const toggleEntry = async (id: string) => {
if (selectedId.value === id)
return switchEntry(null)
return switchEntry(id)

const switchEntry = async (id: string | null = null) => {
  if (id == null) {
    selectedId.value = null
    panelStore.value.open = false
    return true
  }
  // ...
  selectedId.value = entry.id      // L248
  panelStore.value.open = true     // L249

Every rpc. call in that file either reads shared state (renderer manifest, user settings) or receives a push (rpc.client.register, for server→client dock activation) — nothing writes selectedId/panelStore.open back to rpc.sharedState or rpc.broadcast. The one cross-process "activation" concept that exists, devframe:docks:active:

// Cross-iframe dock activation. A dock activation is a discrete user intent
// ("go to Terminals now"), so it fires immediately (no debounce, which could
// coalesce two distinct requests) both as a live broadcast — the host shell
// switches its active dock — and into a shared-state slot, so a dock that
// only mounts *because* of the switch still converges on the request.
const activeDockSharedState = await context.rpc.sharedState.get<DevframeDocksActiveState>(
'devframe:docks:active',
{ initialValue: { activation: null } },
)
docks.events.on('dock:activate', (activation) => {
activeDockSharedState.mutate((state) => {
state.activation = activation
})
context.rpc.broadcast({
method: 'devframe:docks:activate',
args: [activation],
})
})

...is one-way server→client (a plugin telling the panel "go to this dock now"). Nothing routes the reverse direction, so a Node-side plugin has no way to observe that the user opened/closed the panel or switched docks.

Reproduced: opened the panel and selected the "Terminals" dock in the browser — nothing on the Node/Vite process side changes as a result. There's no RPC call, no shared-state key, no event a setup(ctx) plugin could subscribe to for "the user is now looking at a different dock" or "the panel just closed."

2. A reload doesn't just fail to restore UI state — it discards state that IS persisted

DEFAULT_DOCK_PANEL_STORE() has no selectedId/tab/scroll field, and selectedId is a plain in-memory ref, reset to null on every fresh page load:

export function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage {
return {
mode: 'float',
width: 80,
height: 80,
top: 0,
left: 10,
position: 'bottom',
open: false,
inactiveTimeout: 3_000,
}
}

const selectedId = ref<string | null>(null)

Reproduced: with the dock open on "Terminals" (localStorage['devframes-dock-state']{"open":true,...}, confirmed via console right before reload), a plain location.reload() — nothing else touched — comes back fully collapsed, and localStorage['devframes-dock-state'] now reads {"open":false,...}. So even the one field that is persisted (open, via useLocalStorage) gets discarded on reload, on top of selectedId/tab/scroll never having been persisted to begin with. Root cause:

whenever(isMinimized, () => {
setDocksOverflowPanel(null)
})
onMounted(() => {
if (context.panel.store.open && !isRpcTrusted.value)
context.panel.store.open = false
if (isRpcTrusted.value)
bringUp()
recalculateCounter.value++

if (context.panel.store.open && !isRpcTrusted.value)
  context.panel.store.open = false

Every reload starts with isRpcTrusted false until the RPC handshake completes; this watcher fires during that untrusted window and force-writes the persisted open: true back to false — and nothing restores it once trust returns a moment later.

The need

We built a devtool dock on @vitejs/devtools/@devframes/hub that injects config/code overrides into the running app. Because these overrides can't apply via HMR, committing them requires a full page reload.

To avoid reloading out from under someone actively using the panel, we need to gate that reload on the panel actually being inactive — which requires problem 1: a Node-side-observable signal for "the panel is closed" / "the user switched away." Today we can only approximate this by injecting our own client-side visibility hooks into the page, which can't see the panel as a whole (only our own dock) and can't see the user switching to a dock owned by a different plugin.

Once that reload happens, problem 2 makes it disruptive on its own: the developer loses whatever dock/tab/scroll position they had open and has to re-navigate the panel every time. A workflow that's already reload-driven by necessity shouldn't also reset the panel's UI every time.

How should this be solved?

Two prior PRs against vitejs/devtools sketch one possible direction for each half, before the devframe extraction moved this code here:

  • vitejs/devtools#525 — mirrors the panel's local state into rpc.sharedState (so Node-side code can read open/selected dock), with a known caveat: last-mutation-wins across multiple tabs.
  • vitejs/devtools#527 — persists which dock/tab is open and the scroll position across reloads via sessionStorage, and folds a selectedId field into the existing panel store.

These aren't necessarily the right shape for devframe as it is now — flagging them as a starting point for discussion, not a prescription. Questions worth settling before either lands:

  • Should "panel activity" be one shared-state primitive that covers both open/closed and which dock is selected, rather than two separate mechanisms?
  • Does selectedId/tab/scroll persistence want the same shared-state channel as the Node-observability piece, or is client-only sessionStorage enough since nothing server-side needs to read it?
  • How should either behave with multiple tabs open against the same dev server?

Happy to help drive whichever design you prefer, or pair on an implementation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions