Skip to content

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

Closed

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.

Activity

  1. antfu commented on Aug 17, 2026

    @antfu
    Contributor

    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

  2. dvcolomban commented on Aug 17, 2026

    @dvcolomban
    ContributorAuthor

    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:

    • An intermediate selection while the user is still configuring things.
    • The end of the overall interaction, when it is safe to finalize the session.

    Individual docks already expose entry:activated and entry:deactivated lifecycle 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:deactivated may mean either:

    • The user closed the panel.
    • The user switched to another dock and is still actively configuring the session.

    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:

    • The panel was opened.
    • The panel was closed.
    • The panel became visible or hidden.

    This would provide a clear boundary around a multi-dock interaction: The panel is closed, so the current configuration session can now be finalized.

    I am thinking we can have the client send back some activation/deactivations rpc events with unique client id

    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 :)

  3. dvcolomban commented on Aug 17, 2026

    @dvcolomban
    ContributorAuthor

    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

  4. antfu commented on Aug 18, 2026

    @antfu
    Contributor

    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 into sessionStorage / localStorage yourself. 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.

  5. dvcolomban commented on Aug 19, 2026

    @dvcolomban
    ContributorAuthor

    Awesome, if you're ok with this direction for the few fixes i can work on:

    • exposing controlled state for tabs so that state can be persisted across reload on implementer side
    • exposing more global hooks to broadcast panel state

    Unless you prefer take this on your side ?

  6. antfu commented on Aug 20, 2026

    @antfu
    Contributor

    That would be great if you want to help on that! Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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