diff --git a/packages/client-runtime/src/queuedRunEdit.test.ts b/packages/client-runtime/src/queuedRunEdit.test.ts index b9d22b3fa9a51..24d2dc42402d6 100644 --- a/packages/client-runtime/src/queuedRunEdit.test.ts +++ b/packages/client-runtime/src/queuedRunEdit.test.ts @@ -102,7 +102,6 @@ describe("makeQueuedRunEditStore", () => { const finishStale = store.startSave("env:thread"); begin(); store.update("env:thread", (edit) => ({ ...edit, text: "newer" })); - expect(registry.get(store.savingAtom)).toEqual({}); expect(finishStale?.(true)).toBe(false); expect(finishStale?.(false)).toBe(false); @@ -111,6 +110,27 @@ describe("makeQueuedRunEditStore", () => { expect(exits).toEqual([{ threadKey: "env:thread", text: "newer" }]); }); + // The server orders saves by arrival, not by edit age, so a second save could + // land before the first and then be overwritten by it. + it("keeps the save claimed until it settles, even when the edit is begun again or ended", () => { + const { registry, store, begin } = setup(); + begin(); + const finishStale = store.startSave("env:thread"); + begin(); + expect(registry.get(store.savingAtom)).toEqual({ "env:thread": true }); + expect(store.startSave("env:thread")).toBeNull(); + store.end("env:thread"); + begin(); + expect(store.startSave("env:thread")).toBeNull(); + + expect(finishStale?.(true)).toBe(false); + expect(registry.get(store.savingAtom)).toEqual({}); + expect(finishStale?.(false)).toBe(false); + const finishNewer = store.startSave("env:thread"); + expect(finishNewer?.(true)).toBe(true); + expect(store.get("env:thread")).toBeNull(); + }); + it("does not revive a cancelled edit when its save fails", () => { const { registry, runs, store, exits, begin } = setup(); begin(); diff --git a/packages/client-runtime/src/queuedRunEdit.ts b/packages/client-runtime/src/queuedRunEdit.ts index 65b29647c934b..5d9377b263840 100644 --- a/packages/client-runtime/src/queuedRunEdit.ts +++ b/packages/client-runtime/src/queuedRunEdit.ts @@ -141,7 +141,6 @@ export function makeQueuedRunEditStore(opti if (watch === undefined) return; unsubscribe(watch); watches.delete(threadKey); - setSaving(threadKey, false); }; return { @@ -178,21 +177,26 @@ export function makeQueuedRunEditStore(opti }, /** * Claims the open edit for a save, or returns null when there is no edit - * or a save already holds it. The exit watch pauses so the save's result - * decides the edit's fate. + * or a save is in flight. The exit watch pauses so the save's result decides + * the edit's fate. The claim outlives the edit: the server orders saves by + * arrival, so an overlapping older save could overwrite a newer one. */ startSave(threadKey: string): FinishQueuedRunEditSave | null { const watch = watches.get(threadKey); if (watch === undefined || threadKey in registry.get(savingAtom)) return null; setSaving(threadKey, true); unsubscribe(watch); + let settled = false; return (succeeded) => { + if (!settled) { + settled = true; + setSaving(threadKey, false); + } if (watches.get(threadKey) !== watch) return false; if (succeeded) { stopWatch(threadKey); write(threadKey, null); } else { - setSaving(threadKey, false); subscribe(threadKey, watch); } return true;