fix(frontend): keep autosaving a reopened draft-only item

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:45:06 +02:00
co-authored by Claude Opus 5
parent 7512783fde
commit eb6e12f0d2
4 changed files with 63 additions and 18 deletions
@@ -236,7 +236,12 @@
!!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))
pathIsFree: (p) => (selected ? resourcePathIsFree(selected, p) : Promise.resolve(false)),
onAbandonKey: (ws, p) => {
// The handle is pinned to the path this editor opened; once the draft
// has moved off it, its next write would recreate the row it left.
if (p === initialPath) UserDraft.stopSync('resource', p, { workspace: ws })
}
})
// New-resource bootstrap: seed empty state per workspace (edit mode
@@ -308,13 +313,10 @@
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))
}
// The helper owns this draft's key from here (see `draftOnly`): the
// handle keeps autosaving it while the path is unchanged, and hands
// the key over the moment a rename moves it.
if (noDeployed) newDraftSync.adopt(ws, initialPath, structuredClone(s))
perWsUser[ws] = user
// Keep resource_type in sync for the base workspace (controls the schema)
if (ws === effectiveWorkspace) {
@@ -148,7 +148,12 @@
!!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))
pathIsFree: (p) => (selected ? variablePathIsFree(selected, p) : Promise.resolve(false)),
onAbandonKey: (ws, p) => {
// The handle is pinned to the path this editor opened; once the draft
// has moved off it, its next write would recreate the row it left.
if (p === editPath) UserDraft.stopSync('variable', p, { workspace: ws })
}
})
// The list-page `*` hint is owned by UserDraftDbSyncer (set on save, cleared
@@ -240,13 +245,10 @@
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))
}
// The helper owns this draft's key from here (see `draftOnly`): the
// handle keeps autosaving it while the path is unchanged, and hands
// the key over the moment a rename moves it.
if (noDeployed) newDraftSync.adopt(ws, p, structuredClone(s))
extraPerms[ws] = v.extra_perms ?? {}
perWsUser[ws] = user
})
@@ -220,6 +220,41 @@ describe('useNewItemDraftSync', () => {
cleanup()
})
/** An editor's own autosave handle stays pinned to the path it opened, so
* once the draft moves the helper has to hand that key back for suspension —
* otherwise the handle's next write recreates the row just deleted. */
it('reports the key it abandons so a pinned handle can be suspended', async () => {
const form = $state({ path: 'u/me/pinned', n: 1 })
const abandoned: string[] = []
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 }),
onAbandonKey: (_ws, p) => abandoned.push(p)
})
sync.adopt('w', 'u/me/pinned', { n: 1 })
})
flushSync()
// Untouched: the key is still in use, nothing handed back.
vi.advanceTimersByTime(2000)
flushSync()
expect(abandoned).toEqual([])
form.path = 'u/me/elsewhere'
flushSync()
vi.advanceTimersByTime(1000)
flushSync()
await sync!.flush()
expect(abandoned).toEqual(['u/me/pinned'])
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
@@ -23,9 +23,13 @@ export interface NewItemDraftSyncOptions<V> {
/** Reactive deep read of the value to persist (`$state.snapshot` of the
* form state); `undefined` while there is nothing to persist. */
value: () => V | undefined
/** Whether nothing is deployed at `path` yet. Consulted only when a close
* forces a commit early, where `Path`'s own check may not have run. */
/** Whether nothing is deployed at `path` yet. Consulted before every commit:
* `Path`'s own check is debounced and may not have answered. */
pathIsFree?: (path: string) => Promise<boolean>
/** Called for a key this helper has stopped writing to, after its row is
* deleted. An editor whose own autosave handle is pinned to that key MUST
* suspend it here, or the handle's next write would recreate the row. */
onAbandonKey?: (workspace: string, path: string) => void
}
export interface NewItemDraftSync<V> {
@@ -105,6 +109,7 @@ export function useNewItemDraftSync<V>(opts: NewItemDraftSyncOptions<V>): NewIte
// 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 })
opts.onAbandonKey?.(written.workspace, written.path)
markUnsettled(written.workspace, written.path)
written = undefined
writtenValue = undefined
@@ -217,6 +222,7 @@ export function useNewItemDraftSync<V>(opts: NewItemDraftSyncOptions<V>): NewIte
if (w) {
// 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 })
opts.onAbandonKey?.(w.workspace, w.path)
markUnsettled(w.workspace, w.path)
}
await settle()