diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 2a7440a6b4..be0caf9aeb 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -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) { diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index d17057d46f..45d05447b1 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -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 }) diff --git a/frontend/src/lib/components/useNewItemDraftSync.svelte.dom.test.ts b/frontend/src/lib/components/useNewItemDraftSync.svelte.dom.test.ts index 65c1f342ba..d7619afcb3 100644 --- a/frontend/src/lib/components/useNewItemDraftSync.svelte.dom.test.ts +++ b/frontend/src/lib/components/useNewItemDraftSync.svelte.dom.test.ts @@ -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 | 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 diff --git a/frontend/src/lib/components/useNewItemDraftSync.svelte.ts b/frontend/src/lib/components/useNewItemDraftSync.svelte.ts index 374992ba9c..a7368c1582 100644 --- a/frontend/src/lib/components/useNewItemDraftSync.svelte.ts +++ b/frontend/src/lib/components/useNewItemDraftSync.svelte.ts @@ -23,9 +23,13 @@ export interface NewItemDraftSyncOptions { /** 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 + /** 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 { @@ -105,6 +109,7 @@ export function useNewItemDraftSync(opts: NewItemDraftSyncOptions): 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(opts: NewItemDraftSyncOptions): 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()