fix(web): settle every review save, not just the latest
Per-call mutate() callbacks fire only for an observer's latest call, so with overlapping saves the in-flight count could stay above zero and the review would ignore every later server value, including a Restore. Saves now settle through useEntryPatch's hook-level onSettled (fires for every mutation), in FIFO order (the entry-patch scope is serial). The draft counts pending saves, remembers the latest incoming value, skips our own optimistic/rollback echoes, and applies an outside change once the saves settle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4608c977b7
commit
9546fa13f2
4 files changed
+99
-65
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { draftFailedSave, draftIncoming, draftStartSave, formatDateRange, formatPartialDate, formatRating, friendlyError, parsePartialDate, rollupLabel, shortDate } from "../src/format.ts";
|
||||
import { draftIncoming, draftSettled, draftStartSave, newDraft, type Draft, formatDateRange, formatPartialDate, formatRating, friendlyError, parsePartialDate, rollupLabel, shortDate } from "../src/format.ts";
|
||||
|
||||
describe("formatRating", () => {
|
||||
test("5 stars with halves", () => {
|
||||
@@ -98,29 +98,51 @@ test("shortDate", () => {
|
||||
});
|
||||
|
||||
describe("review draft sync", () => {
|
||||
const typed = { text: "Second thoughts.", synced: "First thoughts.", saving: false };
|
||||
const draft = (text: string, synced: string): Draft => ({ ...newDraft(synced), text });
|
||||
const pick = (d: Draft) => [d.text, d.synced, d.pending];
|
||||
|
||||
test("a rollback after a failed save keeps the local text (rollback lands first)", () => {
|
||||
let d = draftStartSave(typed); // optimistic cache now says "Second thoughts."
|
||||
d = draftIncoming(d, "Second thoughts.");
|
||||
const was = typed.synced;
|
||||
d = draftIncoming(d, "First thoughts."); // onError rolled the cache back
|
||||
d = draftFailedSave(d, was);
|
||||
expect(d.text).toBe("Second thoughts.");
|
||||
expect(d.synced).toBe("First thoughts."); // so the text still counts as unsaved
|
||||
expect(d.saving).toBe(false);
|
||||
test("a rollback after a failed save keeps the local text", () => {
|
||||
const save = { was: "First thoughts.", stored: "Second thoughts." };
|
||||
let d = draftStartSave(draft("Second thoughts.", save.was), save);
|
||||
d = draftIncoming(d, save.stored); // optimistic update
|
||||
d = draftIncoming(d, save.was); // rollback
|
||||
d = draftSettled(d, save, false);
|
||||
expect(pick(d)).toEqual(["Second thoughts.", "First thoughts.", 0]);
|
||||
});
|
||||
|
||||
test("a rollback after a failed save keeps the local text (error callback first)", () => {
|
||||
let d = draftStartSave(typed);
|
||||
d = draftFailedSave(d, typed.synced);
|
||||
d = draftIncoming(d, "First thoughts.");
|
||||
expect(d.text).toBe("Second thoughts.");
|
||||
// Two overlapping saves: "a" (was "old"), then "b" (was "a"). Incoming values mirror what the cache does.
|
||||
const A = { was: "old", stored: "a" };
|
||||
const B = { was: "a", stored: "b" };
|
||||
const twoSaves = () => draftIncoming(draftIncoming(draftStartSave({ ...draftStartSave(draft("a", "old"), A), text: "b" }, B), "a"), "b");
|
||||
|
||||
test("two overlapping saves both settle and the flag clears (success, success)", () => {
|
||||
let d = twoSaves();
|
||||
expect(d.pending).toBe(2);
|
||||
d = draftSettled(draftSettled(d, A, true), B, true);
|
||||
expect(pick(d)).toEqual(["b", "b", 0]);
|
||||
expect(d.echoes).toEqual([]);
|
||||
});
|
||||
|
||||
test("a change from elsewhere (Restore) replaces untouched text but not edited text", () => {
|
||||
const clean = { text: "Second thoughts.", synced: "Second thoughts.", saving: false };
|
||||
expect(draftIncoming(clean, "First thoughts.").text).toBe("First thoughts.");
|
||||
expect(draftIncoming(typed, "Something else").text).toBe("Second thoughts.");
|
||||
test("two overlapping saves: fail then success ends synced; success then fail keeps text unsaved", () => {
|
||||
let d = draftIncoming(twoSaves(), "old"); // A's rollback
|
||||
d = draftSettled(draftSettled(d, A, false), B, true);
|
||||
expect(pick(d)).toEqual(["b", "b", 0]);
|
||||
let e = draftIncoming(twoSaves(), "a"); // B's rollback
|
||||
e = draftSettled(draftSettled(e, A, true), B, false);
|
||||
expect(pick(e)).toEqual(["b", "a", 0]);
|
||||
});
|
||||
|
||||
test("an incoming value that arrives while saving is applied once saving ends", () => {
|
||||
const save = { was: "x", stored: "y" };
|
||||
let d = draftStartSave(draft("y", "x"), save);
|
||||
d = draftIncoming(d, "Restored.");
|
||||
expect(d.text).toBe("y");
|
||||
d = draftSettled(d, save, true);
|
||||
expect(pick(d)).toEqual(["Restored.", "Restored.", 0]);
|
||||
});
|
||||
|
||||
test("a change from elsewhere replaces untouched text but not edited text", () => {
|
||||
expect(draftIncoming(draft("Second.", "Second."), "First.").text).toBe("First.");
|
||||
expect(draftIncoming(draft("Typed.", "Second."), "Other.").text).toBe("Typed.");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user