mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 16:02:24 +00:00
refactor: gate useLocalStorageValue nested-update effect behind opt-in flag
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user