diff --git a/web/src/lib/components/items/ItemDetail.svelte b/web/src/lib/components/items/ItemDetail.svelte index 83d6c99f8..482b26427 100644 --- a/web/src/lib/components/items/ItemDetail.svelte +++ b/web/src/lib/components/items/ItemDetail.svelte @@ -5,6 +5,7 @@ // Its own statement, so units that only use `api` keep their reviewed hash. import { isSupersededWriteError } from '$lib/api/client'; import { nextClientWrite } from '$lib/items/clientWrite'; + import { SaveTracker, type SaveToken } from '$lib/items/saveTracker.svelte'; import { confirmOpenChildrenOrThrow, isOpenChildrenError } from '$lib/items/openChildrenError'; import { WriteOrder, fieldWriteTarget, rederiveListWrite, submitOrderedOCC } from '$lib/items/fieldWriteOrder'; import { isMultiRelationType } from '$lib/items/relationFieldTypes'; @@ -727,8 +728,12 @@ }); let contentDebounceTimer: ReturnType | undefined; - let saveStatus = $state<'idle' | 'saving' | 'saved'>('idle'); - let saveStatusTimer: ReturnType | undefined; + // The save indicator counts the writes OUTSTANDING (BUG-3044): every + // `saves.begin()` below is settled in a `finally`, so a superseded or + // fenced-off write still counts down. `saveStatus` stays the name the SSE + // gates read. + const saves = new SaveTracker(); + const saveStatus = $derived(saves.status); // Per-instance shadow of editorStore.dirty/lastSaveTime (PLAN-2154 Phase 0 / // R4, TASK-2156). editorStore is a module singleton — with a second @@ -1970,8 +1975,6 @@ // the next item, which then suppresses SSE/sync refreshes // (the `if (saveStatus === 'saving')` guards above). // Per Codex review round 12. - clearTimeout(saveStatusTimer); - saveStatusTimer = undefined; // Cleared BEFORE the status goes idle: a refresh owed to the previous // item must not fire against this one when the effect below sees the // status drop (BUG-3036). @@ -1979,7 +1982,9 @@ // A change recorded for the PREVIOUS load's item is not this load's // (BUG-3198): the record describes what arrived while this load runs. changedDuringLoad = emptyChangedDuringLoad(); - saveStatus = 'idle'; + // Forgets every write still out for the previous item: their settles + // carry the old epoch and are ignored (BUG-3044). + saves.reset(); // Capture the URL parts this load was scoped to. Used in the catch // path to detect whether the user has navigated away before this // request rejected — without this the stale catch would clobber a @@ -3196,10 +3201,13 @@ onBack?.(); } - function showSaved() { - saveStatus = 'saved'; - clearTimeout(saveStatusTimer); - saveStatusTimer = setTimeout(() => { saveStatus = 'idle'; }, 2000); + /** + * Records that THIS write succeeded. The indicator shows "Saved" once the + * last outstanding write settles, and only if the newest one issued is this + * kind of success (BUG-3044); see `SaveTracker`. + */ + function showSaved(saveTok: SaveToken) { + saves.succeed(saveTok); } async function saveTitle() { @@ -3231,15 +3239,14 @@ const sent = titleDraft.trim(); const targetItem = item; const gen = loadGeneration; - saveStatus = 'saving'; + const saveTok = saves.begin(); try { const updated = await api.items.update(wsSlug, targetItem.id, { title: titleDraft.trim() }); if (gen !== loadGeneration || item?.id !== targetItem.id) return; item = withInflightTags(updated); - showSaved(); + showSaved(saveTok); } catch (err: any) { if (gen !== loadGeneration || item?.id !== targetItem.id) return; - saveStatus = 'idle'; const reason = err?.message || 'Failed to update title'; // BUG-3115: put the refused text back in the editor, with the reason, // unless the user has already reopened it and typed something else. @@ -3255,6 +3262,8 @@ } else { toastStore.show(reason, 'error'); } + } finally { + saves.settle(saveTok); } } @@ -3326,7 +3335,7 @@ // Taken BEFORE the first send, so a second edit to the same field while // this one is in flight is visible to every post-await branch below. const ticket = fieldWrites.take(fieldWriteTarget(targetItem.id, key)); - saveStatus = 'saving'; + const saveTok = saves.begin(); // One PATCH attempt: single-key merge, OCC-guarded by the caller-supplied // token. `force` overrides the open-children guard (BUG-1538). @@ -3404,7 +3413,7 @@ // looks exactly as healthy as the newer one. if (!fieldWrites.claim(ticket)) return; item = withInflightTags(fresh); - showSaved(); + showSaved(saveTok); } catch (e) { // A newer write for this field was dispatched while this one was in // flight, so this one's outcome is not the one the user is waiting @@ -3450,7 +3459,6 @@ // question is re-asked here rather than inherited from the // check at the top of this catch. if (fieldWrites.superseded(ticket)) return; - saveStatus = 'idle'; const msg = retryErr instanceof Error ? retryErr.message : 'Failed to save'; console.error('Forced field update failed:', retryErr); toastStore.show(msg, 'error'); @@ -3469,7 +3477,7 @@ // question that matters: has anything newer actually written? if (!fieldWrites.claim(ticket)) return; item = withInflightTags(forced); - showSaved(); + showSaved(saveTok); return; } // User declined to override. Snap the on-page field back @@ -3486,7 +3494,6 @@ // claim on return — server changed, pane showing the old value, // and nothing left to correct it. A branch that asserts no new // server truth must not take the mark for one. - if (!fieldWrites.superseded(ticket)) saveStatus = 'idle'; item = { ...item }; toastStore.show('Status change cancelled', 'info'); return; @@ -3498,7 +3505,6 @@ if (isUpdateConflictError(e)) { if (!stillCurrent() || !item) return; if (!fieldWrites.claim(ticket)) return; - saveStatus = 'idle'; // `lastServerItem` is only ever assigned inside the submitWithOCC // closure, so TS's control-flow narrows it to `null` here — the // assertion restores the real union type it actually holds. @@ -3513,9 +3519,12 @@ } // Any other failure mode — network, validation, 500, etc. if (!stillCurrent()) return; - saveStatus = 'idle'; console.error('Failed to save field:', e); toastStore.show('Failed to save', 'error'); + } finally { + // EVERY path, the superseded early returns included: a superseded + // write that never settled would pin 'saving' for the item (BUG-3044). + saves.settle(saveTok); } } @@ -3637,7 +3646,7 @@ async function flushTagSaver(saver: TagSaver) { saver.running = true; - saveStatus = 'saving'; + const saveTok = saves.begin(); try { while (saver.pending !== null) { // NO freeze recheck in the drain (BUG-2263): tag editing is invisible @@ -3680,7 +3689,7 @@ item = adoptServerItem(fresh); } } - if (item && item.id === saver.itemId) showSaved(); + if (item && item.id === saver.itemId) showSaved(saveTok); // A newly-created tag should appear in autocomplete next time. void loadTagSuggestions(saver.ws); } catch (e) { @@ -3690,10 +3699,10 @@ if (item && item.id === saver.itemId) { // Revert to the last server-confirmed tags, not the optimistic set. item = { ...item, tags: saver.confirmed }; - saveStatus = 'idle'; toastStore.show('Failed to save', 'error'); } } finally { + saves.settle(saveTok); saver.running = false; // Drop the entry once fully drained so the map doesn't accumulate // stale savers; guard on identity so a newer saver isn't evicted. @@ -4059,7 +4068,7 @@ // coordinator P1). const targetItem = item; const gen = loadGeneration; - saveStatus = 'saving'; + const saveTok = saves.begin(); try { const update: Record = {}; if (userId) { @@ -4070,11 +4079,12 @@ const updated = await api.items.update(wsSlug, targetItem.id, update); if (gen !== loadGeneration || item?.id !== targetItem.id) return; item = withInflightTags(updated); - showSaved(); + showSaved(saveTok); } catch { if (gen !== loadGeneration || item?.id !== targetItem.id) return; - saveStatus = 'idle'; toastStore.show('Failed to update assignment', 'error'); + } finally { + saves.settle(saveTok); } } @@ -4085,7 +4095,7 @@ // coordinator P1). const targetItem = item; const gen = loadGeneration; - saveStatus = 'saving'; + const saveTok = saves.begin(); try { const update: Record = {}; if (roleId) { @@ -4096,11 +4106,12 @@ const updated = await api.items.update(wsSlug, targetItem.id, update); if (gen !== loadGeneration || item?.id !== targetItem.id) return; item = withInflightTags(updated); - showSaved(); + showSaved(saveTok); } catch { if (gen !== loadGeneration || item?.id !== targetItem.id) return; - saveStatus = 'idle'; toastStore.show('Failed to update role', 'error'); + } finally { + saves.settle(saveTok); } } @@ -4157,7 +4168,7 @@ // saved/error feedback onto a switched-in item (Codex). const reqItem = item; const gen = loadGeneration; - saveStatus = 'saving'; + const saveTok = saves.begin(); // Set lastSaveTime BEFORE the API call so the SSE guard works // even if the SSE event arrives before the response. editorStore.setLastSaveTime(Date.now()); @@ -4177,14 +4188,13 @@ localLastSaveTime = Date.now(); editorStore.setDirty(false); localDirty = false; - showSaved(); + showSaved(saveTok); }).catch((e) => { if (switchedAway(reqItem, gen)) return; // A newer write of this tab already landed and owns the outcome. if (isSupersededWriteError(e)) return; - saveStatus = 'idle'; toastStore.show('Failed to save content', 'error'); - }); + }).finally(() => saves.settle(saveTok)); }, 1200); } @@ -4350,8 +4360,11 @@ const isForegroundCurrent = (): boolean => !keepalive && !!item && item.id === itemId && genAtFlush === loadGeneration; + // Only a foreground flush owns the indicator; a keepalive flush after + // navigation begins nothing, so it settles nothing. + let saveTok: SaveToken | null = null; if (isForegroundCurrent()) { - saveStatus = 'saving'; + saveTok = saves.begin(); localLastSaveTime = Date.now(); // Singleton editorStore is owned by the ACTIVE side only: a FROZEN // (peeking) instance stamps its own shadow above but must not touch the @@ -4401,7 +4414,7 @@ editorStore.setLastSaveTime(Date.now()); editorStore.setDirty(false); } - showSaved(); + if (saveTok) showSaved(saveTok); } return 'flushed'; } catch (e) { @@ -4413,10 +4426,12 @@ // every arm, foreground or not. if (e instanceof PadApiError && e.code === 'superseded_write') return 'skipped'; if (isForegroundCurrent()) { - saveStatus = 'idle'; toastStore.show('Failed to save content', 'error'); } return 'failed'; + } finally { + // The force_refresh and superseded returns included (BUG-3044). + if (saveTok) saves.settle(saveTok); } }, }); @@ -4486,7 +4501,7 @@ .catch(() => {}); } // Debounced raw save. - saveStatus = 'saving'; + const saveTok = saves.begin(); editorStore.setLastSaveTime(Date.now()); localLastSaveTime = Date.now(); // Raw mode: content is already in storage format (with [[wiki links]]) @@ -4514,7 +4529,7 @@ rawContentSaver.clearPending(); editorStore.setDirty(false); localDirty = false; - showSaved(); + showSaved(saveTok); } else { // Newer pending edit; keep local content, adopt // server-side metadata only. The next debounce @@ -4525,9 +4540,8 @@ if (!item || item.id !== reqItemId || genAtSave !== loadGeneration) return; // A newer write of this tab already landed and owns the outcome. if (isSupersededWriteError(e)) return; - saveStatus = 'idle'; toastStore.show('Failed to save content', 'error'); - }); + }).finally(() => saves.settle(saveTok)); }, }); @@ -4598,13 +4612,19 @@ const reqItemId = item.id; const genAtFlush = loadGeneration; let lastError = false; + // ONE indicator write for the whole drain, begun before its first await + // and settled in the finally below (BUG-3044). That is the truthful + // status for a drain: it IS saving until its last PATCH settles, so the + // indicator stays 'saving' across every pass and shows "Saved" once, when + // the drain empties, as it always did. A token per pass would flicker + // between passes while the drain is still sending. + const saveTok = saves.begin(); try { for (let i = 0; i < RAW_FLUSH_DRAIN_CAP; i++) { const markdown: string | null = rawContentSaver.pending; if (markdown === null) break; rawContentSaver.cancel(); try { - saveStatus = 'saving'; editorStore.setLastSaveTime(Date.now()); localLastSaveTime = Date.now(); const updated = await api.items.update(wsSlug, reqItemId, { content: markdown, client_write: nextClientWrite() }); @@ -4652,7 +4672,6 @@ if (rawContentSaver.pending === markdown) rawContentSaver.clearPending(); continue; } - saveStatus = 'idle'; toastStore.show('Failed to save content', 'error'); lastError = true; break; @@ -4661,11 +4680,12 @@ if (!lastError && rawContentSaver.pending === null) { editorStore.setDirty(false); localDirty = false; - showSaved(); + showSaved(saveTok); return true; } return false; } finally { + saves.settle(saveTok); rawFlushInFlight = false; } } diff --git a/web/src/lib/components/items/itemDetailIdentityFence.round6.json b/web/src/lib/components/items/itemDetailIdentityFence.round6.json index 007ddaa7d..bbe8d1cca 100644 --- a/web/src/lib/components/items/itemDetailIdentityFence.round6.json +++ b/web/src/lib/components/items/itemDetailIdentityFence.round6.json @@ -6,8 +6,8 @@ "class": 4, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\twindow.addEventListener('focus', (() => () => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t})());\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\twindow.addEventListener('focus', (() => () => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t})());\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -21,8 +21,8 @@ "class": 4, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\twindow.addEventListener('focus', (() => {\n\t\t\t\treturn () => {\n\t\t\t\t\titem = withInflightTags(updated);\n\t\t\t\t};\n\t\t\t})());\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\twindow.addEventListener('focus', (() => {\n\t\t\t\treturn () => {\n\t\t\t\t\titem = withInflightTags(updated);\n\t\t\t\t};\n\t\t\t})());\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -36,8 +36,8 @@ "class": 4, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\t(window.onfocus = updated ? () => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t} : null, tick)();\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\t(window.onfocus = updated ? () => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t} : null, tick)();\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -49,8 +49,8 @@ "class": 2, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tconst applyTitleNow = (next = (item = withInflightTags(updated))) => next;\n\t\t\tapplyTitleNow();\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tconst applyTitleNow = (next = (item = withInflightTags(updated))) => next;\n\t\t\tapplyTitleNow();\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -62,8 +62,8 @@ "class": 2, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\twindow.addEventListener('focus', (_e: Event, _n = (item = withInflightTags(updated))) => {});\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\twindow.addEventListener('focus', (_e: Event, _n = (item = withInflightTags(updated))) => {});\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -75,8 +75,8 @@ "class": 2, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tconst [_first = (item = withInflightTags(updated))] = [];\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tconst [_first = (item = withInflightTags(updated))] = [];\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -88,8 +88,8 @@ "class": 3, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tfor (item of [updated]);\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tfor (item of [updated]);\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -101,8 +101,8 @@ "class": 1, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tswitch ((loadGeneration = gen), 0) {\n\t\t\t\tcase 1:\n\t\t\t\t\tlet loadGeneration = 0;\n\t\t\t\t\tvoid loadGeneration;\n\t\t\t}\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tswitch ((loadGeneration = gen), 0) {\n\t\t\t\tcase 1:\n\t\t\t\t\tlet loadGeneration = 0;\n\t\t\t\t\tvoid loadGeneration;\n\t\t\t}\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -114,8 +114,8 @@ "class": 5, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tuntrack(loadData);\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tuntrack(loadData);\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -128,8 +128,8 @@ "class": 1, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tconst console = { warn: api.items.update };\n\t\t\tconsole.warn(wsSlug, targetItem.id, { title: 'stale' });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tconst console = { warn: api.items.update };\n\t\t\tconsole.warn(wsSlug, targetItem.id, { title: 'stale' });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -141,8 +141,8 @@ "class": 1, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tconst untrack = window.addEventListener.bind(window, 'focus');\n\t\t\tuntrack(() => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t});\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tconst untrack = window.addEventListener.bind(window, 'focus');\n\t\t\tuntrack(() => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t});\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -154,8 +154,8 @@ "class": 1, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tconst identityHeld = (_c: number) => true;\n\t\t\tif (!identityHeld(gen)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tconst identityHeld = (_c: number) => true;\n\t\t\tif (!identityHeld(gen)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -167,8 +167,8 @@ "class": 1, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tconst loadGeneration = gen;\n\t\t\tif (gen !== loadGeneration) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tconst loadGeneration = gen;\n\t\t\tif (gen !== loadGeneration) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -180,12 +180,12 @@ "class": 6, "subs": [ [ - "\t\tconst gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", - "\t\tconst gen = loadGeneration;\n\t\tconst titleStillHere = gen === loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" + "\t\tconst gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tconst gen = loadGeneration;\n\t\tconst titleStillHere = gen === loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" ], [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (!titleStillHere) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (!titleStillHere) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -198,12 +198,12 @@ "class": 6, "subs": [ [ - "\t\tconst gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", - "\t\tconst gen = loadGeneration;\n\t\tconst sameGen = (g: number, _other: number) => g === loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" + "\t\tconst gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tconst gen = loadGeneration;\n\t\tconst sameGen = (g: number, _other: number) => g === loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" ], [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (!sameGen(loadGeneration, gen)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (!sameGen(loadGeneration, gen)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -216,8 +216,8 @@ "class": 6, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (!identityHeld(gen)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (!identityHeld(gen)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -230,12 +230,12 @@ "class": 6, "subs": [ [ - "\t\tconst gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", - "\t\tconst gen = loadGeneration;\n\t\tconst identity = captureIdentity();\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" + "\t\tconst gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tconst gen = loadGeneration;\n\t\tconst identity = captureIdentity();\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" ], [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (identity !== loadGeneration) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (identity !== loadGeneration) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -248,12 +248,12 @@ "class": 2, "subs": [ [ - "\t\tconst gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", - "\t\tconst gen = loadGeneration;\n\t\tconst freshTitleGen = (g: number, _h = (g = loadGeneration)) => g === loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" + "\t\tconst gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tconst gen = loadGeneration;\n\t\tconst freshTitleGen = (g: number, _h = (g = loadGeneration)) => g === loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" ], [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (!freshTitleGen(gen)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (!freshTitleGen(gen)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -265,12 +265,12 @@ "class": 6, "subs": [ [ - "\t\tconst gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", - "\t\tconst gen = loadGeneration;\n\t\tconst titleFresh = () => !!api.items.update(wsSlug, targetItem.id, { title: 'x' }) && gen === loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" + "\t\tconst gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tconst gen = loadGeneration;\n\t\tconst titleFresh = () => !!api.items.update(wsSlug, targetItem.id, { title: 'x' }) && gen === loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" ], [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (!titleFresh() || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (!titleFresh() || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -284,8 +284,8 @@ "class": 6, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (authStore.identityEpoch !== { epoch: authStore.identityEpoch }.epoch) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (authStore.identityEpoch !== { epoch: authStore.identityEpoch }.epoch) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -298,8 +298,8 @@ "class": 6, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (!identityHeld(captureIdentity())) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (!identityHeld(captureIdentity())) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -312,8 +312,8 @@ "class": 6, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (!identityHeld(authStore.identityEpoch)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (!identityHeld(authStore.identityEpoch)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -326,12 +326,12 @@ "class": 6, "subs": [ [ - "\t\tconst gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", - "\t\tconst gen = loadGeneration;\n\t\tlet titleOk = gen === loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" + "\t\tconst gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tconst gen = loadGeneration;\n\t\tlet titleOk = gen === loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" ], [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\ttitleOk = true;\n\t\t\tif (!titleOk) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\ttitleOk = true;\n\t\t\tif (!titleOk) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -344,8 +344,8 @@ "class": 6, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (!identityHeld(identityEpochAtLoad)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (!identityHeld(identityEpochAtLoad)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -358,12 +358,12 @@ "class": 3, "subs": [ [ - "\t\tconst gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", - "\t\tlet gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" + "\t\tconst gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tlet gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" ], [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tfor (gen of [loadGeneration]);\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tfor (gen of [loadGeneration]);\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -375,8 +375,8 @@ "class": 7, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n\t\t\tconst titles = api.items.titleStream(wsSlug, targetItem.id);\n\t\t\ttry {\n\t\t\t\tfor await (const t of titles) void t;\n\t\t\t} catch {\n\t\t\t\titem = null;\n\t\t\t}\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n\t\t\tconst titles = api.items.titleStream(wsSlug, targetItem.id);\n\t\t\ttry {\n\t\t\t\tfor await (const t of titles) void t;\n\t\t\t} catch {\n\t\t\t\titem = null;\n\t\t\t}\n" ] ], "refuses": [ @@ -389,8 +389,8 @@ "class": 7, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n\t\t\tconst titles = api.items.titleStream(wsSlug, targetItem.id);\n\t\t\ttry {\n\t\t\t\tfor await (const t of titles) void t;\n\t\t\t} finally {\n\t\t\t\titem = null;\n\t\t\t}\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n\t\t\tconst titles = api.items.titleStream(wsSlug, targetItem.id);\n\t\t\ttry {\n\t\t\t\tfor await (const t of titles) void t;\n\t\t\t} finally {\n\t\t\t\titem = null;\n\t\t\t}\n" ] ], "refuses": [ @@ -403,8 +403,8 @@ "class": 5, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tconst applyLater = ((_s: TemplateStringsArray) => () => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t})`x`;\n\t\t\twindow.addEventListener('focus', applyLater);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tconst applyLater = ((_s: TemplateStringsArray) => () => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t})`x`;\n\t\t\twindow.addEventListener('focus', applyLater);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -416,12 +416,12 @@ "class": 5, "subs": [ [ - "\tfunction showSaved() {\n", - "\tlet pendingTitleItem: Item | null = null;\n\tfunction applyPendingTitle() {\n\t\tif (pendingTitleItem) item = pendingTitleItem;\n\t}\n\n\tfunction showSaved() {\n" + "\tfunction showSaved(saveTok: SaveToken) {\n", + "\tlet pendingTitleItem: Item | null = null;\n\tfunction applyPendingTitle() {\n\t\tif (pendingTitleItem) item = pendingTitleItem;\n\t}\n\n\tfunction showSaved(saveTok: SaveToken) {\n" ], [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tpendingTitleItem = withInflightTags(updated);\n\t\t\twindow.addEventListener('focus', updated ? applyPendingTitle : showSaved, { once: true });\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tpendingTitleItem = withInflightTags(updated);\n\t\t\twindow.addEventListener('focus', updated ? applyPendingTitle : showSaved, { once: true });\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -435,8 +435,8 @@ "class": 5, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tconst applyTitleLater = () => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t};\n\t\t\twindow.addEventListener('focus', applyTitleLater as EventListener);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tconst applyTitleLater = () => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t};\n\t\t\twindow.addEventListener('focus', applyTitleLater as EventListener);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -450,8 +450,8 @@ "class": 5, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tconst applyTitleLater = () => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t};\n\t\t\twindow.addEventListener('focus', applyTitleLater!);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tconst applyTitleLater = () => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t};\n\t\t\twindow.addEventListener('focus', applyTitleLater!);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -465,8 +465,8 @@ "class": 5, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tfunction* applyTitleGen() {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t}\n\t\t\tconst titleIt = applyTitleGen();\n\t\t\twindow.addEventListener('focus', titleIt.next.bind(titleIt));\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tfunction* applyTitleGen() {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t}\n\t\t\tconst titleIt = applyTitleGen();\n\t\t\twindow.addEventListener('focus', titleIt.next.bind(titleIt));\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -478,8 +478,8 @@ "class": 1, "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\ttry {\n\t\t\t\tthrow loadGeneration;\n\t\t\t} catch (gen) {\n\t\t\t\tif (gen !== loadGeneration) return;\n\t\t\t\titem = updated;\n\t\t\t}\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\ttry {\n\t\t\t\tthrow loadGeneration;\n\t\t\t} catch (gen) {\n\t\t\t\tif (gen !== loadGeneration) return;\n\t\t\t\titem = updated;\n\t\t\t}\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ diff --git a/web/src/lib/components/items/itemDetailIdentityFence.round7.json b/web/src/lib/components/items/itemDetailIdentityFence.round7.json index caba01992..ee2f1dd81 100644 --- a/web/src/lib/components/items/itemDetailIdentityFence.round7.json +++ b/web/src/lib/components/items/itemDetailIdentityFence.round7.json @@ -60,8 +60,8 @@ "class": "F1", "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n\t\t\twindow.addEventListener('focus', async () => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t}, { once: true });\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n\t\t\twindow.addEventListener('focus', async () => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t}, { once: true });\n" ] ], "nested": [ @@ -80,8 +80,8 @@ "class": "F1", "subs": [ [ - "\tfunction showSaved() {\n", - "\tfunction showSaved() {\n\t\twindow.addEventListener('focus', async () => {\n\t\t\tsaveStatus = 'idle';\n\t\t}, { once: true });\n" + "\tfunction showSaved(saveTok: SaveToken) {\n", + "\tfunction showSaved(saveTok: SaveToken) {\n\t\twindow.addEventListener('focus', async () => {\n\t\t\tsaveStatus = 'idle';\n\t\t}, { once: true });\n" ] ], "nested": [ @@ -158,8 +158,8 @@ "class": "F3", "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tconst current = gen === loadGeneration && item?.id === targetItem.id;\n\t\t\tif (!current) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n\t\t\tfor await (const t of api.items.titleStream(wsSlug, targetItem.id)) {\n\t\t\t\tif (!current) return;\n\t\t\t\ttitleDraft = t;\n\t\t\t}\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tconst current = gen === loadGeneration && item?.id === targetItem.id;\n\t\t\tif (!current) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n\t\t\tfor await (const t of api.items.titleStream(wsSlug, targetItem.id)) {\n\t\t\t\tif (!current) return;\n\t\t\t\ttitleDraft = t;\n\t\t\t}\n" ] ], "refuses": [ @@ -172,8 +172,8 @@ "class": "F3", "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tconst current = gen === loadGeneration && item?.id === targetItem.id;\n\t\t\tif (!current) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n\t\t\tconst titles = api.items.titleStream(wsSlug, targetItem.id);\n\t\t\ttry {\n\t\t\t\tfor await (const t of titles) void t;\n\t\t\t} catch {\n\t\t\t\tif (current) toastStore.show('Title stream failed', 'error');\n\t\t\t}\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tconst current = gen === loadGeneration && item?.id === targetItem.id;\n\t\t\tif (!current) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n\t\t\tconst titles = api.items.titleStream(wsSlug, targetItem.id);\n\t\t\ttry {\n\t\t\t\tfor await (const t of titles) void t;\n\t\t\t} catch {\n\t\t\t\tif (current) toastStore.show('Title stream failed', 'error');\n\t\t\t}\n" ] ], "refuses": [ @@ -216,12 +216,12 @@ "\t\t\tif (ws === wsSlug && identityHeld(startedAt)) tagSuggestions = [];\n" ], [ - "\t\tconst gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", - "\t\tconst gen = loadGeneration;\n\t\tconst startedAt = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" + "\t\tconst gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tconst gen = loadGeneration;\n\t\tconst startedAt = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" ], [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (!identityHeld(startedAt)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (!identityHeld(startedAt)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ], "refuses": [ @@ -234,12 +234,12 @@ "class": "F8", "subs": [ [ - "\t\tconst gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", - "\t\tconst gen = loadGeneration;\n\t\tconst sameGen = (g: number) => g === loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" + "\t\tconst gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tconst gen = loadGeneration;\n\t\tconst sameGen = (g: number) => g === loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" ], [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (!sameGen(loadGeneration)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (!sameGen(loadGeneration)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ], [ "\t\t\tconst fresh = await submitWithOCC(false);\n\t\t\tif (!stillCurrent()) return;\n", @@ -256,12 +256,12 @@ "class": "F8", "subs": [ [ - "\t\tconst gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", - "\t\tconst gen = loadGeneration;\n\t\tconst current = () => gen === loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" + "\t\tconst gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tconst gen = loadGeneration;\n\t\tconst current = () => gen === loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" ], [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (!current) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (!current) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ], [ "\t\t\tconst fresh = await submitWithOCC(false);\n\t\t\tif (!stillCurrent()) return;\n", @@ -281,8 +281,8 @@ "model": "shadowing: a callback param named like a capture is read as that capture; params stay exempt from the shadowing refusal because switchedAway(targetItem, gen) and its callers need the name, and reusing a capture's name for a different value is the shadowing the header puts outside the model", "subs": [ [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n\t\t\tdialogs.onGeneration((gen: number) => {\n\t\t\t\tif (gen !== loadGeneration) return;\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t});\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n\t\t\tdialogs.onGeneration((gen: number) => {\n\t\t\t\tif (gen !== loadGeneration) return;\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t});\n" ] ] }, @@ -292,12 +292,12 @@ "model": "contrived fence: a helper whose whole body is a fence boolean computed before the await is a fence that cannot lapse; no author trying to comply wraps a pre-await boolean in a zero-argument function to read it later", "subs": [ [ - "\t\tconst gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", - "\t\tconst gen = loadGeneration;\n\t\tconst startedCurrent = gen === loadGeneration;\n\t\tconst stillHere = () => startedCurrent;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" + "\t\tconst gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tconst gen = loadGeneration;\n\t\tconst startedCurrent = gen === loadGeneration;\n\t\tconst stillHere = () => startedCurrent;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title" ], [ - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - "{ title: titleDraft.trim() });\n\t\t\tif (!stillHere()) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + "{ title: titleDraft.trim() });\n\t\t\tif (!stillHere()) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ] ] } diff --git a/web/src/lib/components/items/itemDetailIdentityFence.round8.json b/web/src/lib/components/items/itemDetailIdentityFence.round8.json index f033169f2..129363339 100644 --- a/web/src/lib/components/items/itemDetailIdentityFence.round8.json +++ b/web/src/lib/components/items/itemDetailIdentityFence.round8.json @@ -19,8 +19,8 @@ "id": "B showSaved re-registers itself by name as a visibilitychange listener", "subs": [ [ - "\tfunction showSaved() {\n\t\tsaveStatus = 'saved';\n", - "\tfunction showSaved() {\n\t\t// Nobody sees the indicator on a hidden tab: show it when they come back.\n\t\tif (document.hidden) {\n\t\t\tdocument.addEventListener('visibilitychange', showSaved, { once: true });\n\t\t\treturn;\n\t\t}\n\t\tsaveStatus = 'saved';\n" + "\tfunction showSaved(saveTok: SaveToken) {\n\t\tsaves.succeed(saveTok);\n", + "\tfunction showSaved(saveTok: SaveToken) {\n\t\t// Nobody sees the indicator on a hidden tab: show it when they come back.\n\t\tif (document.hidden) {\n\t\t\tdocument.addEventListener('visibilitychange', showSaved, { once: true });\n\t\t\treturn;\n\t\t}\n\t\tsaves.succeed(saveTok);\n" ] ], "refuses": [ @@ -31,8 +31,8 @@ "id": "B2 the same through an arrow that calls showSaved", "subs": [ [ - "\tfunction showSaved() {\n\t\tsaveStatus = 'saved';\n", - "\tfunction showSaved() {\n\t\tif (document.hidden) {\n\t\t\tdocument.addEventListener('visibilitychange', () => showSaved(), { once: true });\n\t\t\treturn;\n\t\t}\n\t\tsaveStatus = 'saved';\n" + "\tfunction showSaved(saveTok: SaveToken) {\n\t\tsaves.succeed(saveTok);\n", + "\tfunction showSaved(saveTok: SaveToken) {\n\t\tif (document.hidden) {\n\t\t\tdocument.addEventListener('visibilitychange', () => showSaved(saveTok), { once: true });\n\t\t\treturn;\n\t\t}\n\t\tsaves.succeed(saveTok);\n" ] ], "refuses": [ @@ -95,8 +95,8 @@ "id": "G a function-valued let rebound in an $effect, called above the fence", "subs": [ [ - "\tfunction showSaved() {\n", - "\tlet applyTitle = (next: Item): void => {\n\t\tvoid next;\n\t};\n\t$effect(() => {\n\t\tapplyTitle = (next: Item) => {\n\t\t\titem = withInflightTags(next);\n\t\t};\n\t});\n\n\tfunction showSaved() {\n" + "\tfunction showSaved(saveTok: SaveToken) {\n", + "\tlet applyTitle = (next: Item): void => {\n\t\tvoid next;\n\t};\n\t$effect(() => {\n\t\tapplyTitle = (next: Item) => {\n\t\t\titem = withInflightTags(next);\n\t\t};\n\t});\n\n\tfunction showSaved(saveTok: SaveToken) {\n" ], [ "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n", @@ -152,8 +152,8 @@ "model": "a helper already being inlined is not walked again as the callback it hands itself to", "subs": [ [ - "\tfunction showSaved() {\n\t\tsaveStatus = 'saved';\n", - "\tfunction showSaved() {\n\t\t// Nobody sees the indicator on a hidden tab: show it when they come back.\n\t\tif (document.hidden) {\n\t\t\tdocument.addEventListener('visibilitychange', showSaved, { once: true });\n\t\t\treturn;\n\t\t}\n\t\tsaveStatus = 'saved';\n" + "\tfunction showSaved(saveTok: SaveToken) {\n\t\tsaves.succeed(saveTok);\n", + "\tfunction showSaved(saveTok: SaveToken) {\n\t\t// Nobody sees the indicator on a hidden tab: show it when they come back.\n\t\tif (document.hidden) {\n\t\t\tdocument.addEventListener('visibilitychange', showSaved, { once: true });\n\t\t\treturn;\n\t\t}\n\t\tsaves.succeed(saveTok);\n" ] ] }, @@ -162,8 +162,8 @@ "model": "the same re-entry through a literal that calls the helper", "subs": [ [ - "\tfunction showSaved() {\n\t\tsaveStatus = 'saved';\n", - "\tfunction showSaved() {\n\t\tif (document.hidden) {\n\t\t\tdocument.addEventListener('visibilitychange', () => showSaved(), { once: true });\n\t\t\treturn;\n\t\t}\n\t\tsaveStatus = 'saved';\n" + "\tfunction showSaved(saveTok: SaveToken) {\n\t\tsaves.succeed(saveTok);\n", + "\tfunction showSaved(saveTok: SaveToken) {\n\t\tif (document.hidden) {\n\t\t\tdocument.addEventListener('visibilitychange', () => showSaved(saveTok), { once: true });\n\t\t\treturn;\n\t\t}\n\t\tsaves.succeed(saveTok);\n" ] ] }, @@ -216,8 +216,8 @@ "model": "a helper is declared once, not bound once: a let rebound outside every unit is inlined with its declared body", "subs": [ [ - "\tfunction showSaved() {\n", - "\tlet applyTitle = (next: Item): void => {\n\t\tvoid next;\n\t};\n\t$effect(() => {\n\t\tapplyTitle = (next: Item) => {\n\t\t\titem = withInflightTags(next);\n\t\t};\n\t});\n\n\tfunction showSaved() {\n" + "\tfunction showSaved(saveTok: SaveToken) {\n", + "\tlet applyTitle = (next: Item): void => {\n\t\tvoid next;\n\t};\n\t$effect(() => {\n\t\tapplyTitle = (next: Item) => {\n\t\t\titem = withInflightTags(next);\n\t\t};\n\t});\n\n\tfunction showSaved(saveTok: SaveToken) {\n" ], [ "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n", diff --git a/web/src/lib/components/items/itemDetailIdentityFenceAst.test.ts b/web/src/lib/components/items/itemDetailIdentityFenceAst.test.ts index 457448c4f..cae34c0f8 100644 --- a/web/src/lib/components/items/itemDetailIdentityFenceAst.test.ts +++ b/web/src/lib/components/items/itemDetailIdentityFenceAst.test.ts @@ -85,19 +85,40 @@ interface Row { reviewed?: string; } +/** + * Why a save-indicator settle may run after an unfenced await (BUG-3044, lead + * ruling): it MUST run on every path, the item-switch and superseded returns + * included, or the outstanding count pins 'saving' and every SSE gate with it. + * What keeps a stale settle from moving the NEXT item's indicator is the + * tracker's epoch, which `saves.reset()` bumps at load. This analysis cannot + * see that and is deliberately not taught to trust it; the claim is pinned by + * saveTracker.svelte.test.ts 'a settle from before a reset (item switch) is + * ignored', which a reset that does not bump the epoch fails. + */ +const SETTLE_WHY = + "save-indicator settle: runs on every path by design; a stale token is a no-op by the tracker's epoch (saveTracker.svelte.test.ts 'a settle from before a reset (item switch) is ignored')"; + +/** The same, appended to the reason of a row whose own `finally` settles. */ +const SETTLE_NOTE = `; its finally settles the save indicator (${SETTLE_WHY})`; + +/** The collab flush's `save` callback, exactly (`codeOf`): the settle allowance covers this code and nothing else. */ +const COLLAB_SAVE_CODE = + "async ({ ws, itemId, toSave, keepalive }) => { const genAtFlush = loadGeneration; const isForegroundCurrent = (): boolean => !keepalive && !!item && item.id === itemId && genAtFlush === loadGeneration; let saveTok: SaveToken | null = null; if (isForegroundCurrent()) { saveTok = saves.begin(); localLastSaveTime = Date.now(); // Singleton editorStore is owned by the ACTIVE side only: a FROZEN // (peeking) instance stamps its own shadow above but must not touch the // singleton (PLAN-2179 DR-2 / TASK-2181). `saveStatus` is per-instance. if (!peeking) editorStore.setLastSaveTime(Date.now()); } try { // Pass the provider's per-tab op-log cursor (TASK-1319) so the // server can advance the GC watermark when this tab is caught // up to MAX(op-log.id). When the cursor is below MAX (peer ops // not yet applied here) the server leaves the watermark // untouched — the GC sweeper must not delete rows this markdown // doesn't reflect. The cursor reflects the provider tracking // THIS item; if a navigation has already swapped to another // item the foreground flush path bails earlier on dedupe and // this code doesn't run. const opLogCursor = collabProvider && collabProvider.itemID === itemId ? collabProvider.lastOpLogID : undefined; await api.items.flushCollabContent(ws, itemId, toSave, { keepalive, opLogCursor, // BUG-3080: two teardown flushes are both dispatched before // either answers, so only the server can order them — by // this stamp. Deliberately NO expected_seq here: both would // carry the same one, and the older landing first would get // the NEWER refused. clientWrite: nextClientWrite(), }); // Post-await force_refresh check: a force_refresh frame can // arrive WHILE the PATCH is in flight (server already accepted, // items.content already overwritten — the server-side gate // covers the happens-before case where MIN had advanced past // our cursor by the time the request landed). Refuse to record // this flush as authoritative locally — returning 'skipped' // stops the flusher seeding lastFlushedContent from a // known-stale base and skips the saveStatus update. Per Codex // round 10 [P1]. if (forceRefreshInFlight) return 'skipped'; if (isForegroundCurrent()) { localLastSaveTime = Date.now(); localDirty = false; // Singleton editorStore: active side only (PLAN-2179 / TASK-2181) — // a frozen preview's remote-op flush updates only its shadow above. if (!peeking) { editorStore.setLastSaveTime(Date.now()); editorStore.setDirty(false); } if (saveTok) showSaved(saveTok); } return 'flushed'; } catch (e) { // Superseded (BUG-3080): a newer flush of this tab already // landed. Not an error, and not recorded as the last flushed // content either — this body is not what the server holds. // Classified inline rather than by a call: this catch runs after an // await with no fence of its own, and the answer is needed on // every arm, foreground or not. if (e instanceof PadApiError && e.code === 'superseded_write') return 'skipped'; if (isForegroundCurrent()) { toastStore.show('Failed to save content', 'error'); } return 'failed'; } finally { // The force_refresh and superseded returns included (BUG-3044). if (saveTok) saves.settle(saveTok); } }"; + /** Top-level `async function` declarations, by name. */ const ASYNC_FUNCTIONS: Record = { adoptOrConvergeToLiveCollection: { reviewed: '5a647061f75f', why: 'myGen against loadGeneration before adopting' }, reconcileCollectionSegment: { reviewed: 'dbb36caf0452', why: 'identityHeld after the list fetch; retags and navigates only under it' }, jumpToSection: { reviewed: '17407aeb431b', why: 'switches this instance\'s tab and scrolls to an anchor', may: ['document.getElementById', 'document.getElementById(anchorId).scrollIntoView'] }, ensureGraphComp: { reviewed: 'c1565cfb8a18', why: 'lazy-loads a component module into this instance', may: ['ItemGraphComp', 'graphLoadError'] }, - handleCopyRef: { reviewed: 'fb3adcaf167a', why: 'switchedAway before the copied flag' }, - loadData: { reviewed: '8ed3c63b1d7d', why: 'IS the load: myGen against loadGeneration after every await; the BUG-3198 re-read of a changed item runs after the install, keyed on the installed item id, and fences itself on itemGen' }, + handleCopyRef: { reviewed: 'a5e0a0394fcc', why: 'switchedAway before the copied flag' }, + loadData: { reviewed: '8eb393047b3e', why: 'IS the load: myGen against loadGeneration after every await; the BUG-3198 re-read of a changed item runs after the install, keyed on the installed item id, and fences itself on itemGen' }, startEditTitle: { reviewed: '17f04352d420', why: 'focuses and sizes the input it opened synchronously', may: ['el', 'titleInputEl.focus', 'titleInputEl.setSelectionRange'] }, - saveTitle: { reviewed: 'ba5a2acf0d81', why: 'gen against loadGeneration on both arms, and again after the tick that resizes a reopened editor (BUG-3115)' }, + saveTitle: { reviewed: 'cef019550a0a', why: 'gen against loadGeneration on both arms, and again after the tick that resizes a reopened editor (BUG-3115)' + SETTLE_NOTE, may: ['saves.settle'] }, updateField: { - reviewed: '4ae2212df0a5', - why: 'stillCurrent() on every arm, the OCC refetch and the open-children confirm', + reviewed: '55dd4fdc7824', + why: 'stillCurrent() on every arm, the OCC refetch and the open-children confirm' + SETTLE_NOTE, + may: ['saves.settle'], callbacks: { 'submitOrderedOCC({send})': { may: ['api.items.update'], @@ -117,9 +138,9 @@ const ASYNC_FUNCTIONS: Record = { }, }, flushTagSaver: { - reviewed: 'c2b3dd381073', - why: 'identityHeld(saver.epoch) before every commit and send (each batch goes through sendTagBatch, which fences its own conflict path); the unfenced writes are to this burst\'s own identity-stamped record, and the finally deletes that record only if the registry still holds it (the get)', - may: ['saver', 'tagSavers.get', 'tagSavers.delete'], + reviewed: '310eef4ca57e', + why: 'identityHeld(saver.epoch) before every commit and send (each batch goes through sendTagBatch, which fences its own conflict path); the unfenced writes are to this burst\'s own identity-stamped record, and the finally deletes that record only if the registry still holds it (the get)' + SETTLE_NOTE, + may: ['saves.settle', 'saver', 'tagSavers.get', 'tagSavers.delete'], }, // BUG-3143: one tag batch, re-derived and retried on a conflict. Writes // only this burst's own record (confirmed, token), like flushTagSaver. @@ -134,12 +155,12 @@ const ASYNC_FUNCTIONS: Record = { loadTagSuggestions: { reviewed: 'b03a62bf7294', why: 'identityHeld after the fetch; the identity listener re-runs it' }, stampSourceUrl: { reviewed: 'b9d3f9d400ae', why: 'switchedAway on both arms' }, refreshFromSource: { reviewed: '205f77aba079', why: 'switchedAway on every arm; the frozen-editor notice (BUG-2177) is after that check' }, - updateAssignedUser: { reviewed: '86f060522de1', why: 'gen against loadGeneration on both arms' }, - updateAgentRole: { reviewed: 'ae4f320cdbab', why: 'gen against loadGeneration on both arms' }, + updateAssignedUser: { reviewed: '5e4128e45ae7', why: 'gen against loadGeneration on both arms' + SETTLE_NOTE, may: ['saves.settle'] }, + updateAgentRole: { reviewed: '9b0a6af92cc2', why: 'gen against loadGeneration on both arms' + SETTLE_NOTE, may: ['saves.settle'] }, flushRawIfPending: { - reviewed: '0420c7e37b44', - why: 'genAtFlush against loadGeneration after each PATCH; the re-entrancy waiter returns state; the finally clears this drain\'s own in-flight flag', - may: ['rawFlushInFlight'], + reviewed: '425804645744', + why: 'genAtFlush against loadGeneration after each PATCH; the re-entrancy waiter returns state; the finally clears this drain\'s own in-flight flag' + SETTLE_NOTE, + may: ['saves.settle', 'rawFlushInFlight'], bareAwaits: ['await new Promise((r) => setTimeout(r, 50));'], }, refreshLinksPreservingOnFailure: { @@ -153,10 +174,10 @@ const ASYNC_FUNCTIONS: Record = { // BUG-3036: the re-read an SSE event or sync pass deferred while a save was in // flight. The SSE item_updated shape: itemGen and the item id after each await. runOwedRefresh: { reviewed: '1ede6aa08b69', why: 'destroyed, itemGen and the item id after each await, as the SSE item_updated re-read it stands in for (destroyed since BUG-3198: the load path calls it too)' }, - flushCollabBeforeRestore: { reviewed: '58150a653ea6', why: 'identityHeld before its failure toast' }, + flushCollabBeforeRestore: { reviewed: 'f9f7621c1dd2', why: 'identityHeld before its failure toast' }, closeCopyDialog: { reviewed: '1c925f081a26', why: 'restores focus after closing synchronously', may: ['paneMenuTrigger.focus'] }, closePushDialog: { reviewed: 'bda8c7529677', why: 'restores focus after closing synchronously', may: ['paneMenuTrigger.focus'] }, - flushContentBeforeCopy: { reviewed: '5bea1cb961b5', why: 'returns a boolean to the dialog' }, + flushContentBeforeCopy: { reviewed: 'd7083bf9954c', why: 'returns a boolean to the dialog' }, handleCopied: { reviewed: 'f35fb099838d', why: 'switchedAway before adopting the refreshed item' }, handleDelete: { reviewed: 'b66adcf84794', why: 'switchedAway on both arms' }, handleRestore: { reviewed: 'c3a1732554a4', why: 'switchedAway on every arm' }, @@ -187,15 +208,22 @@ interface SignedRow extends Row { /** Async functions that are not top-level declarations, in the script. */ const NESTED: SignedRow[] = [ - { body: /event\.type === 'collection_updated'/, why: 'SSE: callbackGen after the collection fetch, itemGen on item branches; a change or a migration arriving before this load installs its item is only RECORDED (BUG-3198), synchronously, before any await', reviewed: 'fd626753eaf9' }, - { body: /result\.type === 'caught_up'/, why: 'sync: callbackGen after the reconciliation, itemGen on item branches; a result arriving before this load installs its item is only RECORDED (BUG-3198), synchronously, before any await', reviewed: '30f73f3a3ece' }, - { body: /flushCollabContent\(/, why: 'collab save: isForegroundCurrent (genAtFlush) before UI feedback', reviewed: '6dba5f66f347' }, + { body: /event\.type === 'collection_updated'/, why: 'SSE: callbackGen after the collection fetch, itemGen on item branches; a change or a migration arriving before this load installs its item is only RECORDED (BUG-3198), synchronously, before any await', reviewed: '87c80cea46db' }, + { body: /result\.type === 'caught_up'/, why: 'sync: callbackGen after the reconciliation, itemGen on item branches; a result arriving before this load installs its item is only RECORDED (BUG-3198), synchronously, before any await', reviewed: 'a7ae81a0da33' }, + { + body: /flushCollabContent\(/, + in: '(top level)', + code: COLLAB_SAVE_CODE, + why: 'collab save: isForegroundCurrent (genAtFlush) before UI feedback' + SETTLE_NOTE, + reviewed: 'f197b2c9f0ef', + may: ['saves.settle'], + }, ]; /** Async functions in the markup. */ const MARKUP: SignedRow[] = [ { body: /startGen/, why: 'Rich toggle: startGen against loadGeneration after each await', reviewed: '63a9cce2ed93' }, - { body: /genAtToggle/, why: 'Markdown toggle: genAtToggle against loadGeneration after each await', reviewed: '5c5b8240c566' }, + { body: /genAtToggle/, why: 'Markdown toggle: genAtToggle against loadGeneration after each await', reviewed: '6ad6c1318246' }, ]; const collapse = (s: string) => s.replace(/\s+/g, ' '); @@ -361,28 +389,27 @@ const CONTINUATIONS: SignedRow[] = [ why: 'rename heal failure: clears only the bridge object this heal installed', reviewed: '9a33138e4937', may: ['renameOverride'], }, - { call: /^setTimeout\($/, body: /copied = false/, why: 'copy-flag reset: switchedAway', reviewed: '199047839886' }, - { call: /api\.items\.get\(wsSlug, itemSlug\)\.catch\($/, body: /./, why: 'loadData item fetch: sets a flag local to that load and re-throws', reviewed: '1567ed00227b' }, + { call: /^setTimeout\($/, body: /copied = false/, why: 'copy-flag reset: switchedAway', reviewed: '8a8d4754017f' }, + { call: /api\.items\.get\(wsSlug, itemSlug\)\.catch\($/, body: /./, why: 'loadData item fetch: sets a flag local to that load and re-throws', reviewed: 'f2267a80cece' }, { call: /^setTimeout\($/, body: /staleConnecting = true/, in: '$effect(…)', code: "() => { if (collabProvider?.state === 'connecting' && !hasEverSynced) { staleConnecting = true; } }", - why: 'connection state of this instance\'s own provider', reviewed: 'c4d9f3230509', + why: 'connection state of this instance\'s own provider', reviewed: 'b0f9f4cc5f23', may: ['staleConnecting'], }, - { call: /\.get\(refreshCtx\.wsSlug, refreshCtx\.itemId\) \.then\($/, body: /./, why: 'force-refresh fetch: refreshGen against loadGeneration', reviewed: '0a1fffba6d45' }, - { call: /forceRefreshNonce \+= 1; \}\) \.catch\($/, body: /./, why: 'force-refresh failure: refreshGen against loadGeneration', reviewed: 'e54e21b2c59d' }, + { call: /\.get\(refreshCtx\.wsSlug, refreshCtx\.itemId\) \.then\($/, body: /./, why: 'force-refresh fetch: refreshGen against loadGeneration', reviewed: '85c402dd6ca9' }, + { call: /forceRefreshNonce \+= 1; \}\) \.catch\($/, body: /./, why: 'force-refresh failure: refreshGen against loadGeneration', reviewed: 'b34c4479bb79' }, { call: /^setTimeout\($/, body: /teardownFlushed/, in: 'onBeforeUnload', code: '() => { teardownFlushed = false; }', - why: 're-arms the BUG-3005 teardown latch, itself identity-checked', reviewed: '48cada93d389', + why: 're-arms the BUG-3005 teardown latch, itself identity-checked', reviewed: 'e598de5a2771', may: ['teardownFlushed'], }, - { call: /^queueMicrotask\($/, body: /./, why: 'collab lazy seed: refuses a retired or re-identified context first', reviewed: 'bb4b5bef7eec' }, - { call: /^setTimeout\($/, body: /saveStatus/, in: 'showSaved', code: "() => { saveStatus = 'idle'; }", why: 'cosmetic save-indicator reset', reviewed: 'bd7f1439c3d2', may: ['saveStatus'] }, + { call: /^queueMicrotask\($/, body: /./, why: 'collab lazy seed: refuses a retired or re-identified context first', reviewed: '197a4a45d219' }, { call: /^tick\(\)\.then\($/, body: /./, why: 'schedules a focus frame; commits nothing itself', reviewed: 'b6f7655cb302' }, { call: /^requestAnimationFrame\($/, @@ -396,16 +423,34 @@ const CONTINUATIONS: SignedRow[] = [ call: /^setTimeout\($/, body: /content: toSave, client_write: nextClientWrite\(\) \}\)\.then/, in: 'handleContentUpdate', - why: 'content debounce: loadData clears this timer before its first await, so the callback never runs across a load', reviewed: '1302904e1805', + why: 'content debounce: loadData clears this timer before its first await, so the callback never runs across a load', reviewed: 'd05baa756ebd', startSafe: true, pin: (src, unit) => clearsBeforeFirstAwait(src, 'loadData', 'contentDebounceTimer') ?? assignedTo(src, unit, 'contentDebounceTimer'), }, - { call: /\{ content: toSave, client_write: nextClientWrite\(\) \}\)\.then\($/, body: /^\(\) =>/, why: 'content save: switchedAway', reviewed: '77120c902e52' }, - { call: /showSaved\(\); \}\)\.catch\($/, body: /./, why: 'content save failure: switchedAway', reviewed: '541348670a2a' }, - { call: /\{ keepalive: true \}\) \.then\($/, body: /./, why: 'raw keepalive save: genAtSave against loadGeneration', reviewed: 'dcdd26c60264' }, - { call: /localDirty = false; \} \}\) \.catch\($/, body: /^\(\) => \{\}$/, why: 'raw keepalive failure: empty', reviewed: '61988786e8bf' }, - { call: /reqItemId, \{ content: toSave, client_write: nextClientWrite\(\) \}\)\.then\($/, body: /./, why: 'raw foreground save: genAtSave against loadGeneration', reviewed: 'ddb4a299f255' }, - { call: /content: item\.content \}\); \} \}\)\.catch\($/, body: /./, why: 'raw foreground failure: genAtSave against loadGeneration', reviewed: '3c3619ac0a37' }, + { call: /\{ content: toSave, client_write: nextClientWrite\(\) \}\)\.then\($/, body: /^\(\) =>/, why: 'content save: switchedAway', reviewed: '160532c3b115' }, + { call: /showSaved\(saveTok\); \}\)\.catch\($/, body: /./, why: 'content save failure: switchedAway', reviewed: '73e9f73724f5' }, + { + call: /\.finally\($/, + body: /^\(\) => saves\.settle\(saveTok\)$/, + in: 'setTimeout(…)', + code: '() => saves.settle(saveTok)', + why: SETTLE_WHY, + reviewed: '4fbcd658ebba', + may: ['saves.settle'], + }, + { call: /\{ keepalive: true \}\) \.then\($/, body: /./, why: 'raw keepalive save: genAtSave against loadGeneration', reviewed: '758b235c3705' }, + { call: /localDirty = false; \} \}\) \.catch\($/, body: /^\(\) => \{\}$/, why: 'raw keepalive failure: empty', reviewed: '66b6e4855355' }, + { call: /reqItemId, \{ content: toSave, client_write: nextClientWrite\(\) \}\)\.then\($/, body: /./, why: 'raw foreground save: genAtSave against loadGeneration', reviewed: '109114fbb1b8' }, + { call: /content: item\.content \}\); \} \}\)\.catch\($/, body: /./, why: 'raw foreground failure: genAtSave against loadGeneration', reviewed: 'a47d5c0c6f20' }, + { + call: /\.finally\($/, + body: /^\(\) => saves\.settle\(saveTok\)$/, + in: '{save}', + code: '() => saves.settle(saveTok)', + why: SETTLE_WHY, + reviewed: '0ef0106d13cc', + may: ['saves.settle'], + }, ]; /** @@ -415,7 +460,7 @@ const CONTINUATIONS: SignedRow[] = [ * one is an edit to every unit that reaches it. */ const HELPERS: Record = { - primeCanonicalSeed: 'bd355af5a470', + primeCanonicalSeed: '9d90b6bf0245', adoptCollection: 'ab38368cd8fb', adoptServerItem: '198d4de5b450', applyProgress: 'b28014625819', @@ -426,11 +471,11 @@ const HELPERS: Record = { identityHeld: '1c5505d51f73', navigateToCollectionRoot: '64dce75693d3', refreshPrintMeta: '2312cc481ca5', - runTeardownFlush: '5718e613cefd', + runTeardownFlush: '8251de42632d', // BUG-3124 unit B: the cursor-advance settle. Synchronous; arms the flusher's // single timer only for the current, identity-held context. - settleCollabIfCurrent: 'c3f36ad978c9', - showSaved: '3f1ef91fac07', + showSaved: '456dd972dcf5', + settleCollabIfCurrent: '438f4aed6d2f', // BUG-2367: the move's success toast, naming any not_unique drop. // Synchronous; reads only its arguments and writes only the toast store. showMovedToast: '6a32f06fbd9f', @@ -960,19 +1005,19 @@ const LOAD_DATA_CLEAR = '\t\tclearTimeout(contentDebounceTimer);\n\t\tcontentDeb /** saveTitle's capture, made reassignable. */ const TITLE_GEN_LET: [string, string] = [ - "\t\tconst gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", - "\t\tlet gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tconst gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tlet gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", ]; /** saveTitle records an identity stamp at entry, before its await. */ const TITLE_STAMP_AT_ENTRY: [string, string] = [ - "\t\tconst gen = loadGeneration;\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", - "\t\tconst gen = loadGeneration;\n\t\tconst snap = { epoch: captureIdentity() };\n\t\tsaveStatus = 'saving';\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tconst gen = loadGeneration;\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", + "\t\tconst gen = loadGeneration;\n\t\tconst snap = { epoch: captureIdentity() };\n\t\tconst saveTok = saves.begin();\n\t\ttry {\n\t\t\tconst updated = await api.items.update(wsSlug, targetItem.id, { title", ]; /** saveTitle's fence and the two commits under it: the anchor most round-5 edits rewrite. */ const TITLE_FENCE_AND_COMMITS = - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n"; + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n"; describe('ItemDetail AST guard: round 4\'s edits are all refused (lead ruling, condition 1)', () => { // Without a clean baseline every mutant is "refused" for free. @@ -1009,17 +1054,17 @@ describe('ItemDetail AST guard: round 4\'s edits are all refused (lead ruling, c // read as holding after that await. { id: 'R5 E1 a fence before an await in one && test', - subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (gen === loadGeneration && (await dialogs.confirm('Keep the new title?'))) {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t\tshowSaved();\n\t\t\t}\n"]], + subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (gen === loadGeneration && (await dialogs.confirm('Keep the new title?'))) {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t\tshowSaved(saveTok);\n\t\t\t}\n"]], refuses: ['saveTitle()', 'assigns item after an unfenced await'], }, { id: 'R5 E1b a fence boolean whose initialiser awaits after the fence', - subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tconst keep = gen === loadGeneration && (await dialogs.confirm('Keep the new title?'));\n\t\t\tif (!keep) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n"]], + subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tconst keep = gen === loadGeneration && (await dialogs.confirm('Keep the new title?'));\n\t\t\tif (!keep) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n"]], refuses: ['saveTitle()', 'assigns item after an unfenced await'], }, { id: 'R5 E1c the early-return form: fence, then an await, in one || test', - subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id || !(await dialogs.confirm('Keep the new title?'))) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n"]], + subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id || !(await dialogs.confirm('Keep the new title?'))) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n"]], refuses: ['saveTitle()', 'assigns item after an unfenced await'], }, // Round 5 P1-2: a callback handed to anything but the deferring names was @@ -1042,7 +1087,7 @@ describe('ItemDetail AST guard: round 4\'s edits are all refused (lead ruling, c }, { id: 'R5 E8 a commit inside a requestIdleCallback callback', - subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\trequestIdleCallback(() => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t\tshowSaved();\n\t\t\t});\n"]], + subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\trequestIdleCallback(() => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t\tshowSaved(saveTok);\n\t\t\t});\n"]], refuses: ['saveTitle()', 'callback requestIdleCallback(…)', 'assigns item after an unfenced await'], }, { @@ -1077,7 +1122,7 @@ describe('ItemDetail AST guard: round 4\'s edits are all refused (lead ruling, c id: 'R5 P1-2 a function declared under a name used twice, passed by name', subs: [ [TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tconst onTitleClose = () => {\n\t\t\t\titem = withInflightTags(updated);\n\t\t\t};\n\t\t\twindow.addEventListener('blur', onTitleClose);\n"], - ['\tfunction showSaved() {\n', '\tfunction showSaved() {\n\t\tconst onTitleClose = () => {};\n\t\tvoid onTitleClose;\n'], + ['\tfunction showSaved(saveTok: SaveToken) {\n', '\tfunction showSaved(saveTok: SaveToken) {\n\t\tconst onTitleClose = () => {};\n\t\tvoid onTitleClose;\n'], ], refuses: ['saveTitle()', 'assigns item after an unfenced await'], }, @@ -1092,14 +1137,14 @@ describe('ItemDetail AST guard: round 4\'s edits are all refused (lead ruling, c // Only an empty Set/Map is an allowed constructor default (lead ruling on // checkpoint 63, condition 3). id: 'R6 a default value that constructs something with effects', - subs: [['\tfunction showSaved() {\n', "\tfunction showSaved(stream = new TitleStream()) {\n\t\tvoid stream;\n"]], + subs: [['\tfunction showSaved(saveTok: SaveToken) {\n', "\tfunction showSaved(stream = new TitleStream()) {\n\t\tvoid stream;\n"]], refuses: ['default value calls or assigns'], }, { // A fence boolean stops being one when it is written (round 6 ruling B, // class 6); here the write, not an await, is what makes it stale. id: 'R6 a fence boolean overwritten after it is computed', - subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tlet titleOk = gen === loadGeneration;\n\t\t\ttitleOk = true;\n\t\t\tif (!titleOk) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n"]], + subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tlet titleOk = gen === loadGeneration;\n\t\t\ttitleOk = true;\n\t\t\tif (!titleOk) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n"]], refuses: ['saveTitle()', 'assigns item after an unfenced await'], }, { @@ -1107,7 +1152,7 @@ describe('ItemDetail AST guard: round 4\'s edits are all refused (lead ruling, c id: 'R6 loadGeneration compared against an identity capture', subs: [ [TITLE_GEN_LET[0], TITLE_GEN_LET[0].replace('\t\tsaveStatus', '\t\tconst identity = captureIdentity();\n\t\tsaveStatus')], - [TITLE_FENCE_AND_COMMITS, '{ title: titleDraft.trim() });\n\t\t\tif (loadGeneration !== identity) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n'], + [TITLE_FENCE_AND_COMMITS, '{ title: titleDraft.trim() });\n\t\t\tif (loadGeneration !== identity) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n'], ], refuses: ['saveTitle()', 'assigns item after an unfenced await'], }, @@ -1160,12 +1205,12 @@ describe('ItemDetail AST guard: round 4\'s edits are all refused (lead ruling, c }, { id: 'R5 E3 a commit after a for-of over an awaited list whose body always returns', - subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tfor (const newer of await api.items.newerVersions(wsSlug, targetItem.id)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n"]], + subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tfor (const newer of await api.items.newerVersions(wsSlug, targetItem.id)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n"]], refuses: ['saveTitle()', 'assigns item after an unfenced await'], }, { id: 'R5 P2-2 a commit after a while whose test awaits and whose body always returns', - subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\twhile (await api.items.hasNewer(wsSlug, targetItem.id)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n"]], + subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\twhile (await api.items.hasNewer(wsSlug, targetItem.id)) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n"]], refuses: ['saveTitle()', 'assigns item after an unfenced await'], }, { @@ -1180,7 +1225,7 @@ describe('ItemDetail AST guard: round 4\'s edits are all refused (lead ruling, c }, { id: 'R5 P2-3 a commit after a switch with no default whose test awaits', - subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tswitch (titleDraft) {\n\t\t\t\tcase await api.items.canonicalTitle(wsSlug):\n\t\t\t\t\treturn;\n\t\t\t}\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n"]], + subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tswitch (titleDraft) {\n\t\t\t\tcase await api.items.canonicalTitle(wsSlug):\n\t\t\t\t\treturn;\n\t\t\t}\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n"]], refuses: ['saveTitle()', 'assigns item after an unfenced await'], }, // Round 5 P2-4: an identity comparison between two STAMPS proves nothing. @@ -1267,15 +1312,15 @@ describe('ItemDetail AST guard: round 4\'s edits are all refused (lead ruling, c // A block's bindings hold only inside that block: a `const item` in // one branch excuses no `item =` outside it. id: 'R5 P1-3 a block-scoped declaration excuses a write outside its block', - subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (updated) {\n\t\t\t\tconst item = updated;\n\t\t\t\tvoid item;\n\t\t\t}\n\t\t\titem = withInflightTags(updated);\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tshowSaved();\n"]], + subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (updated) {\n\t\t\t\tconst item = updated;\n\t\t\t\tvoid item;\n\t\t\t}\n\t\t\titem = withInflightTags(updated);\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tshowSaved(saveTok);\n"]], refuses: ['saveTitle()', 'assigns item after an unfenced await — item = withInflightTags(updated)'], }, { // An inlined helper resolves names in ITS scopes: a caller's local that // shares a name with the state the helper writes excuses nothing. id: "R5 P1-3 a caller's local does not excuse the same name inside an inlined helper", - subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tconst saveStatus = updated.title;\n\t\t\tshowSaved();\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n"]], - refuses: ['saveTitle()', 'showSaved() -> assigns saveStatus after an unfenced await'], + subs: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tconst saveStatus = updated.title;\n\t\t\tshowSaved(saveTok);\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n"]], + refuses: ['saveTitle()', 'showSaved() -> calls saves.succeed after an unfenced await'], }, ]; it('a callback allowance that names no callback the unit creates is refused', () => { @@ -1358,7 +1403,7 @@ describe('ItemDetail AST guard: round 4\'s edits are all refused (lead ruling, c id: 'a stamp minted after the await and compared at once', model: 'contrived fence: a stamp is trusted to have been recorded earlier; building one from a live read right before comparing it to a live read is a fence that cannot fail, which no author trying to comply writes', - subs: [[TITLE_FENCE_AND_COMMITS, '{ title: titleDraft.trim() });\n\t\t\tconst fresh = { epoch: captureIdentity() };\n\t\t\tif (authStore.identityEpoch !== fresh.epoch) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n']], + subs: [[TITLE_FENCE_AND_COMMITS, '{ title: titleDraft.trim() });\n\t\t\tconst fresh = { epoch: captureIdentity() };\n\t\t\tif (authStore.identityEpoch !== fresh.epoch) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n']], }, // Round 7 (checkpoint 67): the reviewer's out-of-model findings. ...(round7.gaps as Array<{ id: string; class: string; model: string; subs: string[][] }>).map((g) => ({ @@ -1411,8 +1456,8 @@ describe('ItemDetail AST guard: round 4\'s edits are all refused (lead ruling, c // A catch param is a local of the handler, even though the handler is // entered after an await. id: 'a write to the catch param in an unfenced handler', - old: "\t\t} catch (err: any) {\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tsaveStatus = 'idle';\n", - new: "\t\t} catch (err: any) {\n\t\t\terr = null;\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tsaveStatus = 'idle';\n", + old: "\t\t} catch (err: any) {\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n", + new: "\t\t} catch (err: any) {\n\t\t\terr = null;\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n", }, { // An inlined helper declared inside a block sees that block's names. @@ -1428,8 +1473,8 @@ describe('ItemDetail AST guard: round 4\'s edits are all refused (lead ruling, c }, { id: 'a commit moved BELOW its fence stays accepted', - old: "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n", - new: "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tshowSaved();\n\t\t\titem = withInflightTags(updated);\n", + old: "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n", + new: "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tshowSaved(saveTok);\n\t\t\titem = withInflightTags(updated);\n", }, { // A stamp rooted in a local of the unit is a stamp inside a fence @@ -1438,14 +1483,14 @@ describe('ItemDetail AST guard: round 4\'s edits are all refused (lead ruling, c id: 'a fence boolean over a stamp the unit recorded before its await', old: TITLE_STAMP_AT_ENTRY[0], new: TITLE_STAMP_AT_ENTRY[1], - also: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tconst held = authStore.identityEpoch === snap.epoch;\n\t\t\tif (!held || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n"]], + also: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tconst held = authStore.identityEpoch === snap.epoch;\n\t\t\tif (!held || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n"]], }, { // ...and inside a helper's body, read in the helper's scope. id: 'a fence helper over a stamp the unit recorded before its await', old: TITLE_STAMP_AT_ENTRY[0], - new: TITLE_STAMP_AT_ENTRY[1].replace('\t\tsaveStatus', '\t\tconst snapHeld = () => authStore.identityEpoch === snap.epoch;\n\t\tsaveStatus'), - also: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (!snapHeld() || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n"]], + new: TITLE_STAMP_AT_ENTRY[1].replace('\t\tconst saveTok', '\t\tconst snapHeld = () => authStore.identityEpoch === snap.epoch;\n\t\tconst saveTok'), + also: [[TITLE_FENCE_AND_COMMITS, "{ title: titleDraft.trim() });\n\t\t\tif (!snapHeld() || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n"]], }, ]; it.each(ACCEPTED.map((c) => [c.id, c] as const))('control accepted: %s', (_id, c) => { @@ -1606,7 +1651,7 @@ describe('ItemDetail identity gate: a fenced unit cannot change without a re-rea it('CONTROL: an edit to a fenced unit costs exactly one hash bump', () => { const code = SOURCE.replace( TITLE_FENCE_AND_COMMITS, - "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tshowSaved();\n\t\t\titem = withInflightTags(updated);\n" + "{ title: titleDraft.trim() });\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\tshowSaved(saveTok);\n\t\t\titem = withInflightTags(updated);\n" ); const first = refusals(code); expect(first).toHaveLength(1); @@ -1649,10 +1694,10 @@ describe('ItemDetail identity gate: a fenced unit cannot change without a re-rea it('a function value rebound outside every unit changes the hash of the unit that only NAMES it (round 9 F4)', () => { // Step 1: a reviewed base where saveTitle hands `applyTitle` to someone // else rather than calling it — the round 8 G shape with the call removed. - const noop = '\tlet applyTitle = (next: Item): void => {\n\t\tvoid next;\n\t};\n\t$effect(() => {\n\t\tapplyTitle = (next: Item) => {\n\t\t\tvoid next;\n\t\t};\n\t});\n\n\tfunction showSaved() {\n'; - const base = SOURCE.replace('\tfunction showSaved() {\n', noop).replace( + const noop = '\tlet applyTitle = (next: Item): void => {\n\t\tvoid next;\n\t};\n\t$effect(() => {\n\t\tapplyTitle = (next: Item) => {\n\t\t\tvoid next;\n\t\t};\n\t});\n\n\tfunction showSaved(saveTok: SaveToken) {\n'; + const base = SOURCE.replace('\tfunction showSaved(saveTok: SaveToken) {\n', noop).replace( TITLE_FENCE_AND_COMMITS, - "{ title: titleDraft.trim() });\n\t\t\t[updated].forEach(applyTitle);\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\t[updated].forEach(applyTitle);\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ); const opts = reviewAs(base); // Step 2: only the rebinding inside the $effect changes. saveTitle never @@ -1666,10 +1711,10 @@ describe('ItemDetail identity gate: a fenced unit cannot change without a re-rea it('a function value rebound outside every unit changes the hash of the unit that calls it (refinement 2)', () => { // Step 1: a reviewed base where saveTitle calls a no-op `let` helper. - const noop = '\tlet applyTitle = (next: Item): void => {\n\t\tvoid next;\n\t};\n\t$effect(() => {\n\t\tapplyTitle = (next: Item) => {\n\t\t\tvoid next;\n\t\t};\n\t});\n\n\tfunction showSaved() {\n'; - const base = SOURCE.replace('\tfunction showSaved() {\n', noop).replace( + const noop = '\tlet applyTitle = (next: Item): void => {\n\t\tvoid next;\n\t};\n\t$effect(() => {\n\t\tapplyTitle = (next: Item) => {\n\t\t\tvoid next;\n\t\t};\n\t});\n\n\tfunction showSaved(saveTok: SaveToken) {\n'; + const base = SOURCE.replace('\tfunction showSaved(saveTok: SaveToken) {\n', noop).replace( TITLE_FENCE_AND_COMMITS, - "{ title: titleDraft.trim() });\n\t\t\tapplyTitle(updated);\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved();\n" + "{ title: titleDraft.trim() });\n\t\t\tapplyTitle(updated);\n\t\t\tif (gen !== loadGeneration || item?.id !== targetItem.id) return;\n\t\t\titem = withInflightTags(updated);\n\t\t\tshowSaved(saveTok);\n" ); const opts = reviewAs(base); // Step 2: only the rebinding inside the $effect changes. diff --git a/web/src/lib/components/items/itemDetailOrdersFieldWrites.test.ts b/web/src/lib/components/items/itemDetailOrdersFieldWrites.test.ts index c3b1aa905..f1443af10 100644 --- a/web/src/lib/components/items/itemDetailOrdersFieldWrites.test.ts +++ b/web/src/lib/components/items/itemDetailOrdersFieldWrites.test.ts @@ -141,7 +141,7 @@ describe('ItemDetail field writes are ordered', () => { // leaves the server holding the confirmed value and the pane showing the // old one, with nothing left to correct it. `claim` asks whether anything // newer actually WROTE, which is the question that matters here. - // Bounded at the branch's own `showSaved()`, not at the next landmark: + // Bounded at the branch's own `showSaved(saveTok)`, not at the next landmark: // the DECLINED branch below it legitimately mentions supersession (for // the save indicator), and a slice running into it reports that as a // violation here. @@ -151,7 +151,7 @@ describe('ItemDetail field writes are ordered', () => { // anchored on the branch would miss it — measured, it survived. const windowAt = UPDATE_FIELD.indexOf('if (!stillCurrent() || !item) return;'); const forcedAt = UPDATE_FIELD.indexOf('if (forced) {', windowAt); - const savedAt = UPDATE_FIELD.indexOf('showSaved();', forcedAt); + const savedAt = UPDATE_FIELD.indexOf('showSaved(saveTok);', forcedAt); expect(windowAt).toBeGreaterThan(-1); expect(forcedAt).toBeGreaterThan(windowAt); expect(savedAt).toBeGreaterThan(forcedAt); @@ -175,9 +175,12 @@ describe('ItemDetail field writes are ordered', () => { const afterForced = declined.slice(declined.indexOf('toastStore.show(\'Status change cancelled\'')); expect(declined).toContain("toastStore.show('Status change cancelled'"); // Between the end of the forced branch and the cancel toast there is no - // claim — only the saveStatus guard. + // claim. (It used to hold the save-indicator guard; the indicator now + // settles in updateField's finally, BUG-3044.) const cancelAt = declined.indexOf("toastStore.show('Status change cancelled'"); - const declinedBody = declined.slice(declined.indexOf('}', declined.indexOf('showSaved();')), cancelAt); + const succeedAt = declined.indexOf('showSaved(saveTok);'); + expect(succeedAt, 'the forced branch ends at its success record').toBeGreaterThan(-1); + const declinedBody = declined.slice(declined.indexOf('}', succeedAt), cancelAt); expect(declinedBody).not.toContain('fieldWrites.claim(ticket)'); expect(afterForced.length).toBeGreaterThan(0); }); diff --git a/web/src/lib/components/items/itemDetailSettlesEverySave.test.ts b/web/src/lib/components/items/itemDetailSettlesEverySave.test.ts new file mode 100644 index 000000000..cdd844565 --- /dev/null +++ b/web/src/lib/components/items/itemDetailSettlesEverySave.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** + * BUG-3044 — every write the pane's save indicator counts is settled on every + * path. A `saves.begin()` whose token is not settled in a `finally` leaves the + * count up after any early return (a superseded write, an item switch, a + * force-refresh skip), and 'saving' then blocks every SSE gate for the item + * until navigation. + * + * A source guard, deliberately narrow: it pairs each begin with a settle of + * the same token inside a `finally` block or a `.finally(` in the SAME + * function. It does not prove the finally is reachable on every path; the + * identity-fence gate (itemDetailIdentityFenceAst.test.ts) hashes each of + * these units, so any edit to one is re-read there. + */ +const SOURCE = readFileSync(fileURLToPath(new URL('./ItemDetail.svelte', import.meta.url)), 'utf-8'); + +/** The body of the innermost `function name(` / `name: async (` / `setTimeout(() =>` that contains `at`, by brace matching. */ +function enclosingBlock(src: string, at: number): string { + let depth = 0; + for (let i = at; i >= 0; i--) { + if (src[i] === '}') depth++; + else if (src[i] === '{') { + if (depth === 0) { + const head = src.slice(Math.max(0, i - 160), i); + if (/(function \w+\([^)]*\)[^{]*|=>\s*|async \([^)]*\)[^{]*=>\s*)$/.test(head)) { + let d = 0; + for (let j = i; j < src.length; j++) { + if (src[j] === '{') d++; + else if (src[j] === '}' && --d === 0) return src.slice(i, j + 1); + } + } + } else depth--; + } + } + return ''; +} + +describe('ItemDetail settles every save it begins (BUG-3044)', () => { + const begins = [...SOURCE.matchAll(/(?:const |)saveTok = saves\.begin\(\);/g)]; + + it('the population is the nine save paths', () => { + // Title, field, tags, assignment, role, the legacy content debounce, the + // collab flush, the raw saver and the raw drain. + expect(begins.length).toBe(9); + }); + + it.each(begins.map((m) => [SOURCE.slice(0, m.index).split('\n').length, m.index!] as const))( + 'the begin at line %i is settled in a finally of the same function', + (_line, at) => { + const body = enclosingBlock(SOURCE, at); + expect(body, 'no enclosing function found').not.toBe(''); + const settled = + /finally \{[^}]*saves\.settle\(saveTok\)/.test(body) || /\.finally\(\(\) => saves\.settle\(saveTok\)\)/.test(body); + expect(settled, 'no settle of this token in a finally').toBe(true); + } + ); + + it('writes nothing to the indicator except through the tracker', () => { + // Its one declaration is derived from the tracker; nothing assigns it. + expect(SOURCE).toMatch(/const saveStatus = \$derived\(saves\.status\);/); + expect(SOURCE).not.toMatch(/(? { + vi.useFakeTimers(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +describe('SaveTracker (BUG-3044)', () => { + it('an older success does not show Saved while a newer write is in flight', () => { + const s = new SaveTracker(); + const older = s.begin(); + const newer = s.begin(); + s.succeed(older); + s.settle(older); + expect(s.status).toBe('saving'); + s.succeed(newer); + s.settle(newer); + expect(s.status).toBe('saved'); + }); + + it('an older success settling AFTER the newest write failed does not show Saved', () => { + const s = new SaveTracker(); + const older = s.begin(); + const newer = s.begin(); + s.settle(newer); // failed + expect(s.status).toBe('saving'); + s.succeed(older); + s.settle(older); + expect(s.status).toBe('idle'); + }); + + it("one field's failure does not hide another field's write in flight", () => { + const s = new SaveTracker(); + const a = s.begin(); + const b = s.begin(); + s.settle(a); // a failed + expect(s.status).toBe('saving'); + s.succeed(b); + s.settle(b); + expect(s.status).toBe('saved'); + }); + + it("a previous save's Saved timer does not fire during a new request", () => { + const s = new SaveTracker(); + const first = s.begin(); + s.succeed(first); + s.settle(first); + expect(s.status).toBe('saved'); + vi.advanceTimersByTime(SAVED_MS - 100); + const second = s.begin(); + vi.advanceTimersByTime(500); // the first save's timer would have fired here + expect(s.status).toBe('saving'); + s.succeed(second); + s.settle(second); + expect(s.status).toBe('saved'); + vi.advanceTimersByTime(SAVED_MS); + expect(s.status).toBe('idle'); + }); + + it('a superseded write that settles in a finally does not pin saving', () => { + // The trap a plain counter walks into: a superseded write returns early + // without touching the status. Settled from a finally, it still counts down. + const s = new SaveTracker(); + const superseded = s.begin(); + const winner = s.begin(); + s.succeed(winner); + s.settle(winner); + expect(s.status).toBe('saving'); + s.settle(superseded); + expect(s.status).toBe('saved'); + }); + + it('a settle from before a reset (item switch) is ignored', () => { + const s = new SaveTracker(); + const old = s.begin(); + s.reset(); + expect(s.status).toBe('idle'); + const current = s.begin(); + s.succeed(old); + s.settle(old); + expect(s.status, 'the previous item cannot end this one').toBe('saving'); + s.settle(current); + expect(s.status).toBe('idle'); + }); + + it('settling a token twice counts once', () => { + const s = new SaveTracker(); + const a = s.begin(); + const b = s.begin(); + s.settle(a); + s.settle(a); + expect(s.status).toBe('saving'); + s.settle(b); + expect(s.status).toBe('idle'); + }); + + it('reset clears a pending Saved timer', () => { + const s = new SaveTracker(); + const t = s.begin(); + s.succeed(t); + s.settle(t); + s.reset(); + const next = s.begin(); + vi.advanceTimersByTime(SAVED_MS + 1); + expect(s.status).toBe('saving'); + s.settle(next); + }); +}); diff --git a/web/src/lib/items/saveTracker.svelte.ts b/web/src/lib/items/saveTracker.svelte.ts new file mode 100644 index 000000000..d78a13976 --- /dev/null +++ b/web/src/lib/items/saveTracker.svelte.ts @@ -0,0 +1,100 @@ +/** + * The item pane's save indicator, derived from the writes actually OUTSTANDING + * rather than from whichever write reached a shared variable last (BUG-3044). + * + * `status` is: + * - 'saving' while ANY begun write has not settled; + * - 'saved' (for SAVED_MS) once the last one settles, iff the NEWEST-ISSUED + * write succeeded — an older success cannot announce a value that a newer + * write has since failed to store; + * - 'idle' otherwise. + * + * A single last-writer value got three sequences wrong: an older write's success + * showed "Saved" while a newer write was still in flight; one field's failure + * showed idle while another field was still saving; and a previous "Saved" + * timer fired mid-request, because starting a save never cleared it. The pane's + * SSE gates read `status === 'saving'`, so each of those also admitted an + * incoming event while a local write was outstanding. + * + * Every `begin()` must be `settle()`d, on EVERY return path — including a write + * that was superseded by a newer one for the same field, which otherwise never + * decrements and pins 'saving' (and every SSE gate) until navigation. Call + * `settle` from a `finally`. A token from before the last `reset()` (the pane + * switched items) is ignored, so a late settle cannot move the next item's + * indicator. Settling a token twice is a no-op. + */ + +export type SaveStatus = 'idle' | 'saving' | 'saved'; + +/** How long "Saved" stays up before the indicator goes idle. */ +export const SAVED_MS = 2000; + +export interface SaveToken { + readonly epoch: number; + readonly n: number; + ok: boolean; + settled: boolean; +} + +export class SaveTracker { + status = $state('idle'); + + #epoch = 0; + #outstanding = 0; + #issued = 0; + #newestOk = false; + #timer: ReturnType | undefined; + + /** A write is starting. Clears any pending "Saved" timer. */ + begin(): SaveToken { + this.#clearTimer(); + this.#outstanding += 1; + this.#issued += 1; + this.status = 'saving'; + return { epoch: this.#epoch, n: this.#issued, ok: false, settled: false }; + } + + /** Record that this write succeeded; it still has to be settled. */ + succeed(token: SaveToken): void { + token.ok = true; + } + + /** This write is over, whatever happened to it. Call from a `finally`. */ + settle(token: SaveToken): void { + if (token.settled) return; + token.settled = true; + if (token.epoch !== this.#epoch) return; + this.#outstanding -= 1; + if (token.n === this.#issued) this.#newestOk = token.ok; + if (this.#outstanding > 0) return; + if (this.#newestOk) { + this.status = 'saved'; + this.#timer = setTimeout(() => { + this.#timer = undefined; + this.status = 'idle'; + }, SAVED_MS); + } else { + this.status = 'idle'; + } + } + + /** Forget every outstanding write (the pane moved to another item). */ + reset(): void { + this.#epoch += 1; + this.#outstanding = 0; + this.#issued = 0; + this.#newestOk = false; + this.#clearTimer(); + this.status = 'idle'; + } + + /** Stop the timer; for component teardown. */ + destroy(): void { + this.#clearTimer(); + } + + #clearTimer(): void { + clearTimeout(this.#timer); + this.#timer = undefined; + } +}