From 7512783fdef900c1d5de40f6b7519ec8719abbff Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 4 Sep 2026 01:25:21 +0200 Subject: [PATCH] refactor(frontend): let the draft helper own draft-only items too Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wy24UHSVRZdDaPWiBay9MG --- .../src/lib/components/ResourceEditor.svelte | 85 ++++++++---------- .../src/lib/components/VariableEditor.svelte | 88 ++++++++----------- .../useNewItemDraftSync.svelte.dom.test.ts | 78 ++++++++++++++-- .../components/useNewItemDraftSync.svelte.ts | 82 ++++++++++++----- 4 files changed, 203 insertions(+), 130 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 9c0d762a05..2a7440a6b4 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -205,10 +205,6 @@ ) let pathError = $state('') - // Set before `save` awaits anything: the drawer closes without awaiting the - // write, and the close-time draft move would otherwise leave a draft on the - // resource being created. - let saving = $state(false) async function resourcePathIsFree(ws: string, p: string): Promise { try { @@ -219,19 +215,26 @@ } } - // A new resource's handle is keyed on the empty `initialPath`, so it is - // detached and never POSTs; the form is mirrored under the typed path - // instead. Inert in edit mode. + // Nothing deployed at this path: the item IS its draft. Its list row is keyed + // by the path inside that draft, so the key has to follow the form's path — + // which is what `useNewItemDraftSync` does and the `useMany` handle can't + // (it is pinned to the path this editor opened). + const draftOnly = $derived(!!selected && existedInitially[selected] === false) + // What the form was opened with: the empty seed for a new resource, the + // draft itself for a draft-only one. Divergence from it is the user's edit. + let openedWith: Record = $state({}) + const newDraftSync = useNewItemDraftSync({ itemKind: 'resource', - enabled: () => !initialPath, + enabled: () => draftOnly, workspace: () => selected, path: () => current?.path ?? '', pathError: () => pathError, contentTouched: () => !!current && !!selected && - !draftValuesEqual({ ...current, path: '' }, { ...initialStates[selected], path: '' }), + !!openedWith[selected] && + !draftValuesEqual({ ...current, path: '' }, { ...openedWith[selected], path: '' }), value: () => (current ? ($state.snapshot(current) as ResourceState) : undefined), pathIsFree: (p) => (selected ? resourcePathIsFree(selected, p) : Promise.resolve(false)) }) @@ -253,6 +256,7 @@ } ensureHandle(selected, s) initialStates[selected] = structuredClone(s) + openedWith[selected] = structuredClone(s) existedInitially[selected] = false }) }) @@ -302,7 +306,15 @@ const s: ResourceState = savedDraftState ?? deployedState ensureHandle(ws, s) initialStates[ws] = structuredClone(deployedState) + openedWith[ws] = structuredClone(s) existedInitially[ws] = !noDeployed + if (noDeployed) { + // The helper owns this draft's key from here (see `draftOnly`); + // leaving the handle syncing too would write the same content back + // under the path this editor opened, stranding the row on a rename. + UserDraft.stopSync('resource', initialPath, { workspace: ws }) + newDraftSync.adopt(ws, initialPath, structuredClone(s)) + } perWsUser[ws] = user // Keep resource_type in sync for the base workspace (controls the schema) if (ws === effectiveWorkspace) { @@ -343,36 +355,10 @@ export function localDraftCurrent(): ResourceState | undefined { return current } - /** A draft-only item's list row is keyed by the path INSIDE the draft, while - * the autosave handle stays keyed on the path the editor opened. Renaming - * one would leave the row pointing at a key that holds no draft — it would - * 404 on reopen and its delete would miss — so move the draft to the path - * the form now carries. Reads its state synchronously: the caller runs this - * as the drawer closes, and awaiting first would race the editor's teardown. */ - async function moveRenamedDraftOnly(): Promise { - const ws = selected - if (!ws || !initialPath || existedInitially[ws] !== false || saving) return - const s = states[ws]?.draft - if (!s || !s.path || s.path === initialPath || pathError !== '') return - const value = $state.snapshot(s) as ResourceState - const target = s.path - // The path may have been typed too recently for `Path`'s debounced check - // to have run: moving onto an occupied path would hand this draft to the - // item living there. - if (!(await resourcePathIsFree(ws, target))) return - UserDraft.save('resource', target, value, { workspace: ws }) - UserDraft.remove('resource', initialPath, { workspace: ws }) - await Promise.all([ - UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'resource', path: target }), - UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'resource', path: initialPath }) - ]) - } - /** Settle every pending draft write. The drawer awaits this before its * `onClose`, whose list refetch would otherwise outrun the debounced POST. */ export async function flushDraft(): Promise { - // Both started before the first await so they read live editor state. - await Promise.all([newDraftSync.flush(), moveRenamedDraftOnly()]) + await newDraftSync.flush() } /** Returns true when the item was draft-only: discarding deleted it @@ -385,10 +371,11 @@ }) return false } - // Draft-only: no baseline to fall back to. Blank the cell so the form - // unmounts — mounted on the empty state it re-fills the path from - // `initialPath`, and that autosave would displace the delete. Flushed so - // the list refetch on drawer close no longer finds the row. + // Draft-only: the item is the draft, so discarding deletes it. `finish` + // drops it wherever the helper keyed it and settles that before the + // caller's list refetch; `remove` then blanks the cell so the form + // unmounts, and clears the key this editor opened if the two differ. + await newDraftSync.finish() UserDraft.remove('resource', initialPath ?? '', { workspace: selected }) await UserDraftDbSyncer.flush({ workspace: selected, @@ -426,14 +413,12 @@ export async function save(): Promise { const dirty = dirtyWorkspaces - // Synchronous, before the first await: the drawer closes right after - // calling this, and the close-time draft move must see it. - saving = true try { for (const ws of dirty) { const s = states[ws].draft! const ini = initialStates[ws] - if (existedInitially[ws]) { + const wasDeployed = existedInitially[ws] + if (wasDeployed) { await ResourceService.updateResource({ workspace: ws, path: ini.path, @@ -463,19 +448,19 @@ }) } initialStates[ws] = $state.snapshot(s) as ResourceState + openedWith[ws] = $state.snapshot(s) as ResourceState existedInitially[ws] = true - if (initialPath) { + // Both awaited: the caller refetches the list right after, and a + // debounced delete would bring the just-deployed item back as a draft. + if (wasDeployed) { // Reset the handle to the new deployed baseline via `discard`, not // `remove`. See VariableEditor for the full rationale. UserDraft.discard('resource', initialPath, s, { workspace: ws }) - // Flushed: the caller refetches the list right after, and the - // discard's delete rides the same debounce — the just-deployed item - // would still come back rendered as a draft. await UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'resource', path: initialPath }) } else { - // Awaited: the caller refetches the list right after, and a debounced - // delete would leave the just-created item still flagged as a draft. + // The helper held this draft (new or draft-only), wherever it keyed it. await newDraftSync.finish() + if (initialPath) UserDraft.restartSync('resource', initialPath, { workspace: ws }) } // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 5844a01ab7..d17057d46f 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -58,9 +58,9 @@ let perWsUser: Record = $state({}) let selected: string | undefined = $state(undefined) let pathError = $state('') - // Set before `save` awaits anything, so a close-time draft move can't leave - // a draft on the variable being created. - let saving = $state(false) + // What the form was opened with: the empty seed for a new variable, the + // draft itself for a draft-only one. Divergence from it is the user's edit. + let openedWith: Record = $state({}) async function variablePathIsFree(ws: string, p: string): Promise { try { @@ -130,19 +130,23 @@ Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws])) ) - // A new variable's handle is keyed on the empty `editPath`, so it is - // detached and never POSTs; the form is mirrored under the typed path - // instead. Inert in edit mode. + // Nothing deployed at this path: the item IS its draft. Its list row is keyed + // by the path inside that draft, so the key has to follow the form's path — + // which is what `useNewItemDraftSync` does and the `useMany` handle can't + // (it is pinned to the path this editor opened). + const draftOnly = $derived(!!selected && existedInitially[selected] === false) + const newDraftSync = useNewItemDraftSync({ itemKind: 'variable', - enabled: () => !edit, + enabled: () => draftOnly, workspace: () => selected, path: () => current?.path ?? '', pathError: () => pathError, contentTouched: () => !!current && !!selected && - !draftValuesEqual({ ...current, path: '' }, { ...initialStates[selected], path: '' }), + !!openedWith[selected] && + !draftValuesEqual({ ...current, path: '' }, { ...openedWith[selected], path: '' }), value: () => (current ? ($state.snapshot(current) as VariableState) : undefined), pathIsFree: (p) => (selected ? variablePathIsFree(selected, p) : Promise.resolve(false)) }) @@ -234,44 +238,25 @@ const s: VariableState = savedDraftState ?? deployedState ensureHandle(ws, s) initialStates[ws] = structuredClone(deployedState) + openedWith[ws] = structuredClone(s) existedInitially[ws] = !noDeployed + if (noDeployed) { + // The helper owns this draft's key from here (see `draftOnly`); + // leaving the handle syncing too would write the same content back + // under the path this editor opened, stranding the row on a rename. + UserDraft.stopSync('variable', p, { workspace: ws }) + newDraftSync.adopt(ws, p, structuredClone(s)) + } extraPerms[ws] = v.extra_perms ?? {} perWsUser[ws] = user }) }) }) - /** A draft-only item's list row is keyed by the path INSIDE the draft, while - * the autosave handle stays keyed on the path the editor opened. Renaming - * one would leave the row pointing at a key that holds no draft — it would - * 404 on reopen and its delete would miss — so move the draft to the path - * the form now carries. Reads its state synchronously: the caller runs this - * as the drawer closes, and awaiting first would race the form's teardown. */ - async function moveRenamedDraftOnly(): Promise { - const ws = selected - if (!ws || !editPath || existedInitially[ws] !== false || saving) return - const s = states[ws]?.draft - if (!s || !s.path || s.path === editPath || pathError !== '') return - const value = $state.snapshot(s) as VariableState - const target = s.path - const from = editPath - // The path may have been typed too recently for `Path`'s debounced check - // to have run: moving onto an occupied path would hand this draft to the - // item living there. - if (!(await variablePathIsFree(ws, target))) return - UserDraft.save('variable', target, value, { workspace: ws }) - UserDraft.remove('variable', from, { workspace: ws }) - await Promise.all([ - UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'variable', path: target }), - UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'variable', path: from }) - ]) - } - /** Settle every pending draft write before the drawer's close event, whose * list refetch would otherwise outrun the debounced POST. */ async function flushDraft(): Promise { - // Both started before the first await so they read live form state. - await Promise.all([newDraftSync.flush(), moveRenamedDraftOnly()]) + await newDraftSync.flush() } function reset() { @@ -283,7 +268,7 @@ extraPerms = {} perWsUser = {} pathError = '' - saving = false + openedWith = {} newDraftSync.reset() } @@ -299,6 +284,7 @@ } ensureHandle(ws, s) initialStates[ws] = structuredClone(s) + openedWith[ws] = structuredClone(s) existedInitially[ws] = false selected = ws drawer?.openDrawer() @@ -329,12 +315,11 @@ async function save(): Promise { const dirty = dirtyWorkspaces - // Synchronous, before the first await, so the close-time draft move sees it. - saving = true try { for (const ws of dirty) { const s = states[ws].draft! const ini = initialStates[ws] + const wasDeployed = existedInitially[ws] if (existedInitially[ws]) { await VariableService.updateVariable({ workspace: ws, @@ -370,17 +355,17 @@ // `undefined` reads as dirty). The `value: null` POST also deletes // the server draft row so `is_draft` clears on refetch. initialStates[ws] = $state.snapshot(s) as VariableState + openedWith[ws] = $state.snapshot(s) as VariableState existedInitially[ws] = true - if (editPath) { - UserDraft.discard('variable', editPath, s, { workspace: ws }) - // Flushed: the caller refetches the list right after, and the - // discard's delete rides the same debounce — the just-deployed item - // would still come back rendered as a draft. - await UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'variable', path: editPath }) + // Both awaited: the caller refetches the list right after, and a + // debounced delete would bring the just-deployed item back as a draft. + if (wasDeployed) { + UserDraft.discard('variable', editPath!, s, { workspace: ws }) + await UserDraftDbSyncer.flush({ workspace: ws, itemKind: 'variable', path: editPath! }) } else { - // Awaited: the caller refetches the list right after, and a debounced - // delete would leave the just-created item still flagged as a draft. + // The helper held this draft (new or draft-only), wherever it keyed it. await newDraftSync.finish() + if (editPath) UserDraft.restartSync('variable', editPath, { workspace: ws }) } // Path now exists server-side — drop the autocomplete cache so // it shows up immediately instead of after the 60s TTL. @@ -425,10 +410,11 @@ }) return } - // Draft-only: no baseline to fall back to. Blank the cell so the form - // unmounts — mounted on the empty state it re-fills the path from - // `initialPath`, and that autosave would displace the delete. Flushed - // so the list refetch on drawer close no longer finds the row. + // Draft-only: the item is the draft, so discarding deletes it. `finish` + // drops it wherever the helper keyed it and settles that before the + // caller's list refetch; `remove` then blanks the cell so the form + // unmounts, and clears the key this editor opened if the two differ. + await newDraftSync.finish() UserDraft.remove('variable', editPath ?? '', { workspace: selected }) await UserDraftDbSyncer.flush({ workspace: selected, diff --git a/frontend/src/lib/components/useNewItemDraftSync.svelte.dom.test.ts b/frontend/src/lib/components/useNewItemDraftSync.svelte.dom.test.ts index e53d84c0ef..65c1f342ba 100644 --- a/frontend/src/lib/components/useNewItemDraftSync.svelte.dom.test.ts +++ b/frontend/src/lib/components/useNewItemDraftSync.svelte.dom.test.ts @@ -3,11 +3,13 @@ import { flushSync } from 'svelte' const save = vi.fn() const remove = vi.fn() +const discard = vi.fn() const flush = vi.fn(async () => {}) vi.mock('$lib/userDraft.svelte', () => ({ UserDraft: { save: (...a: unknown[]) => save(...a), - remove: (...a: unknown[]) => remove(...a) + remove: (...a: unknown[]) => remove(...a), + discard: (...a: unknown[]) => discard(...a) } })) vi.mock('$lib/userDraftDbSyncer.svelte', () => ({ @@ -70,14 +72,14 @@ describe('useNewItemDraftSync', () => { flushSync() vi.advanceTimersByTime(1000) flushSync() - expect(remove).toHaveBeenCalledWith('resource', 'u/me/auto_name', { workspace: 'w' }) + expect(discard.mock.calls[0].slice(0, 2)).toEqual(['resource', 'u/me/auto_name']) expect(save).toHaveBeenLastCalledWith('resource', 'u/me/renamed', { n: 2 }, { workspace: 'w' }) form.pathError = 'path already used' flushSync() vi.advanceTimersByTime(1000) flushSync() - expect(remove).toHaveBeenLastCalledWith('resource', 'u/me/renamed', { workspace: 'w' }) + expect(discard.mock.calls.at(-1)?.slice(0, 2)).toEqual(['resource', 'u/me/renamed']) expect(draftPath).toBe('') form.pathError = '' @@ -87,7 +89,7 @@ describe('useNewItemDraftSync', () => { expect(save).toHaveBeenLastCalledWith('resource', 'u/me/renamed', { n: 2 }, { workspace: 'w' }) cleanup() - expect(remove).toHaveBeenCalledTimes(2) + expect(discard).toHaveBeenCalledTimes(2) }) /** The commit is delayed so the key can't land on a half-typed path, and the @@ -188,6 +190,70 @@ describe('useNewItemDraftSync', () => { cleanup() }) + /** A draft-only item arrives with a draft already stored under the path the + * editor opened. Renaming it has to MOVE that row, so the helper must be + * told which key it inherited or it would leave a second one behind. */ + it('moves an adopted key on rename instead of leaving it behind', async () => { + const form = $state({ path: 'u/me/adopted', n: 1 }) + let sync: ReturnType | undefined + const cleanup = $effect.root(() => { + sync = useNewItemDraftSync({ + itemKind: 'resource', + enabled: () => true, + workspace: () => 'w', + path: () => form.path, + pathError: () => '', + contentTouched: () => false, + value: () => ({ n: form.n }) + }) + sync.adopt('w', 'u/me/adopted', { n: 1 }) + }) + flushSync() + + form.path = 'u/me/moved' + flushSync() + vi.advanceTimersByTime(1000) + flushSync() + await sync!.flush() + expect(discard.mock.calls.at(-1)?.slice(0, 2)).toEqual(['resource', 'u/me/adopted']) + expect(save).toHaveBeenLastCalledWith('resource', 'u/me/moved', { n: 1 }, { workspace: 'w' }) + cleanup() + }) + + /** An adopted draft exists whether or not the user edits it, so the + * touched gate that keeps an untouched NEW item from leaving a row must not + * apply — deleting here would wipe the item the editor is showing. An + * invalid path likewise leaves it where it is rather than dropping it. */ + it('keeps an adopted draft through an untouched open and an invalid path', async () => { + const form = $state({ path: 'u/me/kept', pathError: '', n: 1 }) + let sync: ReturnType | undefined + const cleanup = $effect.root(() => { + sync = useNewItemDraftSync({ + itemKind: 'resource', + enabled: () => true, + workspace: () => 'w', + path: () => form.path, + pathError: () => form.pathError, + contentTouched: () => false, + value: () => ({ n: form.n }) + }) + sync.adopt('w', 'u/me/kept', { n: 1 }) + }) + flushSync() + vi.advanceTimersByTime(2000) + flushSync() + expect(discard).not.toHaveBeenCalled() + + form.pathError = 'path already used' + flushSync() + vi.advanceTimersByTime(2000) + flushSync() + await sync!.flush() + expect(discard).not.toHaveBeenCalled() + expect(sync!.draftPath).toBe('u/me/kept') + cleanup() + }) + /** `Path` auto-fills a unique name on mount and flips its own `dirty` on any * keyup, tabbing included. Only a departure from that name counts, or an * untouched drawer would leave a phantom row behind. */ @@ -245,7 +311,7 @@ describe('useNewItemDraftSync', () => { expect(save).toHaveBeenCalledTimes(1) await sync!.finish() - expect(remove).toHaveBeenCalledWith('variable', 'u/me/item', { workspace: 'w' }) + expect(discard.mock.calls.at(-1)?.slice(0, 2)).toEqual(['variable', 'u/me/item']) form.n = 2 flushSync() @@ -259,7 +325,7 @@ describe('useNewItemDraftSync', () => { vi.advanceTimersByTime(1000) flushSync() expect(save).toHaveBeenLastCalledWith('variable', 'u/me/item', { n: 3 }, { workspace: 'w' }) - expect(remove).toHaveBeenCalledTimes(1) + expect(discard).toHaveBeenCalledTimes(1) cleanup() }) diff --git a/frontend/src/lib/components/useNewItemDraftSync.svelte.ts b/frontend/src/lib/components/useNewItemDraftSync.svelte.ts index 7c81456fa2..374992ba9c 100644 --- a/frontend/src/lib/components/useNewItemDraftSync.svelte.ts +++ b/frontend/src/lib/components/useNewItemDraftSync.svelte.ts @@ -28,9 +28,13 @@ export interface NewItemDraftSyncOptions { pathIsFree?: (path: string) => Promise } -export interface NewItemDraftSync { +export interface NewItemDraftSync { /** Storage path of the persisted draft, `''` when none. */ readonly draftPath: string + /** Take ownership of a draft that already exists at `path` — a draft-only + * item the editor loaded. Without this the helper believes it has written + * nothing, and a rename would add a second row instead of moving this one. */ + adopt(workspace: string, path: string, value: V): void /** Commit anything still pending and settle it server-side. Callers MUST * await this before a list refetch (both the commit delay and the syncer's * own debounce outlive a closing drawer, so a refetch would miss the row). */ @@ -51,7 +55,7 @@ export interface NewItemDraftSync { * the list pages' draft-only rows and the get-by-path draft overlay resolve — * and moves it (delete the old key, write the new) as the path changes. */ -export function useNewItemDraftSync(opts: NewItemDraftSyncOptions): NewItemDraftSync { +export function useNewItemDraftSync(opts: NewItemDraftSyncOptions): NewItemDraftSync { let draftPath = $state('') let finished = $state(false) // The key last written, workspace included: a move or a delete has to target @@ -67,13 +71,21 @@ export function useNewItemDraftSync(opts: NewItemDraftSyncOptions): NewIte // first edit must keep the draft, so a pending commit is never cancelled by // teardown — only superseded by a newer one, or consumed by `flush`. let pending: - | { timer: ReturnType; key: string; value: V | undefined } + | { + timer: ReturnType + workspace: string | undefined + key: string + value: V | undefined + } | undefined - let pendingWorkspace: string | undefined // `Path` auto-fills a unique name on mount, so a non-empty path is no // evidence the user did anything. Only a departure from the name it settled // on counts (`Path.dirty` can't: it flips on any keyup, tabbing included). let autoPath: string | undefined + // An adopted draft already exists: it is kept regardless of whether the user + // edits anything, and an invalid path leaves it where it is rather than + // deleting it. Only a brand-new item's draft is gated on being touched. + let adopted = false function markUnsettled(workspace: string, path: string): void { if (!unsettled.some((k) => k.workspace === workspace && k.path === path)) { @@ -89,7 +101,10 @@ export function useNewItemDraftSync(opts: NewItemDraftSyncOptions): NewIte function write(workspace: string | undefined, path: string, value: V | undefined): void { if (written && (written.path !== path || written.workspace !== workspace)) { - UserDraft.remove(opts.itemKind, written.path, { workspace: written.workspace }) + // `discard`, not `remove`: an adopted key is the editor's own handle key, + // and `remove` blanks that live cell — the form would lose its state + // mid-rename. The fallback leaves the cell holding what the form holds. + UserDraft.discard(opts.itemKind, written.path, value, { workspace: written.workspace }) markUnsettled(written.workspace, written.path) written = undefined writtenValue = undefined @@ -109,18 +124,36 @@ export function useNewItemDraftSync(opts: NewItemDraftSyncOptions): NewIte pending = undefined } - function commit(): void { + /** Validate the pending key, then take it. The timer is cleared first and + * the payload kept, so the validation below can't race a second commit of + * the same transition; a newer transition scheduled meanwhile wins. */ + async function commitPending(): Promise { const p = pending if (!p) return - dropPending() + clearTimeout(p.timer) + if (p.key) { + // `Path` debounces its own existence check and may not have answered + // yet, so the key is verified here rather than trusted: keying a draft + // on a path that already holds an item would take it as an edit of + // that item, and saving from there would overwrite its value. + const free = + opts.pathError() === '' && (opts.pathIsFree ? await opts.pathIsFree(p.key) : true) + if (pending !== p) return + if (!free) { + pending = undefined + return + } + } + pending = undefined draftPath = p.key - write(pendingWorkspace, p.key, p.value) + write(p.workspace, p.key, p.value) } $effect(() => { if (!opts.enabled() || finished) return const p = opts.path() - const key = p !== '' && opts.pathError() === '' && touched() ? p : '' + const usable = p !== '' && opts.pathError() === '' + const key = usable && (adopted || touched()) ? p : adopted ? untrack(() => draftPath) : '' const workspace = opts.workspace() const value = opts.value() if (key === untrack(() => draftPath)) { @@ -130,8 +163,12 @@ export function useNewItemDraftSync(opts: NewItemDraftSyncOptions): NewIte } untrack(() => { dropPending() - pendingWorkspace = workspace - pending = { timer: setTimeout(commit, COMMIT_DELAY_MS), key, value } + pending = { + timer: setTimeout(() => void commitPending(), COMMIT_DELAY_MS), + workspace, + key, + value + } }) }) @@ -159,18 +196,15 @@ export function useNewItemDraftSync(opts: NewItemDraftSyncOptions): NewIte get draftPath() { return draftPath }, + adopt(workspace: string, path: string, value: V) { + written = { workspace, path } + writtenValue = JSON.stringify(value) + autoPath = path + draftPath = path + adopted = true + }, async flush() { - const p = pending - if (p && p.key) { - // Forced by a close, so the commit delay that lets `Path`'s debounced - // existence check land was cut short. Re-check before keying on it: - // a path that already holds an item would take this draft as an edit - // of that item, and saving from there would overwrite its value. - const free = - opts.pathError() === '' && (opts.pathIsFree ? await opts.pathIsFree(p.key) : true) - if (!free && pending === p) dropPending() - } - commit() + await commitPending() await settle() }, async finish() { @@ -181,13 +215,15 @@ export function useNewItemDraftSync(opts: NewItemDraftSyncOptions): NewIte writtenValue = undefined draftPath = '' if (w) { - UserDraft.remove(opts.itemKind, w.path, { workspace: w.workspace }) + // See `write`: an adopted key is a live handle key, so keep its cell. + UserDraft.discard(opts.itemKind, w.path, opts.value(), { workspace: w.workspace }) markUnsettled(w.workspace, w.path) } await settle() }, reset() { finished = false + adopted = false dropPending() written = undefined writtenValue = undefined