refactor: share ai workspace cross-window store
This commit is contained in:
@@ -373,6 +373,8 @@ In a mounted-workspace graph, each loaded `ViewFile` carries optional renderer-o
|
||||
|
||||
`useMcpSetupDialogController()` owns MCP setup dialog state, busy actions, and manual config callbacks so `App.tsx` only passes the controller into settings/status surfaces. `useAiWorkspaceWindowBridgeEvents()` owns native AI-workspace event subscriptions and listener cleanup for popped-out workspace windows.
|
||||
|
||||
`createCrossWindowPersistedStore()` is the shared renderer primitive for AI workspace state that must stay synchronized across the main window and popped-out workspace windows. It owns localStorage reads/writes, BroadcastChannel publishing, storage-event synchronization, and external-store subscribers; domain modules such as `aiWorkspaceSessionStore` and `aiWorkspaceWindowSharedContext` provide sanitizers and mutations around that shell.
|
||||
|
||||
The renderer uses `viewOrdering` helpers to convert drag or command-palette move intent into dense order updates before saving each affected view file through `save_view_cmd`. The sidebar treats saved View rows like Type rows for direct customization: double-click starts inline rename, right-click opens edit/rename/icon-color/delete actions, and keyboard users can open that same menu from the focused row while command-palette actions remain responsible for saved View ordering.
|
||||
|
||||
### Neighborhood Mode
|
||||
|
||||
@@ -233,7 +233,7 @@ flowchart TD
|
||||
Note PDF export stays renderer-owned for layout: `useEditorPdfExport` exits diff/raw views, applies a print-only stylesheet to the rendered note root, and opens the platform print/save-to-PDF dialog through the Tauri `print_current_webview` command, with `window.print()` as the browser fallback. The export reuses rendered BlockNote output so math, images, Mermaid diagrams, tldraw blocks, code, tables, and links degrade through their existing DOM rather than a second Markdown-to-PDF renderer, and the source Markdown is never modified.
|
||||
- **Right side panels** (200-500px or hidden): Properties and Table of Contents are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation. The breadcrumb bar toggles Table of Contents and Properties actions. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
|
||||
- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat sidebar, installed-only target picker, permission-mode picker, and dock/pop-out controls. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a compact bounded desktop tool inside the main app; users resize the anchored panel from its left/top edges and resize the chat-list sidebar separately from the transcript area. Pop-out mode opens a dedicated undecorated transparent Tauri webview window labeled `ai-workspace` and boots the lightweight `AiWorkspaceWindowApp` route instead of the full vault shell. The chat header and sidebar header are draggable in native-window mode; closing the pop-out only closes that window, while the dock control emits a dock request back to the main window before closing the pop-out. Chat sessions reuse `AiPanelView` for transcript/composer rendering with the old panel header disabled; target and permission controls live in the composer toolbar so there is one workspace header per active chat.
|
||||
- **AI workspace** (docked panel or native window): `AiWorkspace` owns the multi-chat sidebar, installed-only target picker, permission-mode picker, and dock/pop-out controls. The status-bar AI affordance opens this workspace instead of changing the default target inline. Docked workspace mode renders as a compact bounded desktop tool inside the main app; users resize the anchored panel from its left/top edges and resize the chat-list sidebar separately from the transcript area. Pop-out mode opens a dedicated undecorated transparent Tauri webview window labeled `ai-workspace` and boots the lightweight `AiWorkspaceWindowApp` route instead of the full vault shell. The chat header and sidebar header are draggable in native-window mode; closing the pop-out only closes that window, while the dock control emits a dock request back to the main window before closing the pop-out. Chat sessions reuse `AiPanelView` for transcript/composer rendering with the old panel header disabled; target and permission controls live in the composer toolbar so there is one workspace header per active chat. AI workspace cross-window localStorage, BroadcastChannel, storage-event, and subscriber-set plumbing is owned by `createCrossWindowPersistedStore`; domain stores keep only sanitization, mutation, and native persistence behavior.
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize. `useLayoutPanels` clamps the sidebar, note-list, and inspector widths before applying them, keeps the side panes from flex-shrinking below their protected widths, and persists the last chosen widths in installation-local localStorage under `tolaria:layout-panels`.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Dispatch, SetStateAction } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { AgentStatus, AiAgentMessage } from './aiAgentConversation'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { createCrossWindowPersistedStore, type CrossWindowStoreReadReason } from './crossWindowPersistedStore'
|
||||
|
||||
const STORAGE_KEY = 'tolaria:ai-workspace-sessions:v1'
|
||||
const BROADCAST_CHANNEL = 'tolaria-ai-workspace-sessions'
|
||||
@@ -13,16 +14,19 @@ export interface AiWorkspaceSessionSnapshot {
|
||||
}
|
||||
|
||||
type SessionMap = Record<string, AiWorkspaceSessionSnapshot>
|
||||
type Listener = () => void
|
||||
|
||||
const EMPTY_SESSION: AiWorkspaceSessionSnapshot = {
|
||||
messages: [],
|
||||
status: 'idle',
|
||||
}
|
||||
|
||||
let sessions: SessionMap = readStoredSessions()
|
||||
let broadcastChannel: BroadcastChannel | null = null
|
||||
const listeners = new Set<Listener>()
|
||||
const sessionStore = createCrossWindowPersistedStore<SessionMap>({
|
||||
broadcastChannelName: BROADCAST_CHANNEL,
|
||||
broadcastMessage: { type: 'ai-workspace-sessions-updated' },
|
||||
emptySnapshot: {},
|
||||
sanitizeStoredValue: normalizeStoredSessionsForReason,
|
||||
storageKey: STORAGE_KEY,
|
||||
})
|
||||
let storeVersion = 0
|
||||
let nativeWriteTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let nativeWriteInFlight = false
|
||||
@@ -71,24 +75,11 @@ function normalizeStoredSessions(value: unknown, resetRunningStatus: boolean): S
|
||||
)
|
||||
}
|
||||
|
||||
function readStoredSessions(resetRunningStatus = true): SessionMap {
|
||||
if (typeof localStorage === 'undefined') return {}
|
||||
|
||||
try {
|
||||
return normalizeStoredSessions(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}'), resetRunningStatus)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredSessions(): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions))
|
||||
} catch {
|
||||
// Native persistence is the durable backend; localStorage is only a fast browser cache.
|
||||
}
|
||||
function normalizeStoredSessionsForReason(
|
||||
value: unknown,
|
||||
reason: CrossWindowStoreReadReason,
|
||||
): SessionMap {
|
||||
return normalizeStoredSessions(value, reason !== 'storage')
|
||||
}
|
||||
|
||||
async function readNativeSessions(): Promise<SessionMap> {
|
||||
@@ -131,32 +122,10 @@ function scheduleNativeSessionsWrite(nextSessions: SessionMap): void {
|
||||
}, NATIVE_WRITE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function notifyListeners(): void {
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
function broadcastSessions(): void {
|
||||
if (typeof BroadcastChannel === 'undefined') return
|
||||
|
||||
broadcastChannel ??= new BroadcastChannel(BROADCAST_CHANNEL)
|
||||
broadcastChannel.postMessage({ type: 'ai-workspace-sessions-updated' })
|
||||
}
|
||||
|
||||
function publishSessions(): void {
|
||||
function publishSessions(nextSessions: SessionMap): void {
|
||||
storeVersion += 1
|
||||
writeStoredSessions()
|
||||
scheduleNativeSessionsWrite(sessions)
|
||||
broadcastSessions()
|
||||
notifyListeners()
|
||||
}
|
||||
|
||||
function replaceSessions(nextSessions: SessionMap): void {
|
||||
sessions = nextSessions
|
||||
notifyListeners()
|
||||
}
|
||||
|
||||
function syncFromStorage(): void {
|
||||
replaceSessions(readStoredSessions(false))
|
||||
sessionStore.publishSnapshot(nextSessions)
|
||||
scheduleNativeSessionsWrite(nextSessions)
|
||||
}
|
||||
|
||||
async function syncFromNativeStorage(): Promise<void> {
|
||||
@@ -164,42 +133,35 @@ async function syncFromNativeStorage(): Promise<void> {
|
||||
const nativeSessions = await readNativeSessions()
|
||||
if (storeVersion !== loadVersion) return
|
||||
if (Object.keys(nativeSessions).length === 0) {
|
||||
if (Object.keys(sessions).length > 0) scheduleNativeSessionsWrite(sessions)
|
||||
const currentSessions = sessionStore.getSnapshot()
|
||||
if (Object.keys(currentSessions).length > 0) scheduleNativeSessionsWrite(currentSessions)
|
||||
return
|
||||
}
|
||||
|
||||
replaceSessions(nativeSessions)
|
||||
writeStoredSessions()
|
||||
sessionStore.replaceSnapshot(nativeSessions)
|
||||
sessionStore.writeStoredSnapshot(nativeSessions)
|
||||
}
|
||||
|
||||
function ensureCrossWindowSync(): void {
|
||||
function ensureSessionStoreSync(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === STORAGE_KEY) syncFromStorage()
|
||||
})
|
||||
|
||||
sessionStore.ensureCrossWindowSync()
|
||||
window.addEventListener('pagehide', () => {
|
||||
if (nativeWriteTimer) clearTimeout(nativeWriteTimer)
|
||||
nativeWriteTimer = null
|
||||
void flushNativeSessionsWrite()
|
||||
})
|
||||
|
||||
if (typeof BroadcastChannel === 'undefined') return
|
||||
broadcastChannel ??= new BroadcastChannel(BROADCAST_CHANNEL)
|
||||
broadcastChannel.onmessage = syncFromStorage
|
||||
}
|
||||
|
||||
ensureCrossWindowSync()
|
||||
ensureSessionStoreSync()
|
||||
void syncFromNativeStorage()
|
||||
|
||||
export function aiWorkspaceSessionSnapshot(sessionId: string): AiWorkspaceSessionSnapshot {
|
||||
return sessions[sessionId] ?? EMPTY_SESSION
|
||||
return sessionStore.getSnapshot()[sessionId] ?? EMPTY_SESSION
|
||||
}
|
||||
|
||||
export function subscribeAiWorkspaceSession(_sessionId: string, listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
export function subscribeAiWorkspaceSession(_sessionId: string, listener: () => void): () => void {
|
||||
return sessionStore.subscribe(listener)
|
||||
}
|
||||
|
||||
export function setAiWorkspaceSessionMessages(
|
||||
@@ -208,14 +170,13 @@ export function setAiWorkspaceSessionMessages(
|
||||
): void {
|
||||
const current = aiWorkspaceSessionSnapshot(sessionId)
|
||||
const messages = typeof next === 'function' ? next(current.messages) : next
|
||||
sessions = {
|
||||
...sessions,
|
||||
publishSessions({
|
||||
...sessionStore.getSnapshot(),
|
||||
[sessionId]: {
|
||||
...current,
|
||||
messages,
|
||||
},
|
||||
}
|
||||
publishSessions()
|
||||
})
|
||||
}
|
||||
|
||||
export function setAiWorkspaceSessionStatus(
|
||||
@@ -224,36 +185,33 @@ export function setAiWorkspaceSessionStatus(
|
||||
): void {
|
||||
const current = aiWorkspaceSessionSnapshot(sessionId)
|
||||
const status = typeof next === 'function' ? next(current.status) : next
|
||||
sessions = {
|
||||
...sessions,
|
||||
publishSessions({
|
||||
...sessionStore.getSnapshot(),
|
||||
[sessionId]: {
|
||||
...current,
|
||||
status,
|
||||
},
|
||||
}
|
||||
publishSessions()
|
||||
})
|
||||
}
|
||||
|
||||
export function resetAiWorkspaceSession(sessionId: string): void {
|
||||
sessions = {
|
||||
...sessions,
|
||||
publishSessions({
|
||||
...sessionStore.getSnapshot(),
|
||||
[sessionId]: EMPTY_SESSION,
|
||||
}
|
||||
publishSessions()
|
||||
})
|
||||
}
|
||||
|
||||
export function cloneAiWorkspaceSessionUntilMessage(sourceSessionId: string, targetSessionId: string, messageId: string): void {
|
||||
const source = aiWorkspaceSessionSnapshot(sourceSessionId)
|
||||
const messageIndex = source.messages.findIndex((message) => message.id === messageId)
|
||||
const messages = messageIndex >= 0 ? source.messages.slice(0, messageIndex + 1) : source.messages
|
||||
sessions = {
|
||||
...sessions,
|
||||
publishSessions({
|
||||
...sessionStore.getSnapshot(),
|
||||
[targetSessionId]: {
|
||||
messages: messages.map((message) => ({ ...message, isStreaming: false })),
|
||||
status: 'idle',
|
||||
},
|
||||
}
|
||||
publishSessions()
|
||||
})
|
||||
}
|
||||
|
||||
export function aiWorkspaceSessionDispatchers(sessionId: string): {
|
||||
@@ -267,12 +225,10 @@ export function aiWorkspaceSessionDispatchers(sessionId: string): {
|
||||
}
|
||||
|
||||
export function resetAiWorkspaceSessionStoreForTests(): void {
|
||||
sessions = {}
|
||||
storeVersion = 0
|
||||
pendingNativeSessions = null
|
||||
if (nativeWriteTimer) clearTimeout(nativeWriteTimer)
|
||||
nativeWriteTimer = null
|
||||
nativeWriteInFlight = false
|
||||
writeStoredSessions()
|
||||
notifyListeners()
|
||||
sessionStore.publishSnapshot({})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { AiWorkspaceWindowContext } from '../utils/openAiWorkspaceWindow'
|
||||
import type { NoteListItem } from '../utils/ai-context'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { createCrossWindowPersistedStore } from './crossWindowPersistedStore'
|
||||
|
||||
const STORAGE_KEY = 'tolaria:ai-workspace-window-context:v1'
|
||||
const BROADCAST_CHANNEL = 'tolaria-ai-workspace-window-context'
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
export interface AiWorkspaceWindowSharedContext extends AiWorkspaceWindowContext {
|
||||
activeEntry?: VaultEntry | null
|
||||
activeNoteContent?: string | null
|
||||
@@ -18,9 +17,13 @@ export interface AiWorkspaceWindowSharedContext extends AiWorkspaceWindowContext
|
||||
|
||||
const EMPTY_CONTEXT: AiWorkspaceWindowSharedContext = {}
|
||||
|
||||
let context = readStoredContext()
|
||||
let broadcastChannel: BroadcastChannel | null = null
|
||||
const listeners = new Set<Listener>()
|
||||
const contextStore = createCrossWindowPersistedStore<AiWorkspaceWindowSharedContext>({
|
||||
broadcastChannelName: BROADCAST_CHANNEL,
|
||||
broadcastMessage: { type: 'ai-workspace-window-context-updated' },
|
||||
emptySnapshot: EMPTY_CONTEXT,
|
||||
sanitizeStoredValue: (value) => sanitizeContext(value),
|
||||
storageKey: STORAGE_KEY,
|
||||
})
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
@@ -80,54 +83,6 @@ function sanitizeContext(value: unknown): AiWorkspaceWindowSharedContext {
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredContext(): AiWorkspaceWindowSharedContext {
|
||||
if (typeof localStorage === 'undefined') return EMPTY_CONTEXT
|
||||
|
||||
try {
|
||||
return sanitizeContext(JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}'))
|
||||
} catch {
|
||||
return EMPTY_CONTEXT
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredContext(): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(context))
|
||||
} catch {
|
||||
// The AI window can still open without shared editor context.
|
||||
}
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
function broadcast(): void {
|
||||
if (typeof BroadcastChannel === 'undefined') return
|
||||
|
||||
broadcastChannel ??= new BroadcastChannel(BROADCAST_CHANNEL)
|
||||
broadcastChannel.postMessage({ type: 'ai-workspace-window-context-updated' })
|
||||
}
|
||||
|
||||
function syncFromStorage(): void {
|
||||
context = readStoredContext()
|
||||
notify()
|
||||
}
|
||||
|
||||
function ensureCrossWindowSync(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === STORAGE_KEY) syncFromStorage()
|
||||
})
|
||||
|
||||
if (typeof BroadcastChannel === 'undefined') return
|
||||
broadcastChannel ??= new BroadcastChannel(BROADCAST_CHANNEL)
|
||||
broadcastChannel.onmessage = syncFromStorage
|
||||
}
|
||||
|
||||
function cloneEntryForWindowContext(entry: VaultEntry): VaultEntry {
|
||||
return {
|
||||
...entry,
|
||||
@@ -158,19 +113,15 @@ function cloneContextForWindow(nextContext: AiWorkspaceWindowSharedContext): AiW
|
||||
}
|
||||
|
||||
export function aiWorkspaceWindowSharedContextSnapshot(): AiWorkspaceWindowSharedContext {
|
||||
return context
|
||||
return contextStore.getSnapshot()
|
||||
}
|
||||
|
||||
export function subscribeAiWorkspaceWindowSharedContext(listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
export function subscribeAiWorkspaceWindowSharedContext(listener: () => void): () => void {
|
||||
return contextStore.subscribe(listener)
|
||||
}
|
||||
|
||||
export function publishAiWorkspaceWindowSharedContext(nextContext: AiWorkspaceWindowSharedContext): void {
|
||||
context = cloneContextForWindow(nextContext)
|
||||
writeStoredContext()
|
||||
broadcast()
|
||||
notify()
|
||||
contextStore.publishSnapshot(cloneContextForWindow(nextContext))
|
||||
}
|
||||
|
||||
ensureCrossWindowSync()
|
||||
contextStore.ensureCrossWindowSync()
|
||||
|
||||
115
src/lib/crossWindowPersistedStore.test.ts
Normal file
115
src/lib/crossWindowPersistedStore.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createCrossWindowPersistedStore } from './crossWindowPersistedStore'
|
||||
|
||||
const STORAGE_KEY = 'tolaria:test-cross-window-store'
|
||||
const CHANNEL_NAME = 'tolaria-test-cross-window-store'
|
||||
|
||||
interface TestSnapshot {
|
||||
value: string
|
||||
}
|
||||
|
||||
class LocalStorageMock implements Storage {
|
||||
private readonly store = new Map<string, string>()
|
||||
|
||||
get length() {
|
||||
return this.store.size
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.store.clear()
|
||||
}
|
||||
|
||||
getItem(key: string) {
|
||||
return this.store.get(key) ?? null
|
||||
}
|
||||
|
||||
key(index: number) {
|
||||
return Array.from(this.store.keys())[index] ?? null
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
this.store.delete(key)
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.store.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
class BroadcastChannelMock {
|
||||
static channels: BroadcastChannelMock[] = []
|
||||
|
||||
onmessage: ((event: MessageEvent<unknown>) => void) | null = null
|
||||
|
||||
constructor(readonly name: string) {
|
||||
BroadcastChannelMock.channels.push(this)
|
||||
}
|
||||
|
||||
postMessage(message: unknown) {
|
||||
for (const channel of BroadcastChannelMock.channels) {
|
||||
if (channel === this || channel.name !== this.name) continue
|
||||
channel.onmessage?.(new MessageEvent('message', { data: message }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createTestStore() {
|
||||
return createCrossWindowPersistedStore<TestSnapshot>({
|
||||
broadcastChannelName: CHANNEL_NAME,
|
||||
broadcastMessage: { type: 'test-updated' },
|
||||
emptySnapshot: { value: '' },
|
||||
sanitizeStoredValue: (value) => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return { value: '' }
|
||||
const candidate = value as Partial<TestSnapshot>
|
||||
return typeof candidate.value === 'string' ? { value: candidate.value } : { value: '' }
|
||||
},
|
||||
storageKey: STORAGE_KEY,
|
||||
})
|
||||
}
|
||||
|
||||
describe('createCrossWindowPersistedStore', () => {
|
||||
beforeEach(() => {
|
||||
BroadcastChannelMock.channels = []
|
||||
vi.stubGlobal('localStorage', new LocalStorageMock())
|
||||
vi.stubGlobal('BroadcastChannel', BroadcastChannelMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('hydrates and sanitizes persisted snapshots', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ value: 'persisted', ignored: true }))
|
||||
|
||||
const store = createTestStore()
|
||||
|
||||
expect(store.getSnapshot()).toEqual({ value: 'persisted' })
|
||||
})
|
||||
|
||||
it('syncs from storage events and notifies subscribers', () => {
|
||||
const store = createTestStore()
|
||||
const listener = vi.fn()
|
||||
store.subscribe(listener)
|
||||
store.ensureCrossWindowSync()
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ value: 'external' }))
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: STORAGE_KEY }))
|
||||
|
||||
expect(store.getSnapshot()).toEqual({ value: 'external' })
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('broadcasts local publishes to sibling stores through localStorage', () => {
|
||||
const firstStore = createTestStore()
|
||||
const secondStore = createTestStore()
|
||||
const secondListener = vi.fn()
|
||||
firstStore.ensureCrossWindowSync()
|
||||
secondStore.ensureCrossWindowSync()
|
||||
secondStore.subscribe(secondListener)
|
||||
|
||||
firstStore.publishSnapshot({ value: 'published' })
|
||||
|
||||
expect(secondStore.getSnapshot()).toEqual({ value: 'published' })
|
||||
expect(secondListener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
91
src/lib/crossWindowPersistedStore.ts
Normal file
91
src/lib/crossWindowPersistedStore.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export type CrossWindowStoreReadReason = 'initial' | 'storage'
|
||||
type Listener = () => void
|
||||
|
||||
interface CrossWindowPersistedStoreOptions<TSnapshot> {
|
||||
broadcastChannelName: string
|
||||
broadcastMessage: unknown
|
||||
emptySnapshot: TSnapshot
|
||||
sanitizeStoredValue: (value: unknown, reason: CrossWindowStoreReadReason) => TSnapshot
|
||||
storageKey: string
|
||||
}
|
||||
|
||||
export function createCrossWindowPersistedStore<TSnapshot>({
|
||||
broadcastChannelName,
|
||||
broadcastMessage,
|
||||
emptySnapshot,
|
||||
sanitizeStoredValue,
|
||||
storageKey,
|
||||
}: CrossWindowPersistedStoreOptions<TSnapshot>) {
|
||||
let snapshot = readStoredSnapshot('initial')
|
||||
let broadcastChannel: BroadcastChannel | null = null
|
||||
const listeners = new Set<Listener>()
|
||||
function readStoredSnapshot(reason: CrossWindowStoreReadReason = 'initial'): TSnapshot {
|
||||
if (typeof localStorage === 'undefined') return emptySnapshot
|
||||
|
||||
try {
|
||||
return sanitizeStoredValue(JSON.parse(localStorage.getItem(storageKey) ?? '{}'), reason)
|
||||
} catch {
|
||||
return emptySnapshot
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredSnapshot(nextSnapshot = snapshot): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(nextSnapshot))
|
||||
} catch {
|
||||
// Cross-window localStorage is a best-effort cache; callers may have a durable backend.
|
||||
}
|
||||
}
|
||||
|
||||
function notifyListeners(): void {
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
function broadcastSnapshot(): void {
|
||||
if (typeof BroadcastChannel === 'undefined') return
|
||||
|
||||
broadcastChannel ??= new BroadcastChannel(broadcastChannelName)
|
||||
broadcastChannel.postMessage(broadcastMessage)
|
||||
}
|
||||
|
||||
function replaceSnapshot(nextSnapshot: TSnapshot): void {
|
||||
snapshot = nextSnapshot
|
||||
notifyListeners()
|
||||
}
|
||||
|
||||
function publishSnapshot(nextSnapshot: TSnapshot): void {
|
||||
snapshot = nextSnapshot
|
||||
writeStoredSnapshot()
|
||||
broadcastSnapshot()
|
||||
notifyListeners()
|
||||
}
|
||||
|
||||
function syncFromStorage(): void {
|
||||
replaceSnapshot(readStoredSnapshot('storage'))
|
||||
}
|
||||
|
||||
function ensureCrossWindowSync(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === storageKey) syncFromStorage()
|
||||
})
|
||||
|
||||
if (typeof BroadcastChannel === 'undefined') return
|
||||
broadcastChannel ??= new BroadcastChannel(broadcastChannelName)
|
||||
broadcastChannel.onmessage = syncFromStorage
|
||||
}
|
||||
|
||||
return {
|
||||
ensureCrossWindowSync,
|
||||
getSnapshot: () => snapshot,
|
||||
publishSnapshot,
|
||||
replaceSnapshot,
|
||||
subscribe(listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
writeStoredSnapshot,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user