From 718a7b516ec9dc21110140756daf03a23d68af48 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 15 Sep 2026 09:11:41 +0200 Subject: [PATCH] fix: send a held item's queued row before re-reading it Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015vn1jCHUcAdaG9C4NsUehF --- frontend/src/lib/itemStore.svelte.ts | 3 +++ frontend/src/lib/itemStore.test.ts | 26 +++++++++++++++++--------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/itemStore.svelte.ts b/frontend/src/lib/itemStore.svelte.ts index e094669be2..80d26ecb1f 100644 --- a/frontend/src/lib/itemStore.svelte.ts +++ b/frontend/src/lib/itemStore.svelte.ts @@ -826,6 +826,9 @@ export function createItemStore(ports: ItemRowPort) { const holder = entries.get(keyString(key)) if (holder === by) return if (holder) { + // Its row is debounced: unsent, an edit typed in the last moment is not what the + // re-read would find, and the read would put the screen back before it. + await ports.flush(key) await holder.reread() return } diff --git a/frontend/src/lib/itemStore.test.ts b/frontend/src/lib/itemStore.test.ts index f75b751afc..87b6539ee0 100644 --- a/frontend/src/lib/itemStore.test.ts +++ b/frontend/src/lib/itemStore.test.ts @@ -32,12 +32,20 @@ function fakeRows() { const hints = new Map() /** Paths whose parked payload was dropped. */ const dropped: string[] = [] + /** What a write leaves queued, and what a flush sends: the syncer debounces. */ + const queued = new Map() + const sent = new Map() const port: ItemRowPort = { write: (key, value) => { writes.push({ path: key.path, value }) + queued.set(key.path, value) if (failing.has(key.path)) failures.set(key.path, 'unreachable') }, - flush: async () => {}, + flush: async (key) => { + if (!queued.has(key.path)) return + sent.set(key.path, queued.get(key.path)) + queued.delete(key.path) + }, overwrite: async (key, value) => { conflicts.delete(key.path) writes.push({ path: key.path, value }) @@ -48,7 +56,7 @@ function fakeRows() { dropPending: (key) => void dropped.push(key.path), hint: (key, on) => void hints.set(key.path, on) } - return { port, writes, conflicts, failing, hints, dropped } + return { port, writes, conflicts, failing, hints, dropped, sent } } const deployedRes: Res = { path: 'u/me/r', description: 'deployed', args: { a: 1 } } @@ -527,17 +535,17 @@ describe('item store: one entry per key', () => { const rows = fakeRows() const store = createItemStore(rows.port) const b = { ...deployedRes, path: 'u/me/b' } - // What the server holds: the editor's own edits reach it as its draft row. + // What the server holds: the deployed value, and the row the syncer has actually sent. The + // editor's edit is only queued, so the re-read has to flush it before reading. let deployed = b - let draft: Res | undefined const { handle: open } = store.acquire( { workspace: 'w', kind: 'resource', path: 'u/me/b' }, { workspace: 'w', path: 'u/me/b' }, - adapter(async () => ({ deployed, draft })) + adapter(async () => ({ deployed, draft: rows.sent.get('u/me/b') as Res | undefined })) ) await settle() - open.value = { ...b, description: 'typed in the open editor' } - draft = { ...b, description: 'typed in the open editor' } + const typed = { ...b, description: 'typed in the open editor' } + open.value = typed const temporary = newItemPath() const { handle: panel } = store.acquire( @@ -549,9 +557,9 @@ describe('item store: one entry per key', () => { panel.value = { ...b, args: { a: 2 } } expect(await panel.save()).toMatchObject({ ok: true, path: 'u/me/b' }) - // The write landed under it, and its own edits came back over it from its row. + // The write landed under it, and its own edit came back over it from its row. expect(open.deployed).toEqual({ ...b, args: { a: 2 } }) - expect(open.value).toEqual(draft) + expect(open.value).toEqual(typed) expect(open.dirty).toBe(true) })