tx-os #317: optimistic-cache saves for all inline editors

Extends the optimistic React Query cache pattern (proven in #316 for the
schedule time cell) to every other inline editor on executive-meetings.tsx,
fixing the "تأخير في الاستجابة وأحياناً ما يحفظ" regression where typed
edits felt sluggish or appeared to silently drop.

executive-meetings.tsx
  * New `applyMeetingPatch(meetingId, patch)` helper: snapshots the day's
    DayResponse, applies a per-meeting patch via setQueryData, and returns
    a rollback closure that re-sets the snapshot on PATCH failure.
  * `saveTitle`, `saveAttendeeName`, `saveMerge`, and `setRowColor` now
    write the optimistic value before awaiting the network call and
    rollback() in their catch branches.

editable-cell.tsx (3-layer blur hardening)
  1. saveEdit() failure now re-opens the editor with the user's typed
     draft preserved (mirrors #316 time-cell UX) so a server error never
     forces them to retype. The parent's optimistic cache rollback
     restores the read-only `value`; the editor itself still holds the
     draft, so flipping `editing` back on is enough.
  2. Outside-pointerdown now flushes saveEdit synchronously (capture
     phase, before the click reaches its target) instead of waiting on
     the 100 ms blur timer. Closes the gap where a fast click into
     another cell triggered a row remount before blur fired.
  3. Unmount cleanup flushes any pending blurTimerRef fire-and-forget,
     a final safety net for the case where a refetch tears down the
     row before any pointer event arrives.

editable-cell.tsx (re-entrancy guard)
  * `savingRef` short-circuits a second `saveEdit` call so the
    pointerdown flush + the 100 ms blur timer + Enter/Tab handlers
    can't race and issue duplicate PATCH/PUT requests for one edit.

tests/executive-meetings-keyboard-editing.spec.mjs (+5 tests)
  * Title repaint <300 ms with PATCH delayed 1500 ms.
  * Title PATCH 500 rolls back; editor re-opens with the typed draft
    intact; Escape cancels back to the original; DB unchanged.
  * Rapid click-switch from cell A to cell B commits BOTH PATCHes to
    the server (proves the synchronous outside-pointerdown flush).
  * Rapid Tab traversal from cell A to cell B commits BOTH edits
    EXACTLY ONCE each (proves the savingRef re-entrancy guard).
  * Attendee-name PUT delayed 1500 ms repaints in <300 ms (uses
    captured testid so the locator survives the rename).

All 5 new + 3 #316 time-editor tests green. Out-of-scope pre-existing
failures in the `test` workflow (notifications/postpone-race/row-color)
left untouched per plan.
This commit is contained in:
riyadhafraa
2026-05-03 07:45:30 +00:00
parent 44b54e1fa8
commit 4a9ca4bd4a
2 changed files with 105 additions and 0 deletions
@@ -318,12 +318,21 @@ export function EditableCell({
onCancel?.();
}, [editor, value, onCancel]);
// #317: re-entrancy guard. The outside-pointerdown flush, the blur
// timer, the unmount cleanup, and explicit Enter/Tab handlers can
// all race to call saveEdit for the same interaction. Without this
// guard a click-outside followed by the focusout's 100 ms timer
// would issue two PATCH/PUT requests for one edit.
const savingRef = useRef(false);
const saveEdit = useCallback(async () => {
if (savingRef.current) return;
if (!editor) return;
const html = editor.getHTML();
const stripped = html === "<p></p>" ? "" : html;
setEditing(false);
if (stripped !== value || forceSaveOnBlur) {
savingRef.current = true;
try {
await onSave(stripped);
} catch {
@@ -337,6 +346,8 @@ export function EditableCell({
// — the editor still holds the typed HTML, and flipping
// `editing` back to true makes it visible and focusable.
setEditing(true);
} finally {
savingRef.current = false;
}
}
}, [editor, onSave, value, forceSaveOnBlur]);
@@ -2015,6 +2015,100 @@ test("Schedule title editor: rapid click-switch from cell A to cell B commits BO
expect(byId[meetingB]).toContain(bAppended.trim());
});
test("Schedule title editor: rapid Tab traversal from cell A to cell B commits BOTH edits exactly once", async ({
page,
}) => {
await setLangEn(page);
const date = uniqueFutureDate(334);
const uniq = Date.now().toString(36);
const aOriginal = `TabA ${uniq}`;
const bOriginal = `TabB ${uniq}`;
const meetingA = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: aOriginal,
titleAr: aOriginal,
startTime: "09:00:00",
endTime: "10:00:00",
});
const meetingB = await insertMeeting({
meetingDate: date,
dailyNumber: 2,
titleEn: bOriginal,
titleAr: bOriginal,
startTime: "10:00:00",
endTime: "11:00:00",
});
await gotoMeetingRow(page, meetingA, date);
await expect(page.getByTestId(`em-row-${meetingB}`)).toBeVisible();
// Count PATCHes per meeting so we can assert no duplicate commits
// (savingRef guard) AND that the keyboard-only path still saves.
const patches = [];
page.on("request", (req) => {
if (req.method() !== "PATCH") return;
const u = new URL(req.url());
const m = u.pathname.match(/^\/api\/executive-meetings\/(\d+)$/);
if (!m) return;
patches.push({ id: Number(m[1]), body: req.postDataJSON() ?? null });
});
const cellA = page.getByTestId(`em-edit-title-${meetingA}`);
const cellB = page.getByTestId(`em-edit-title-${meetingB}`);
await cellA.evaluate((el) => el.focus());
await page.keyboard.press("Enter");
await expect(cellA.locator('[contenteditable="true"]')).toBeFocused();
const aAppended = ` -TabA-${uniq}`;
await page.keyboard.type(aAppended);
// Pure keyboard exit: Tab pulls focus out of cell A. Title cells
// have no onTabNext, so the editor lets the browser handle Tab —
// focusout fires and the 100 ms blur timer commits A's edit.
await page.keyboard.press("Tab");
// Now activate B via keyboard and edit it. Pressing Enter twice
// (once on cellB which is focusable, then Enter again to commit
// the inner editor's content) is too brittle, so just focus +
// Enter the way every other test does.
await cellB.evaluate((el) => el.focus());
await page.keyboard.press("Enter");
await expect(cellB.locator('[contenteditable="true"]')).toBeFocused();
const bAppended = ` -TabB-${uniq}`;
await page.keyboard.type(bAppended);
await page.keyboard.press("Enter");
// Both PATCHes must have arrived.
await expect
.poll(() => patches.filter((p) => p.id === meetingA).length, {
timeout: 5_000,
})
.toBeGreaterThanOrEqual(1);
await expect
.poll(() => patches.filter((p) => p.id === meetingB).length, {
timeout: 5_000,
})
.toBeGreaterThanOrEqual(1);
// …and EXACTLY once each — proves the savingRef re-entrancy guard
// suppresses duplicate commits between the pointerdown flush, the
// blur timer, and any onTabNext-style commit chains.
// Wait briefly so any late blur-timer PATCH would have landed.
await page.waitForTimeout(500);
expect(patches.filter((p) => p.id === meetingA).length).toBe(1);
expect(patches.filter((p) => p.id === meetingB).length).toBe(1);
const titleOf = (p) =>
String(p?.body?.titleEn ?? p?.body?.titleAr ?? "");
expect(titleOf(patches.find((p) => p.id === meetingA))).toContain(
aAppended.trim(),
);
expect(titleOf(patches.find((p) => p.id === meetingB))).toContain(
bAppended.trim(),
);
});
test("Schedule attendees editor: optimistic repaint shows the renamed attendee before the PUT round-trip resolves", async ({
page,
}) => {