diff --git a/frontend/src/lib/svelte5Utils.svelte.ts b/frontend/src/lib/svelte5Utils.svelte.ts index aad16431cb..d688ec1502 100644 --- a/frontend/src/lib/svelte5Utils.svelte.ts +++ b/frontend/src/lib/svelte5Utils.svelte.ts @@ -600,11 +600,21 @@ export function useLocalStorageValue( * across long editing sessions. */ transformBeforePersist?: (val: T) => T + /** + * Register a `$effect` that walks the stored value on every access and + * persists when any nested field mutated. Required for callers that + * mutate the value in place (`s.foo = bar`) rather than reassigning + * via the setter. Setter-only callers (e.g. a flat string slot) should + * leave this `false` — the `$effect` requires a Svelte effect scope, + * so enabling it forces the hook to be called from inside a component. + */ + reactToNestedUpdates?: boolean } ): { val: T; skipNextWriteOnce(): void; setWithoutPersist(newVal: T): void } { const saveInitialValue = options?.saveInitialValue ?? true const debounceMs = options?.debounce ?? 0 const transformBeforePersist = options?.transformBeforePersist + const reactToNestedUpdates = options?.reactToNestedUpdates ?? false const serialize = (val: T) => typ === 'string' || typ === 'number' || typ === 'boolean' ? String(val) : JSON.stringify(val) const deserialize = (val: string): T => { @@ -672,17 +682,19 @@ export function useLocalStorageValue( pendingValue = undefined } - $effect(() => { - readFieldsRecursively(s) - const next = s === undefined ? undefined : serialize(s) - if (next === lastSerialized) return - lastSerialized = next - if (skipNextWrite) { - skipNextWrite = false - return - } - schedulePersist(s) - }) + if (reactToNestedUpdates) { + $effect(() => { + readFieldsRecursively(s) + const next = s === undefined ? undefined : serialize(s) + if (next === lastSerialized) return + lastSerialized = next + if (skipNextWrite) { + skipNextWrite = false + return + } + schedulePersist(s) + }) + } return { get val() { diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 61134e2453..1e86707149 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -765,7 +765,10 @@ function acquireEntry( debounce: 500, // Stamp `lastWrittenAt` at persist time so deep mutations also bump // the GC clock (the setter doesn't re-run for those). - transformBeforePersist: stamp + transformBeforePersist: stamp, + // Form state writes the draft via deep mutations as well as setter + // assignments — both paths must persist. + reactToNestedUpdates: true } as const let stateRef: DraftState | undefined const destroyRoot = $effect.root(() => { diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts index 84c49fefc9..16fac47345 100644 --- a/frontend/src/lib/userDraftDbSyncer.svelte.ts +++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts @@ -67,57 +67,22 @@ type QueuedEntry = { onDraftsRejected?: RejectedDraftsCallback } -// `useLocalStorageValue` needs to run inside a Svelte effect scope to set up -// its persist `$effect`. The module-level handle is lazily initialized on -// first use so it gets a real scope (the calling component's) instead of -// firing at import time where there's no scope. -type LastSyncCell = { - val: string | undefined - skipNextWriteOnce(): void - setWithoutPersist(newVal: string | undefined): void -} -let lastSyncCell: LastSyncCell | undefined - -function readPersistedLastSync(): string | undefined { - if (typeof localStorage === 'undefined') return undefined - try { - const raw = localStorage.getItem(LAST_SYNC_KEY) - return raw ?? undefined - } catch { - return undefined - } -} - -function writeLastSyncDirect(value: string | undefined): void { - if (typeof localStorage === 'undefined') return - try { - if (value === undefined) { - localStorage.removeItem(LAST_SYNC_KEY) - } else { - localStorage.setItem(LAST_SYNC_KEY, value) - } - } catch (e) { - console.error('UserDraftDbSyncer: failed to persist lastSync', e) - } -} +// Setter-only callers can use `useLocalStorageValue` at module scope by +// disabling the nested-mutation `$effect`. The lastSync slot is a flat +// string updated exclusively via `cell.val = ...`, so the effect is +// unnecessary. +const lastSyncCell = useLocalStorageValue(LAST_SYNC_KEY, undefined, 'string', { + saveInitialValue: false +}) function getLastSync(): string | undefined { - if (lastSyncCell) return lastSyncCell.val - return readPersistedLastSync() -} - -function setLastSync(value: string | undefined): void { - if (lastSyncCell) { - lastSyncCell.val = value - } else { - writeLastSyncDirect(value) - } + return lastSyncCell.val } function bumpLastSync(serverTimestamp: string): void { - const previous = getLastSync() + const previous = lastSyncCell.val if (!previous || new Date(serverTimestamp).getTime() > new Date(previous).getTime()) { - setLastSync(serverTimestamp) + lastSyncCell.val = serverTimestamp } } @@ -211,29 +176,16 @@ export async function syncDrafts(opts: SyncOptions): Promise(LAST_SYNC_KEY, undefined, 'string', { - saveInitialValue: false - }) - }, - getLastSync, /** - * Test-only: clear the in-memory queue + lastSync cell so successive + * Test-only: clear the in-memory queue + lastSync slot so successive * tests start with a clean slate. */ __resetForTesting(): void { clearTimers() queue.clear() - lastSyncCell = undefined + lastSyncCell.val = undefined }, /** diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index bcf6d717e2..4d6f6e77d5 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -17,7 +17,6 @@ import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte' import ForkConflictModal from '$lib/components/ForkConflictModal.svelte' import UserDraftConflictModal from '$lib/components/common/confirmationModal/UserDraftConflictModal.svelte' - import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { enterpriseLicense, isPremiumStore, @@ -101,11 +100,6 @@ goto('/user/login') } - // Bind UserDraftDbSyncer's lastSync cell to this layout's Svelte scope. - // Without this it still works via raw localStorage reads, but the value - // isn't reactive across tabs. - UserDraftDbSyncer.mount() - function onQueryChangeUserSettings() { if (userSettings && page.url.hash.startsWith(USER_SETTINGS_HASH)) { const mcpMode = page.url.hash.includes('-mcp')