feat(frontend): add defaultValue + empty-path handling to UserDraft

UserDraft.use() accepts an opts.defaultValue used when no localStorage
entry exists yet. It is not persisted on first read — only an actual
mutation writes through.

Empty paths (new items) bypass localStorage entirely. The entry still
lives in the in-memory Map so multiple components on the same /add page
share state, but save/get/remove/use never read or write localStorage
with an empty path. Once the item is saved and the route navigates to
its new URL, a fresh use() on the non-empty path takes over.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-05-11 14:37:00 +02:00
co-authored by Claude Opus 4.7
parent 6b7c1ea87a
commit b56a189713
2 changed files with 134 additions and 3 deletions
+42 -3
View File
@@ -33,6 +33,15 @@ export type UserDraftOptions = {
workspace?: string
}
export type UserDraftUseOptions<V> = UserDraftOptions & {
/**
* Initial value used when localStorage holds no draft for this
* (workspace, itemKind, path). It is *not* eagerly persisted — the first
* actual mutation is what writes to localStorage.
*/
defaultValue?: V
}
type DraftState<V> = { val: V | undefined }
type DraftEntry = {
@@ -52,6 +61,28 @@ function resolveWorkspace(opts?: UserDraftOptions): string {
return ws
}
/**
* Returns true when this (workspace, itemKind, path) should never touch
* localStorage. An empty path means "new item, not yet on disk"; we keep the
* draft in-memory so multiple components on the same /add page still share
* state, but we don't persist it to avoid colliding new-item drafts.
*/
function isLocalOnly(path: string): boolean {
return path === ''
}
function createInMemoryState<T>(defaultValue: T | undefined): DraftState<T> {
let s = $state<T | undefined>(defaultValue)
return {
get val(): T | undefined {
return s
},
set val(newVal: T | undefined) {
s = newVal
}
}
}
function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
return `${workspace}/${itemKind}/${path}`
}
@@ -72,9 +103,12 @@ export const UserDraft = {
const entry = entries.get(mk)
if (entry) {
// Update the shared reactive state so all observers are notified.
// For non-empty paths the underlying useLocalStorageValue setter
// persists; for empty paths the in-memory state stays in-memory.
entry.state.val = value
return
}
if (isLocalOnly(path)) return
try {
localStorage.setItem(localStorageKey(ws, itemKind, path), JSON.stringify(value))
} catch (e) {
@@ -93,6 +127,7 @@ export const UserDraft = {
if (entry) {
return entry.state.val as V | undefined
}
if (isLocalOnly(path)) return undefined
try {
const raw = localStorage.getItem(localStorageKey(ws, itemKind, path))
if (raw == null || raw === 'undefined') return undefined
@@ -112,6 +147,7 @@ export const UserDraft = {
entry.state.val = undefined
return
}
if (isLocalOnly(path)) return
try {
localStorage.removeItem(localStorageKey(ws, itemKind, path))
} catch (e) {
@@ -122,15 +158,18 @@ export const UserDraft = {
use<V = unknown>(
itemKind: UserDraftItemKind,
path: string,
opts?: UserDraftOptions
opts?: UserDraftUseOptions<V>
): UserDraftHandle<V> {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const lk = localStorageKey(ws, itemKind, path)
const defaultValue = opts?.defaultValue
let entry = entries.get(mk)
if (!entry) {
entry = { count: 1, state: useLocalStorageValue<unknown>(lk, undefined) }
const state: DraftState<unknown> = isLocalOnly(path)
? createInMemoryState<unknown>(defaultValue)
: useLocalStorageValue<unknown>(localStorageKey(ws, itemKind, path), defaultValue)
entry = { count: 1, state }
entries.set(mk, entry)
} else {
entry.count++
+92
View File
@@ -170,6 +170,98 @@ describe('UserDraft.use() — observer sync', () => {
})
})
describe('UserDraft.use() — defaultValue', () => {
it('returns defaultValue when localStorage has no entry', () => {
const handle = UserDraft.use<{ value: string }>('flow', 'u/me/withdefault', {
defaultValue: { value: 'fallback' }
})
expect(handle.draft).toEqual({ value: 'fallback' })
})
it('does not persist the defaultValue on first read', () => {
UserDraft.use<{ value: string }>('flow', 'u/me/lazyDefault', {
defaultValue: { value: 'fallback' }
})
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/lazyDefault')).toBeNull()
})
it('localStorage value wins over defaultValue', () => {
localStorage.setItem(
'userdraft/w/test_ws/flow/u/me/overridden',
JSON.stringify({ value: 'persisted' })
)
const handle = UserDraft.use<{ value: string }>('flow', 'u/me/overridden', {
defaultValue: { value: 'fallback' }
})
expect(handle.draft).toEqual({ value: 'persisted' })
})
it('a write through the setter persists even though defaultValue was set', () => {
const handle = UserDraft.use<{ value: string }>('flow', 'u/me/writeDefault', {
defaultValue: { value: 'fallback' }
})
handle.draft = { value: 'modified' }
expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/writeDefault')).toBe(
JSON.stringify({ value: 'modified' })
)
})
})
describe('UserDraft — empty path (new-item, in-memory only)', () => {
it('use() with empty path uses defaultValue and does not persist', () => {
const handle = UserDraft.use<{ value: number }>('flow', '', {
defaultValue: { value: 0 }
})
handle.draft = { value: 99 }
expect(handle.draft).toEqual({ value: 99 })
// No localStorage key with empty path should ever be written.
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
})
it('two handles with empty path share in-memory state per workspace', () => {
const a = UserDraft.use<{ value: number }>('flow', '')
const b = UserDraft.use<{ value: number }>('flow', '')
a.draft = { value: 1 }
expect(b.draft).toEqual({ value: 1 })
b.draft = { value: 2 }
expect(a.draft).toEqual({ value: 2 })
})
it('save() with empty path is a no-op against localStorage but updates live handles', () => {
const handle = UserDraft.use<{ value: number }>('flow', '')
UserDraft.save('flow', '', { value: 5 })
expect(handle.draft).toEqual({ value: 5 })
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
})
it('get() with empty path returns the in-memory value when a handle is live, else undefined', () => {
expect(UserDraft.get('flow', '')).toBeUndefined()
const handle = UserDraft.use<{ value: number }>('flow', '')
handle.draft = { value: 11 }
expect(UserDraft.get('flow', '')).toEqual({ value: 11 })
})
it('remove() with empty path is a no-op against localStorage but clears live handles', () => {
const handle = UserDraft.use<{ value: number }>('flow', '')
handle.draft = { value: 1 }
UserDraft.remove('flow', '')
expect(handle.draft).toBeUndefined()
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
})
})
describe('UserDraft.use() — reference counting & cleanup', () => {
it('destroys the entry when the last handle is released', () => {
// First handle acquires the entry.