diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 304a9b1e30..479b7fa587 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -33,6 +33,15 @@ export type UserDraftOptions = { workspace?: string } +export type UserDraftUseOptions = 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 = { 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(defaultValue: T | undefined): DraftState { + let s = $state(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( itemKind: UserDraftItemKind, path: string, - opts?: UserDraftOptions + opts?: UserDraftUseOptions ): UserDraftHandle { 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(lk, undefined) } + const state: DraftState = isLocalOnly(path) + ? createInMemoryState(defaultValue) + : useLocalStorageValue(localStorageKey(ws, itemKind, path), defaultValue) + entry = { count: 1, state } entries.set(mk, entry) } else { entry.count++ diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts index fb24655f56..257aa2f3cd 100644 --- a/frontend/src/lib/userDraft.test.ts +++ b/frontend/src/lib/userDraft.test.ts @@ -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.