Repository navigation
Need: a way to know the dock panel is inactive, and to keep its UI state across a reload #229
Description
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
| 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 // L249Every 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:
devframe/packages/hub/src/node/context.ts
Lines 168 to 185 in 97cbe1d
| // 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:
devframe/packages/hub-ui/src/client/state/docks.ts
Lines 8 to 19 in 97cbe1d
| 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:
devframe/packages/hub-ui/src/client/components/dock/Dock.vue
Lines 215 to 225 in 97cbe1d
| 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 = falseEvery 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 readopen/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 aselectedIdfield 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-onlysessionStorageenough 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.
Activity
antfu commentedon Aug 17, 2026
Thanks for your explanation.
I think for point 2 it's reasonable, while the panels' position/mode should be persistent in the localStorage, for the selected docks, etc, I think they should be persistent in the session storage instead (so two different types of client storage to differentiate). I will implement the feature.
For point 1, I am not sure what you are trying to do on the Node side to have the state. I think the tricky part is that this is a one-server-to-multiple-client pattern, so we couldn't just use a shared state for that. Ideally, I am thinking we can have the client send back some activation/deactivations rpc events with unique client id. But before that, I wanted to know why you want that state on Node side, and what would you expect when tehre are multiple clients. Thanks
dvcolomban commentedon Aug 17, 2026
First, thank you for all the work you have put into Devframe and Devtools, the tooling is amazing, and we really appreciate all the work you guys are putting into it !
Just to clarify a bit our use case:
We have workflows where a user makes several related selections across multiple docks.
Each selection can be persisted in server-side session state as it happens. However, applying the final state may involve heavier work, such as persisting the completed configuration and triggering a full-page reload.
We therefore need to distinguish between:
Individual docks already expose
entry:activatedandentry:deactivatedlifecycle events.These are useful for understanding whether a particular dock is selected, but they do not describe the lifecycle of the panel as a whole.
For example,
entry:deactivatedmay mean either:A dock can observe its own lifecycle, but it cannot reliably know whether the user continued working in another dock owned by a different plugin.
What we are missing global panel lifecycle information, such as:
This would provide a clear boundary around a multi-dock interaction:
The panel is closed, so the current configuration session can now be finalized.Indeed as you mentioned, we would also need to distinguish what session is broadcasting this state, we missed this issue because in our use case, state is bound to a domain, so we want to persist state across instances uniformly, regardless where the user does it's final selection.
Thanks again for the quick response and for taking the time.
We would also be happy to help with or contribute to the implementation if needed :)
dvcolomban commentedon Aug 17, 2026
One additional question about the state persistence added in #246.
The panel now restores whether it was open, the selected dock, and the selected iframe route.
However, in Tabs, it appears we keep an uncontrolled selection only in a local Vue
ref.That preserves the selected tab across ordinary renders while the component remains mounted, but a full-page reload recreates the component and resets it to
defaultValue,value, or the first tab.Unless the tab is explicitly bound to state that is persisted elsewhere, it therefore seems that the selected JSON-render tab—and similarly its scroll position—still does not survive a reload.
Would it be acceptable to also persist state to local storage or session storage ?
Or should this state be owned by the dock instead ?
I'd be happy to contribute a fix if you're ok with that direction too
antfu commentedon Aug 18, 2026
Thanks for the clarification.
For the JSON-render components such as Tabs, I think it would be reasonable to have a controlled version of them, with their state bound to the JSON-render state payload.
For syncing/reporting the JSON-render state back to the Node side, as well as persisting it across reloads, I think these behaviors should be opt-in. Not every JSON-rendered view needs this. Similar to a normal web page, I think it is reasonable for local UI state to be reset after a refresh unless the developer explicitly implements persistence.
I'm more inclined to expose programmatic APIs for accessing and controlling the JSON-render state. Then, depending on the use case, you could bind it to
sharedState, use a custom RPC to report it back to the Node side, or persist it intosessionStorage/localStorageyourself. This keeps JSON-render itself relatively unopinionated about where and how the state should be stored.And also, I agree that having some additional events for the global panel state/lifecycle would be useful as well. We can look into exposing events around the panel being opened/closed or becoming active/inactive, while keeping the multi-client case in mind.
dvcolomban commentedon Aug 19, 2026
Awesome, if you're ok with this direction for the few fixes i can work on:
Unless you prefer take this on your side ?
antfu commentedon Aug 20, 2026
That would be great if you want to help on that! Thanks