Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions packages/core/src/client/inject/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand All @@ -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', () => ({
Expand Down Expand Up @@ -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 })
})
})
31 changes: 29 additions & 2 deletions packages/core/src/client/inject/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,39 @@
/// <reference types="vite/client" />
/// <reference lib="dom" />

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<T extends object>(
rpc: DevToolsRpcClient,
key: string,
initialValue: T,
options?: UseStorageOptions<T>,
) {
const state = useLocalStorage<T>(key, initialValue, options)
void rpc.sharedState.get<T>(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`.
Expand Down Expand Up @@ -60,7 +86,8 @@ async function mountDock(): Promise<void> {
],
})

const state = useLocalStorage<DockPanelStorage>(
const state = useLocalStorageSharedState<DockPanelStorage>(
rpc,
'vite-devtools-dock-state',
{
mode: 'float',
Expand Down
Loading