From 1857a4b254ed9c87cbb3274d8e4140ded434495e Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Mon, 10 Aug 2026 12:44:15 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(core):=20session-scoped=20dock=20UI=20?= =?UTF-8?q?state=20=E2=80=94=20restore=20open/selected=20dock,=20tab,=20an?= =?UTF-8?q?d=20scroll=20across=20reloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the dock panel's persisted state in two: config (geometry/position/ mode/inactiveTimeout) stays in localStorage (vite-devtools-dock-state, cross-tab), while *state* (open, selected dock, active tab, scroll position) moves to sessionStorage (vite-devtools-dock-session, per-tab) — a full page reload no longer leaves the panel open-but-empty or resets scroll/tab. - createDocksContext (context.ts) now backs open + selectedId with a single useSessionStorage store, merged with the geometry-only panelStore into the same DockPanelStorage shape every dock component already reads/writes through context.panel.store — only open's backing store changes. - A restored selectedId is validated once at boot (once dock entries have actually loaded) against being a real selectable leaf — not a group, a subTabs anchor, or a stale id — clearing it without routing through switchEntry (which would force the panel back open). Past that one-time check, switchEntry may legitimately land on a group/anchor id itself (e.g. a subTabs anchor with no live member yet) — this doesn't re-litigate that. - DockEmbedded now mirrors DockStandalone's own auto-select-first-entry boot guard, gated on the panel actually being open. - ViewJsonRender provides its dock entry id for descendants to inject, and restores/persists its own scroll position per dock, debounced. - New useUncontrolledValue composable: session-persisted fallback for a json-render element's own uncontrolled value, keyed by the injected dock id plus a signature of the element's static shape (works around FlatElement.key not being forwarded to registry components). Tabs now uses it instead of its hand-rolled ref; Select/TextInput/Switch — until now inert while unbound — get it too. Two behavior changes ship with this, both intentional: - Opening the docks in one tab no longer auto-opens them in another tab of the same page (open moved from localStorage to sessionStorage). - The very first load after this ships, the panel starts closed even if a user's old vite-devtools-dock-state said open: true — that key's open is simply never read again. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/core/src/client/inject/runtime.ts | 10 +- .../components/dock/DockEmbedded.vue | 17 ++- .../components/views/ViewJsonRender.vue | 24 +++- .../json-render/components/Select.ts | 16 ++- .../json-render/components/Switch.ts | 13 +- .../json-render/components/Tabs.ts | 5 +- .../json-render/components/TextInput.ts | 13 +- .../json-render/composables/dock-entry-id.ts | 10 ++ .../composables/useUncontrolledValue.ts | 45 ++++++ .../state/__tests__/dock-session.test.ts | 130 ++++++++++++++++++ .../src/client/webcomponents/state/context.ts | 102 ++++++++++++-- .../src/client/webcomponents/state/docks.ts | 18 +++ .../client/webcomponents.snapshot.d.ts | 5 + .../devtools/client/webcomponents.snapshot.js | 1 + 14 files changed, 386 insertions(+), 23 deletions(-) create mode 100644 packages/core/src/client/webcomponents/json-render/composables/dock-entry-id.ts create mode 100644 packages/core/src/client/webcomponents/json-render/composables/useUncontrolledValue.ts create mode 100644 packages/core/src/client/webcomponents/state/__tests__/dock-session.test.ts diff --git a/packages/core/src/client/inject/runtime.ts b/packages/core/src/client/inject/runtime.ts index 0a5d2c520..954e7d181 100644 --- a/packages/core/src/client/inject/runtime.ts +++ b/packages/core/src/client/inject/runtime.ts @@ -60,7 +60,14 @@ async function mountDock(): Promise { ], }) - const state = useLocalStorage( + // `open` is intentionally not part of this localStorage-persisted geometry + // — it's session-scoped (sessionStorage, per-tab) inside `createDocksContext` + // instead, so opening the docks in one tab no longer auto-opens them in + // another. Two behavior changes ship with this: (a) that cross-tab + // auto-open, and (b) after this change deploys, the panel starts closed on + // the very first load even for a user whose old `vite-devtools-dock-state` + // said `open: true` — the old key's `open` is simply never read again. + const state = useLocalStorage>( 'vite-devtools-dock-state', { mode: 'float', @@ -69,7 +76,6 @@ async function mountDock(): Promise { top: 0, left: 0, position: 'left', - open: false, inactiveTimeout: 3_000, }, { mergeDefaults: true }, diff --git a/packages/core/src/client/webcomponents/components/dock/DockEmbedded.vue b/packages/core/src/client/webcomponents/components/dock/DockEmbedded.vue index 142773512..f819760ce 100644 --- a/packages/core/src/client/webcomponents/components/dock/DockEmbedded.vue +++ b/packages/core/src/client/webcomponents/components/dock/DockEmbedded.vue @@ -2,7 +2,7 @@ import type { DocksContext } from '@vitejs/devtools-kit/client' import type { DockLayout } from './dock-layout' import { useEventListener } from '@vueuse/core' -import { onUnmounted } from 'vue' +import { onUnmounted, watch } from 'vue' import { sharedStateToRef } from '../../state/docks' import { closeDockPopup, useIsDockPopupOpen } from '../../state/popup' import { useIsRpcTrusted } from '../../utils/useIsRpcTrusted' @@ -21,12 +21,27 @@ const props = defineProps<{ layout?: Partial }>() +const context = props.context + const isDockPopupOpen = useIsDockPopupOpen() const settings = sharedStateToRef(props.context.docks.settings) // Force float mode when unauthorized, regardless of store setting const isRpcTrusted = useIsRpcTrusted(props.context) +// If the panel is open (restored from `vite-devtools-dock-session`, or +// opened otherwise) but nothing valid is selected — e.g. the persisted +// `selectedId` didn't resolve to a real entry — fall back to the first +// available one, mirroring `DockStandalone`'s own boot guard. +watch( + () => context.docks.entries, + () => { + if (context.panel.store.open) + context.docks.selectedId ||= context.docks.entries[0]?.id ?? null + }, + { immediate: true }, +) + // Close the dock when clicking outside of it useEventListener(window, 'mousedown', (e: MouseEvent) => { if (!settings.value.closeOnOutsideClick) diff --git a/packages/core/src/client/webcomponents/components/views/ViewJsonRender.vue b/packages/core/src/client/webcomponents/components/views/ViewJsonRender.vue index 723e93d57..e7fb78930 100644 --- a/packages/core/src/client/webcomponents/components/views/ViewJsonRender.vue +++ b/packages/core/src/client/webcomponents/components/views/ViewJsonRender.vue @@ -3,7 +3,9 @@ import type { Spec } from '@json-render/core' import type { DevToolsViewJsonRender } from '@vitejs/devtools-kit' import type { DocksContext } from '@vitejs/devtools-kit/client' import { JSONUIProvider, Renderer } from '@json-render/vue' -import { computed, markRaw, onMounted, ref, shallowRef, watch } from 'vue' +import { useDebounceFn, useSessionStorage } from '@vueuse/core' +import { computed, markRaw, onMounted, provide, ref, shallowRef, useTemplateRef, watch } from 'vue' +import { DOCK_ENTRY_ID_KEY } from '../../json-render/composables/dock-entry-id' import { devtoolsRegistry, UnsupportedComponent } from '../../json-render/registry' const props = defineProps<{ @@ -11,10 +13,28 @@ const props = defineProps<{ entry: DevToolsViewJsonRender }>() +// Descendants (e.g. `useUncontrolledValue`) `inject()` this to scope +// session-persisted state to "this dock" — the entry's own id, stable for +// this component instance's lifetime (`ViewEntry` re-keys on entry change). +provide(DOCK_ENTRY_ID_KEY, props.entry.id) + const spec = shallowRef(null) const isLoading = ref(true) const error = ref(null) +// Restores/persists the scroll position of this dock's own view, per tab, +// across a reload — keyed by the dock entry id so switching docks doesn't +// bleed one dock's scroll into another's. +const scrollContainer = useTemplateRef('scrollContainer') +const scrollTop = useSessionStorage(`vite-devtools-scroll:${props.entry.id}`, 0) +onMounted(() => { + if (scrollContainer.value) + scrollContainer.value.scrollTop = scrollTop.value +}) +const persistScrollTop = useDebounceFn(() => { + scrollTop.value = scrollContainer.value?.scrollTop ?? 0 +}, 200) + // Resolve spec from entry.ui._stateKey async function loadSpec() { try { @@ -87,7 +107,7 @@ watch(() => props.entry.ui?._stateKey, loadSpec)