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..23c7ac71 100644 --- a/packages/core/src/client/inject/runtime.ts +++ b/packages/core/src/client/inject/runtime.ts @@ -1,13 +1,39 @@ /// /// -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 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( + 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(() => { + /** 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) + }) + }) + return state +} + export type InjectMode = 'passive' | 'normal' | 'hidden' // Persistence endpoint the node middleware serves next to `__connection.json`. @@ -60,7 +86,8 @@ async function mountDock(): Promise { ], }) - const state = useLocalStorage( + const state = useLocalStorageSharedState( + rpc, 'vite-devtools-dock-state', { mode: 'float',