refactor: gate useLocalStorageValue nested-update effect behind opt-in flag

This commit is contained in:
Diego Imbert
2026-05-28 11:15:24 +02:00
parent 5bb0837baf
commit dcf60a933c
4 changed files with 39 additions and 78 deletions
+23 -11
View File
@@ -600,11 +600,21 @@ export function useLocalStorageValue<T>(
* 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<T>(
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() {
+4 -1
View File
@@ -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<unknown>
transformBeforePersist: stamp<unknown>,
// Form state writes the draft via deep mutations as well as setter
// assignments — both paths must persist.
reactToNestedUpdates: true
} as const
let stateRef: DraftState<unknown> | undefined
const destroyRoot = $effect.root(() => {
+12 -60
View File
@@ -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<string | undefined>(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<V = unknown>(opts: SyncOptions<V>): Promise<voi
}
export const UserDraftDbSyncer = {
/**
* Call inside a component (e.g. the root layout) to bind the persisted
* `lastSync` cell to a Svelte effect scope. Without this the syncer
* still works (it falls back to raw localStorage reads/writes) but the
* value won't react across multiple tabs / SSR replays.
*/
mount(): void {
if (lastSyncCell) return
lastSyncCell = useLocalStorageValue<string | undefined>(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
},
/**
@@ -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')