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.
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:
devframe/packages/hub-ui/src/client/state/context.ts
Lines 174 to 256 in 97cbe1d
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 writesselectedId/panelStore.openback torpc.sharedStateorrpc.broadcast. The one cross-process "activation" concept that exists,devframe:docks:active:devframe/packages/hub/src/node/context.ts
Lines 168 to 185 in 97cbe1d
...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 noselectedId/tab/scroll field, andselectedIdis a plain in-memory ref, reset tonullon every fresh page load:devframe/packages/hub-ui/src/client/state/docks.ts
Lines 8 to 19 in 97cbe1d
devframe/packages/hub-ui/src/client/state/context.ts
Line 76 in 97cbe1d
Reproduced: with the dock open on "Terminals" (
localStorage['devframes-dock-state']→{"open":true,...}, confirmed via console right before reload), a plainlocation.reload()— nothing else touched — comes back fully collapsed, andlocalStorage['devframes-dock-state']now reads{"open":false,...}. So even the one field that is persisted (open, viauseLocalStorage) gets discarded on reload, on top ofselectedId/tab/scroll never having been persisted to begin with. Root cause:devframe/packages/hub-ui/src/client/components/dock/Dock.vue
Lines 215 to 225 in 97cbe1d
Every reload starts with
isRpcTrustedfalse until the RPC handshake completes; this watcher fires during that untrusted window and force-writes the persistedopen: trueback tofalse— and nothing restores it once trust returns a moment later.The need
We built a devtool dock on
@vitejs/devtools/@devframes/hubthat 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/devtoolssketch one possible direction for each half, before thedevframeextraction moved this code here:rpc.sharedState(so Node-side code can readopen/selected dock), with a known caveat: last-mutation-wins across multiple tabs.sessionStorage, and folds aselectedIdfield into the existing panel store.These aren't necessarily the right shape for
devframeas it is now — flagging them as a starting point for discussion, not a prescription. Questions worth settling before either lands:selectedId/tab/scroll persistence want the same shared-state channel as the Node-observability piece, or is client-onlysessionStorageenough since nothing server-side needs to read it?Happy to help drive whichever design you prefer, or pair on an implementation.