fix(frontend): add UserDraft.discard so "Reset to deployed" doesn't re-persist

The "Reset to deployed" toast action in resource/variable editors
called UserDraft.save with the backend value to repaint the form. That
left a duplicate-of-backend autosave in localStorage which would
silently restore on every subsequent reload, defeating the reset.

New UserDraft.discard(itemKind, path, fallback) clears LS AND resets
any live handle's in-memory state to the fallback, skipping the next
persist so the fallback doesn't round-trip back into storage. Backed
by a new `skipNextWriteOnce()` method on useLocalStorageValue's return.
This commit is contained in:
Diego Imbert
2026-05-15 18:13:01 +02:00
parent 066149c6fb
commit 118b0c4e90
5 changed files with 88 additions and 5 deletions
@@ -216,7 +216,7 @@
if (persisted !== undefined && !deepEqual(persisted, s)) {
notifyRestoredFromLocal(false, true, {
onResetToDeployed: () => {
UserDraft.save('resource', initialPath ?? '', s, { workspace: ws })
UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws })
}
})
}
@@ -137,7 +137,7 @@
if (persisted !== undefined && !deepEqual(persisted, s)) {
notifyRestoredFromLocal(false, true, {
onResetToDeployed: () => {
UserDraft.save('variable', p, s, { workspace: ws })
UserDraft.discard('variable', p, s, { workspace: ws })
}
})
}
+12 -2
View File
@@ -601,7 +601,7 @@ export function useLocalStorageValue<T>(
*/
transformBeforePersist?: (val: T) => T
}
): { val: T } {
): { val: T; skipNextWriteOnce(): void } {
const saveInitialValue = options?.saveInitialValue ?? true
const debounceMs = options?.debounce ?? 0
const transformBeforePersist = options?.transformBeforePersist
@@ -626,7 +626,7 @@ export function useLocalStorageValue<T>(
}
}
if (typeof window === 'undefined') return { val: defaultValue }
if (typeof window === 'undefined') return { val: defaultValue, skipNextWriteOnce: () => {} }
const savedValue = localStorage.getItem(key)
let s = $state<T>(
savedValue != null && savedValue !== 'undefined' ? (deserialize(savedValue) as T) : defaultValue
@@ -694,6 +694,16 @@ export function useLocalStorageValue<T>(
}
}
s = newVal
},
/**
* Arm the persist skip so the next `set val` (or deep-mutation flush)
* updates only the in-memory cell and leaves localStorage untouched.
* Used by `UserDraft.discard` to reset the in-memory state to a
* fallback without re-persisting it — pairs with an explicit LS
* delete to leave the slot empty.
*/
skipNextWriteOnce(): void {
skipNextWrite = true
}
}
}
+44 -1
View File
@@ -89,7 +89,10 @@ function stamp<V>(stored: StoredDraft<V> | undefined): StoredDraft<V> | undefine
return { ...stored, lastWrittenAt: Date.now() }
}
type DraftState<V> = { val: StoredDraft<V> | undefined }
type DraftState<V> = {
val: StoredDraft<V> | undefined
skipNextWriteOnce(): void
}
type DraftEntry = {
count: number
@@ -350,6 +353,46 @@ export const UserDraft = {
}
},
/**
* Discard the local autosave and reset any live handle's `draft` to
* `fallback`. Differs from `remove` in two ways:
*
* 1. The in-memory cell is updated, so consumers reactively reading
* `handle.draft` immediately see the fallback instead of the
* stale local autosave.
* 2. The in-memory reset is marked to skip the next persist, so the
* fallback value does NOT round-trip back into localStorage. The
* LS slot stays empty until the user makes a real edit.
*
* Used by route editors' "Reset to deployed" flow: pass the backend
* baseline as `fallback` so the form repaints against deployed state
* without leaving a duplicate-of-backend LS entry behind.
*/
discard<V>(
itemKind: UserDraftItemKind,
path: string,
fallback: V | undefined,
opts?: UserDraftOptions
): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
// Arm the skip BEFORE the cell write so `useLocalStorageValue`'s
// setter consumes it and suppresses the would-be persist. The
// explicit `localStorage.removeItem` below is what actually
// clears the slot (and also covers the case where no live
// handle exists).
entry.state.skipNextWriteOnce()
entry.state.val = wrap(fallback) as StoredDraft<unknown> | undefined
}
try {
localStorage.removeItem(localStorageKey(ws, itemKind, path))
} catch (e) {
console.error('UserDraft.discard: localStorage remove failed', e)
}
},
use<V = unknown>(
itemKind: UserDraftItemKind,
path: string,
+30
View File
@@ -169,6 +169,36 @@ describe('UserDraft.use() — observer sync', () => {
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/removed')).toBeNull()
})
it('discard() clears LS, resets the handle to the fallback, and does NOT re-persist', () => {
// Seed: handle holds a divergent local autosave.
localStorage.setItem('userdraft/w/test_ws/flow/u/me/discard', wrapped('local-edit'))
const handle = UserDraft.use<string>('flow', 'u/me/discard')
expect(handle.draft).toBe('local-edit')
// Reset to a known backend baseline.
UserDraft.discard('flow', 'u/me/discard', 'backend-baseline')
flushPersist()
// In-memory handle reflects the fallback immediately.
expect(handle.draft).toBe('backend-baseline')
// LS is cleared and stays cleared — the fallback must NOT round-trip
// back into storage (that would make the next reload "restore" the
// fallback as if it were a real autosave).
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/discard')).toBeNull()
})
it('discard() with undefined fallback clears both LS and in-memory state', () => {
localStorage.setItem('userdraft/w/test_ws/flow/u/me/wipe', wrapped('local-edit'))
const handle = UserDraft.use<string>('flow', 'u/me/wipe')
expect(handle.draft).toBe('local-edit')
UserDraft.discard('flow', 'u/me/wipe', undefined)
flushPersist()
expect(handle.draft).toBeUndefined()
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/wipe')).toBeNull()
})
it('the second write through the handle setter persists to localStorage', () => {
const handle = UserDraft.use<string>('flow', 'u/me/setter')