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:
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user