From e32b2087403df34892cb24f51f52af1137246154 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Mon, 10 Aug 2026 15:02:30 +0200 Subject: [PATCH 1/2] feat(core): mirror the dock panel's localStorage state into shared state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `panelStore` (`vite-devtools-dock-state`) is purely browser-local today — a node-side plugin has no way to observe it, including whether the panel is even open. Wrap the existing `useLocalStorage` call with a small generic helper, `useLocalStorageSharedState`, that mirrors the whole value into `rpc.sharedState` under the same key: shared-state mutations already round-trip through the server before reappearing locally, so this is the entire mechanism — no new dock-entry field, no client-script gating, no RPC command, no trust handshake. Deliberately mirrors the whole object rather than cherry-picking `open`: it's already one `useLocalStorage`-backed, fully serializable, browser-local singleton (not per-tab) — a second, narrower key for one of its fields would just be a redundant place for that field to live. Known, accepted limitation: shared state is one authoritative value, last-mutation-wins, so a node-side consumer watching `open` across two open tabs sees whichever one mutated most recently. Per-connection keying would need a way to garbage-collect a disconnected tab's entry, which nothing in the public API exposes today — revisit if that changes. --- .../core/src/client/inject/runtime.test.ts | 52 ++++++++++++++++++- packages/core/src/client/inject/runtime.ts | 36 ++++++++++++- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/packages/core/src/client/inject/runtime.test.ts b/packages/core/src/client/inject/runtime.test.ts index d653aaf5..11599e51 100644 --- a/packages/core/src/client/inject/runtime.test.ts +++ b/packages/core/src/client/inject/runtime.test.ts @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { startDevTools } from './runtime' +import { nextTick, ref } from 'vue' +import { startDevTools, useLocalStorageSharedState } from './runtime' const mocks = vi.hoisted(() => ({ getDevToolsRpcClient: vi.fn( (_options: { baseURL: string[] }) => new Promise(() => {}), ), + useLocalStorage: vi.fn(), })) vi.mock('@vitejs/devtools-kit/client', () => ({ @@ -13,7 +15,7 @@ vi.mock('@vitejs/devtools-kit/client', () => ({ })) vi.mock('@vueuse/core', () => ({ - useLocalStorage: vi.fn(), + useLocalStorage: mocks.useLocalStorage, })) vi.mock('../webcomponents/state/context', () => ({ @@ -51,3 +53,49 @@ describe('injected DevTools runtime', () => { ) }) }) + +describe('useLocalStorageSharedState', () => { + afterEach(() => { + mocks.useLocalStorage.mockReset() + }) + + it('returns the exact ref useLocalStorage produces, untouched', () => { + const state = ref({ open: false }) + mocks.useLocalStorage.mockReturnValue(state) + const rpc = { sharedState: { get: vi.fn(() => new Promise(() => {})) } } as any + + expect(useLocalStorageSharedState(rpc, 'k', { open: false })).toBe(state) + }) + + it('creates the shared-state slot under the same key, seeded from the current local value', () => { + const state = ref({ open: true, mode: 'float' }) + mocks.useLocalStorage.mockReturnValue(state) + const get = vi.fn(() => new Promise(() => {})) + const rpc = { sharedState: { get } } as any + + useLocalStorageSharedState(rpc, 'vite-devtools-dock-state', { open: false, mode: 'float' }) + + expect(get).toHaveBeenCalledWith('vite-devtools-dock-state', { initialValue: state.value }) + }) + + it('mirrors every local change into the shared state once the slot resolves', async () => { + const state = ref<{ open: boolean }>({ open: false }) + mocks.useLocalStorage.mockReturnValue(state) + const mutate = vi.fn() + const sharedStatePromise = Promise.resolve({ mutate }) + const rpc = { sharedState: { get: vi.fn(() => sharedStatePromise) } } as any + + useLocalStorageSharedState(rpc, 'k', { open: false }) + await sharedStatePromise // by then, the watchEffect this attaches has already run once, synchronously + + expect(mutate).toHaveBeenCalledTimes(1) + expect(mutate.mock.calls[0]![0]()).toStrictEqual({ open: false }) + + mutate.mockClear() + state.value = { open: true } + await nextTick() // let the watchEffect's reactive dependency flush + + expect(mutate).toHaveBeenCalledTimes(1) + expect(mutate.mock.calls[0]![0]()).toStrictEqual({ open: true }) + }) +}) diff --git a/packages/core/src/client/inject/runtime.ts b/packages/core/src/client/inject/runtime.ts index 0a5d2c52..1e31db45 100644 --- a/packages/core/src/client/inject/runtime.ts +++ b/packages/core/src/client/inject/runtime.ts @@ -1,13 +1,44 @@ /// /// -import type { DockPanelStorage } from '@vitejs/devtools-kit/client' +import type { DevToolsRpcClient, DockPanelStorage } from '@vitejs/devtools-kit/client' +import type { UseStorageOptions } from '@vueuse/core' import { CLIENT_CONTEXT_KEY, getDevToolsRpcClient } from '@vitejs/devtools-kit/client' import { DEVTOOLS_MOUNT_PATH } from '@vitejs/devtools-kit/constants' import { useLocalStorage } from '@vueuse/core' +import { watchEffect } from 'vue' import { DEVTOOLS_HIDE_EVENT, DEVTOOLS_MODE_FILENAME } from '../../constants' import { createDocksContext } from '../webcomponents/state/context' +/** + * `useLocalStorage`, plus mirroring the whole value into shared state under + * the same key — so a node-side plugin (e.g. one gating a deferred reload on + * "the panel closed") can observe it. A plain `useLocalStorage` ref never + * reaches the server on its own; shared state already round-trips a client + * mutation through the server before it reappears locally, so this is the + * whole mirror. Fire-and-forget: nothing here needs the shared handle back, + * and the local ref (returned unchanged) stays the source of truth for every + * existing reader/writer. + */ +export function useLocalStorageSharedState( + rpc: DevToolsRpcClient, + key: string, + initialValue: T, + options?: UseStorageOptions, +) { + const state = useLocalStorage(key, initialValue, options) + void rpc.sharedState.get(key, { initialValue: state.value }).then((shared) => { + watchEffect(() => { + // Reading `state.value` here, synchronously, is what registers it as + // this effect's reactive dependency — capturing it inside `mutate`'s + // own (lazily-invoked) recipe instead would never re-run on a change. + const snapshot = { ...state.value } + shared.mutate(() => snapshot) + }) + }) + return state +} + export type InjectMode = 'passive' | 'normal' | 'hidden' // Persistence endpoint the node middleware serves next to `__connection.json`. @@ -60,7 +91,8 @@ async function mountDock(): Promise { ], }) - const state = useLocalStorage( + const state = useLocalStorageSharedState( + rpc, 'vite-devtools-dock-state', { mode: 'float', From db5b0869d1aa57eb28b92bfcb8ff9eecf68436bf Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Mon, 10 Aug 2026 17:11:14 +0200 Subject: [PATCH 2/2] docs(core): trim verbose comments in useLocalStorageSharedState --- packages/core/src/client/inject/runtime.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/core/src/client/inject/runtime.ts b/packages/core/src/client/inject/runtime.ts index 1e31db45..23c7ac71 100644 --- a/packages/core/src/client/inject/runtime.ts +++ b/packages/core/src/client/inject/runtime.ts @@ -12,12 +12,9 @@ import { createDocksContext } from '../webcomponents/state/context' /** * `useLocalStorage`, plus mirroring the whole value into shared state under - * the same key — so a node-side plugin (e.g. one gating a deferred reload on - * "the panel closed") can observe it. A plain `useLocalStorage` ref never - * reaches the server on its own; shared state already round-trips a client - * mutation through the server before it reappears locally, so this is the - * whole mirror. Fire-and-forget: nothing here needs the shared handle back, - * and the local ref (returned unchanged) stays the source of truth for every + * the same key so a Node-side plugin can observe it too — a plain + * `useLocalStorage` ref never reaches the server on its own. Fire-and-forget: + * the local ref (returned unchanged) stays the source of truth for every * existing reader/writer. */ export function useLocalStorageSharedState( @@ -29,9 +26,7 @@ export function useLocalStorageSharedState( const state = useLocalStorage(key, initialValue, options) void rpc.sharedState.get(key, { initialValue: state.value }).then((shared) => { watchEffect(() => { - // Reading `state.value` here, synchronously, is what registers it as - // this effect's reactive dependency — capturing it inside `mutate`'s - // own (lazily-invoked) recipe instead would never re-run on a change. + /** Read synchronously so this effect tracks `state.value` as its dependency — capturing it inside `mutate`'s lazy recipe wouldn't. */ const snapshot = { ...state.value } shared.mutate(() => snapshot) })