refactor(frontend): let the draft helper own draft-only items too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy24UHSVRZdDaPWiBay9MG
This commit is contained in:
Ruben Fiszel
2026-09-04 01:25:21 +02:00
co-authored by Claude Opus 5
parent 33063fbf25
commit 7512783fde
4 changed files with 203 additions and 130 deletions
@@ -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<boolean> {
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<string, ResourceState> = $state({})
const newDraftSync = useNewItemDraftSync<ResourceState>({
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<void> {
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<void> {
// 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<void> {
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.
@@ -58,9 +58,9 @@
let perWsUser: Record<string, UserExt | undefined> = $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<string, VariableState> = $state({})
async function variablePathIsFree(ws: string, p: string): Promise<boolean> {
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<VariableState>({
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<void> {
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<void> {
// 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<void> {
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,
@@ -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<typeof useNewItemDraftSync> | 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<typeof useNewItemDraftSync> | 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()
})
@@ -28,9 +28,13 @@ export interface NewItemDraftSyncOptions<V> {
pathIsFree?: (path: string) => Promise<boolean>
}
export interface NewItemDraftSync {
export interface NewItemDraftSync<V> {
/** 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<V>(opts: NewItemDraftSyncOptions<V>): NewItemDraftSync {
export function useNewItemDraftSync<V>(opts: NewItemDraftSyncOptions<V>): NewItemDraftSync<V> {
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<V>(opts: NewItemDraftSyncOptions<V>): 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<typeof setTimeout>; key: string; value: V | undefined }
| {
timer: ReturnType<typeof setTimeout>
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<V>(opts: NewItemDraftSyncOptions<V>): 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<V>(opts: NewItemDraftSyncOptions<V>): 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<void> {
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<V>(opts: NewItemDraftSyncOptions<V>): 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<V>(opts: NewItemDraftSyncOptions<V>): 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<V>(opts: NewItemDraftSyncOptions<V>): 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