feat(collab): 5s-idle + on-disconnect markdown flush (TASK-1260) (#458)

* feat(collab): 5s-idle + on-disconnect markdown flush (TASK-1260)

Replaces the temporary handleContentUpdate suppression introduced
in TASK-1259 (PR #457) with a proper flush mechanism. Under
collab, the Y.Doc + op-log are canonical for live state but
items.content needs to stay reasonably fresh for downstream
consumers (search index, share-page, exports, plain API readers).

## Mechanism

1. **5s idle timer.** Every editor onUpdate (local OR remote)
   resets a 5s timer. On fire, PATCHes items.content via the new
   `?source=collab-snapshot` query param.

2. **Server-side bypass.** handleUpdateItem inspects the source
   query param. When set, skips the applyContentViaCollab routing
   entirely and writes directly. Without the bypass, the PATCH
   would loop back through the applier protocol (the same tab
   gets asked to apply, acks, server strips input.Content) and
   leave items.content unchanged forever. The flag is
   trustworthy because the caller already has edit access.

3. **Dedupe across peers.** Track lastFlushedContent. If our
   last successful flush already landed this exact markdown,
   skip the PATCH. Multiple connected tabs would otherwise each
   fire a redundant flush after every shared edit converges,
   multiplying server load by the peer count.

4. **On-disconnect flush.** $effect cleanup (item swap or page
   unmount) calls flushCollabNow(true) BEFORE provider.destroy().
   A separate beforeunload listener catches close-tab / reload /
   external-nav. Both use fetch keepalive: true so the request
   outlives the page lifecycle.

5. **Item-id race guards.** runCollabFlush captures reqItemId
   before await; ignores response if item swapped. loadData()
   clears collabFlushTimer + lastFlushedContent on navigation.

## Files

- internal/server/handlers_items.go — accept `?source=collab-snapshot`
- web/src/lib/api/client.ts — add api.items.flushCollabContent
- web/src/routes/.../[slug]/+page.svelte — handleContentUpdate gains
  scheduleCollabFlush + runCollabFlush + flushCollabNow; wired to
  $effect cleanup + beforeunload + loadData reset.

Parent: PLAN-1248

* fix(collab): capture ws+itemId at provider mount + apply unescapeDocLinks per Codex review (round 1)

Two findings from round 1:

1) [P1] runCollabFlush resolved item.id and wsSlug at execution
   time, not at schedule time. During item navigation the timer
   could fire (or $effect cleanup could run) AFTER `item` was
   already updated to the new item, causing the OLD editor's
   markdown to be PATCHed against the NEW item's URL —
   cross-item content corruption.

   Fix: introduce activeCollabContext = { wsSlug, itemId },
   captured at $effect-body time (when the provider is minted).
   scheduleCollabFlush, runCollabFlush, and flushCollabNow all
   take their target identity from this captured context, never
   from live reactive state. Cleared in the $effect's own
   cleanup (defensive `=== ctx` slot guard so a fast-navigation
   churn doesn't clobber a successor context).

2) [P2] The disconnect flush read raw editor.storage.markdown
   .getMarkdown() without unescapeDocLinks, unlike the regular
   onUpdate path. Closing/navigating before the idle flush could
   persist escaped wiki links like \[\[TASK-1\]\] which then
   wouldn't be converted by markdownToWikiLinks.

   Fix: apply unescapeDocLinks() at the start of runCollabFlush
   (covers both the timer-driven idle path and the unmount path).

* fix(collab): gate UI mutations on foreground+current-item per Codex review (round 2)

[P2] runCollabFlush mutated page-scoped state (saveStatus,
editorStore.lastSaveTime, lastFlushedContent) before checking
whether the captured itemId still matches the foreground item.
On item navigation, the cleanup-driven keepalive flush could
stamp 'saving' onto the NEW page's saveStatus, leaving it
pinned indefinitely (and pollute lastFlushedContent for the
new item's dedupe state).

Fix: introduce isForegroundCurrent() = !keepalive && item.id ===
itemId. Gate saveStatus / setLastSaveTime / showSaved on it so
background cleanup flushes never touch UI state. Gate
lastFlushedContent on item.id === itemId regardless of keepalive
so a stale flush can't seed the wrong item's dedupe.

* fix(collab): skip cleanup flush on rich→raw transition per Codex review (round 3)

[P1] $effect cleanup fires the keepalive flushCollabNow on every
provider teardown, including rawMode toggles. The raw-button
onclick already pre-populated rawPendingMarkdown with the live
editor markdown (which the 1.2s raw debounce will land), so the
keepalive PATCH from cleanup is redundant — and worse, can land
AFTER the raw save and clobber newer raw edits with the older
Y.Doc snapshot.

Fix: gate the cleanup flush on `!rawMode`. If rawMode is true at
cleanup time, the user just toggled to raw and the raw-mode
codepath owns items.content from here. The other cleanup
triggers (item nav, canEdit flip, page unmount) all keep
firing the flush as before.

Note: rawMode === true at cleanup time unambiguously means
"transitioning into raw" — the inverse case (already in raw and
the cleanup fires for some other reason) is impossible because
collabKey gates on !rawMode, so the provider $effect never
runs while rawMode is true.

* fix(collab): synchronously flush Y.Doc state on rich→raw toggle per Codex review (round 4)

[P1] Rich → raw → navigate-without-typing-or-toggling-back never
PATCHed the live Y.Doc state to items.content. The previous
seed mechanism only set rawPendingMarkdown, which only fires the
1.2s debounce on a subsequent handleRawContentUpdate call —
which never happens if the user doesn't type.

Fix: await runCollabFlush(ws, itemId, md, true) inside the raw
button's async onclick BEFORE flipping rawMode = true. This:

  - Lands items.content with the live Y.Doc state synchronously
    (one PATCH, awaited, with keepalive: true so it survives a
    fast post-toggle navigation).
  - Seeds lastFlushedContent so any cleanup-driven re-flush is
    deduped.
  - Avoids populating rawPendingMarkdown — the raw debounce now
    only fires for actual user edits in raw mode, eliminating
    the race where a stale debounce fired after navigation
    could clobber state.

The Round 3 cleanup-skip on rawMode is kept as defense-in-depth
(also makes the no-op-when-already-flushed semantics explicit).

* fix(collab): loop-flush until stable + cancel timer on rich→raw toggle per Codex review (round 5)

Two HIGH findings from round 5:

1) Round 4's single-flush captured md BEFORE the await; concurrent
   peer edits (e.g. same user's other tab) during the await
   were lost from the seed and could be overwritten by
   subsequent raw-mode saves.

   Fix: loop-flush until stable. Re-read editor markdown after
   each PATCH; if it changed, flush again. Capped at 3
   iterations to bound the transition under aggressive
   concurrent typing.

2) An onUpdate during the await could schedule a 5s collab
   flush timer that survived the rawMode flip. The cleanup
   skipped flushCollabNow on rawMode, but the timer fired its
   own runCollabFlush — which then PATCHed stale rich markdown
   on top of subsequent raw saves.

   Fix: explicitly clearTimeout(collabFlushTimer) at the end
   of the rich→raw onclick (after the loop-flush, before
   flipping rawMode). Belt-and-braces with the Round 3 cleanup
   skip.

* fix(collab): seed raw mode from lastFlushed (not unflushed Y.Doc) per Codex review (round 6)

[HIGH] Round 5's loop-flush could exit at the 3-iteration cap with
md still differing from the last-PATCHed value, then seed
rawSeedMarkdown with that unflushed md. An immediate navigation
without typing would lose the unpersisted state.

Fix: track lastFlushed inside the loop. After the loop, seed
rawSeedMarkdown = lastFlushed (the markdown we actually PATCHed),
NOT md (potentially a never-flushed in-memory value). If peer
edits keep arriving past our cap, items.content lags Y.Doc
briefly — but the peer's own 5s flush will catch up shortly,
and at least raw mode shows state consistent with items.content
rather than holding a value the server never received.

* fix(collab): three corner-case fixes per Codex review (round 7)

1) [HIGH] lastFlushed = md was set unconditionally inside the
   loop-flush, even when runCollabFlush returned false (PATCH
   failed). rawSeedMarkdown could then be seeded with markdown
   the server never received.

   Fix: gate `lastFlushed = md` on runCollabFlush returning true.
   Failed PATCHes leave lastFlushed at its prior value.

2) [HIGH] lastFlushedContent (the collab-flush dedupe key) was
   never invalidated by raw-mode direct saves. Scenario: collab
   flushes A. Raw saves B. User returns to rich + edits back to
   A. Next collab flush dedupes (lastFlushedContent === A) and
   skips, leaving items.content stuck on B.

   Fix: reset lastFlushedContent = null after every successful
   raw save (both the regular handleRawContentUpdate path and
   the flushRawIfPending drain loop) so subsequent collab
   flushes always re-PATCH.

3) [MEDIUM] The async rich→raw onclick applied rawSeedMarkdown +
   rawMode = true after multiple awaits without verifying the
   user was still on the same item. A navigation during the
   loop-flush could let item A's handler resume and seed raw
   mode on item B.

   Fix: before mutating component state (rawSeedMarkdown,
   rawMode), check `item?.id === itemId` (the captured target).
   Bail with `return` if mismatched.

* fix(collab): differentiate flush outcomes + foreground keepalive=false per Codex review (round 8)

Two findings from round 8:

1) [P1] runCollabFlush returned `false` for both PATCH failure
   AND dedupe-skip. The rich→raw toggle treated `false` as
   "didn't flush" and didn't seed rawSeedMarkdown — but a dedupe
   means items.content already matches our markdown (the prior
   successful flush put it there). Raw mode then seeded from
   the page's stale `item.content` field, and a subsequent raw
   save could overwrite the current server content with the
   pre-collab snapshot.

   Fix: change runCollabFlush's return type to a discriminated
   string: 'flushed' | 'deduped' | 'failed'. The toggle treats
   'flushed' and 'deduped' equivalently for seeding (both mean
   "server has this markdown") and only bails on 'failed'.

2) [P2] The toggle path used keepalive=true for the awaited
   flush. Browser keepalive requests can reject for bodies
   larger than the per-origin keepalive quota (~64KB). On
   reject, the catch silently fell through and raw mode
   activated with rawSeedMarkdown null.

   Fix: switch the toggle path to keepalive=false. The await is
   synchronous and user-initiated; navigation isn't imminent, so
   the keepalive escape hatch isn't needed (and risks losing
   the explicit save). Also added an `aborted` short-circuit so
   a 'failed' result returns early WITHOUT entering raw mode —
   user can retry. Cleanup-driven flushes (which DO need to
   survive page lifecycle) still use keepalive=true.
This commit is contained in:
xarmian
2026-05-08 22:48:56 -04:00
committed by GitHub
parent 5dc42b60df
commit 9b1a91ab00
3 changed files with 332 additions and 41 deletions
+12 -1
View File
@@ -493,7 +493,18 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) {
// post-write snapshot for the response. Per Codex review round 9.
var fullWriteHandled bool
var fullWriteUpdated *models.Item
if input.Content != nil && s.collab != nil {
// `?source=collab-snapshot` opts out of the applier-routing path so
// a connected collab tab can flush its Y.Doc-derived markdown to
// items.content WITHOUT looping back through ApplyExternalContent
// (which would ask this same tab to apply, ack, and then strip
// input.Content — leaving items.content unchanged). The flag is
// trustworthy because the caller already has edit access (else the
// PATCH would 401/403); the bypass just skips a defensive
// re-routing that's only useful for EXTERNAL content updates.
// Per TASK-1260 / PLAN-1248.
collabSnapshot := r.URL.Query().Get("source") == "collab-snapshot"
if input.Content != nil && s.collab != nil && !collabSnapshot {
// applyContentViaCollab calls directWrite ONLY on the no-
// room/no-applier paths (where pruning the op-log is safe
// and we need to land items.content under the per-item
+27
View File
@@ -337,6 +337,33 @@ export const api = {
body: JSON.stringify(data)
}),
/**
* flushCollabContent PATCHes items.content with the
* `?source=collab-snapshot` query param so the server
* skips the applier-routing path. Used by the editor's
* 5s-idle + on-disconnect flush (TASK-1260) — the
* connected tab IS the canonical source of truth for
* Y.Doc state, and routing through the applier would
* loop the request back to itself.
*
* `keepalive` is passed straight to fetch so the
* unmount / beforeunload flush path can outlive the
* page lifecycle (browser holds the request open until
* it completes or hits the ~64KB body cap; markdown
* bodies are well under that for typical items).
*/
flushCollabContent: (
ws: string,
slug: string,
content: string,
opts?: { keepalive?: boolean },
) =>
request<Item>(`/workspaces/${ws}/items/${slug}?source=collab-snapshot`, {
method: 'PATCH',
body: JSON.stringify({ content }),
keepalive: opts?.keepalive,
}),
delete: (ws: string, slug: string) =>
request<void>(`/workspaces/${ws}/items/${slug}`, {
method: 'DELETE'
@@ -16,7 +16,7 @@
import ItemTimeline from '$lib/components/timeline/ItemTimeline.svelte';
import ChildItems from '$lib/components/ChildItems.svelte';
import { goto } from '$app/navigation';
import { relativeTime, wikiLinksToMarkdown, markdownToWikiLinks, cleanBrokenLinks } from '$lib/utils/markdown';
import { relativeTime, wikiLinksToMarkdown, markdownToWikiLinks, cleanBrokenLinks, unescapeDocLinks } from '$lib/utils/markdown';
import { toastStore } from '$lib/stores/toast.svelte';
import { editorStore } from '$lib/stores/editor.svelte';
import type { Item, Collection, CollectionSettings, QuickAction, ItemLink, AgentRole } from '$lib/types';
@@ -327,8 +327,16 @@
// debounce too. Per Codex review round 10.
clearTimeout(contentDebounceTimer);
contentDebounceTimer = undefined;
clearTimeout(collabFlushTimer);
collabFlushTimer = undefined;
rawSeedMarkdown = null;
rawPendingMarkdown = null;
// lastFlushedContent is per-item; resetting prevents the
// dedupe from incorrectly suppressing the first flush on
// the next item (which happens to share the same markdown
// as the last flush of the previous item — vanishingly
// unlikely but trivial to defend).
lastFlushedContent = null;
// Reset transient save UI state too. Without this, a stale
// raw PATCH that gets discarded by the new race guard
// (item.id mismatch) leaves saveStatus pinned at 'saving' on
@@ -456,6 +464,11 @@
$effect(() => {
if (!collabKey) return;
const itemId = collabKey;
// Snapshot the workspace alongside the itemId. activeCollabContext
// is what the timer-driven + cleanup-driven flushes target so
// they always PATCH the right URL even after navigation.
const ctx = { wsSlug, itemId };
activeCollabContext = ctx;
const doc = new Y.Doc();
const provider = new CollabProvider(itemId, doc, {
@@ -496,8 +509,35 @@
collabProvider = provider;
return () => {
// Best-effort flush of items.content BEFORE we tear the
// provider down. The Y.Doc + op-log are canonical, but
// downstream consumers (search, share-page, exports,
// API readers) read items.content; without this flush a
// user closing the tab right after typing would leave
// items.content frozen at the prior 5s tick. We pass
// the captured ctx (NOT live reactive state) so a
// navigation that already updated `item` and `wsSlug`
// doesn't mis-route this flush to a different item.
// keepalive=true lets the request outlive the page
// lifecycle. Per TASK-1260.
//
// EXCEPTION: skip the flush if the cleanup is firing
// because the user just toggled INTO raw mode. The
// raw-button onclick already pre-populated
// rawPendingMarkdown with the live editor markdown, so
// the 1.2s raw debounce will land items.content cleanly.
// A keepalive collab-snapshot PATCH from here is
// fire-and-forget and can arrive AFTER the raw save —
// clobbering newer raw edits with the older Y.Doc
// snapshot. The other cleanup triggers (item nav,
// canEdit flip, page unmount) all benefit from the
// flush. Per Codex review round 3.
if (!rawMode) {
flushCollabNow(ctx, true);
}
provider.destroy();
doc.destroy();
if (activeCollabContext === ctx) activeCollabContext = null;
// Defensive — only clear the slot if it still holds the
// pair we created. A reactive churn that swapped a new
// pair in before this cleanup ran shouldn't get clobbered.
@@ -506,6 +546,22 @@
};
});
// beforeunload: same flush as $effect cleanup, but routed
// through the page lifecycle so navigation off-site (close tab,
// reload, follow external link) lands the markdown snapshot
// before the WS dies. fetch keepalive: true is the modern
// equivalent of sendBeacon for non-POST requests; supports up to
// ~64KB body which dwarfs typical markdown items.
$effect(() => {
if (typeof window === 'undefined') return;
const onBeforeUnload = () => {
const ctx = activeCollabContext;
if (ctx) flushCollabNow(ctx, true);
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
});
// Handle the ?new=1 auto-edit-title flow reactively. canEdit may flip
// from false → true after loadData() resolves (workspace layout fires
// workspaceStore.setCurrent without awaiting it, so /me can land after
@@ -616,20 +672,31 @@
}
}
// Tracks the last markdown we successfully PATCHed to items.content
// in collab-flush mode. Lets the idle-fire dedupe redundant flushes
// across multiple connected tabs that all converge on the same
// Y.Doc state — without this, every tab fires its own 5s flush
// after every shared edit, multiplying server PATCH load by the
// peer count.
let lastFlushedContent: string | null = null;
// 5s-idle timer for the collab flush path. Distinct from
// contentDebounceTimer (which the legacy non-collab and raw-mode
// paths still use) so the two firings don't trample each other on
// rapid mode toggles.
let collabFlushTimer: ReturnType<typeof setTimeout> | undefined;
const COLLAB_FLUSH_IDLE_MS = 5_000;
function handleContentUpdate(markdown: string) {
// Suppress the legacy 1.2s autosave when the collab provider
// owns this editor's content. Routing the PATCH through the
// server while a provider is connected would loop the
// applier protocol back to this same tab (a no-op
// round-trip), then set input.Content = nil server-side so
// items.content never gets the snapshot — leaving canonical
// markdown stale for search / share-page / API consumers.
// TASK-1260 introduces the proper 5s idle flush that bypasses
// applier with a `?source=collab-flush` semantic; this guard
// is the temporary stop-gap for the inter-PR window. Per
// Codex review round 4.
// Collab-active path: 5s idle flush of items.content via the
// `?source=collab-snapshot` bypass (server skips the applier
// loop, writes items.content directly). Y.Doc op-log is
// canonical for live state; items.content stays "reasonably
// fresh" for search / share-page / API consumers. Per
// TASK-1260 / PLAN-1248.
if (collabProvider) {
editorStore.setDirty(true);
scheduleCollabFlush(markdown);
return;
}
clearTimeout(contentDebounceTimer);
@@ -659,6 +726,129 @@
}, 1200);
}
// activeCollabContext holds the (workspace, item) the currently-
// connected provider was minted against. Capturing this at
// $effect-body time (NOT at flush time) makes the flush path
// resistant to navigation: if the user moves to a new item
// between schedule and fire, the timer-driven and cleanup-driven
// flushes still PATCH the OLD item's URL with its OLD markdown,
// so we never cross-write one item's content into another. Per
// Codex review round 1.
let activeCollabContext: { wsSlug: string; itemId: string } | null = null;
function scheduleCollabFlush(markdown: string) {
clearTimeout(collabFlushTimer);
const ctx = activeCollabContext;
if (!ctx) return;
collabFlushTimer = setTimeout(() => {
collabFlushTimer = undefined;
void runCollabFlush(ctx.wsSlug, ctx.itemId, markdown, false);
}, COLLAB_FLUSH_IDLE_MS);
}
// CollabFlushResult discriminates the three outcomes runCollabFlush
// can produce so callers can act on them differently:
// - 'flushed' — PATCH succeeded; items.content now matches.
// - 'deduped' — skipped because lastFlushedContent already
// matched; items.content is ALREADY at this content (the
// previous successful flush put it there). Treated by the
// rich→raw toggle as equivalent to 'flushed' for seeding
// purposes — both mean "server has this markdown."
// - 'failed' — PATCH errored. The toggle path bails so we
// don't enter raw mode with stale state.
// Per Codex review round 8.
type CollabFlushResult = 'flushed' | 'deduped' | 'failed';
// runCollabFlush PATCHes items.content via the
// `?source=collab-snapshot` bypass. Takes ws/item from the
// captured context (NOT live reactive state) so a navigation in
// flight doesn't mis-route the PATCH to a different item.
async function runCollabFlush(
ws: string,
itemId: string,
markdown: string,
keepalive: boolean,
): Promise<CollabFlushResult> {
const allItems = collectionStore.items ?? [];
let toSave = unescapeDocLinks(markdown);
if (allItems.length > 0) {
toSave = markdownToWikiLinks(toSave, allItems);
}
toSave = cleanBrokenLinks(toSave);
// Dedupe: skip the PATCH if our last successful flush
// already landed this exact content. Multiple connected
// tabs would otherwise each fire a redundant PATCH after
// every shared edit converges. Returns 'deduped' (NOT
// 'failed') so callers can distinguish "no work needed"
// from a real error. Per Codex review round 8.
if (lastFlushedContent === toSave) return 'deduped';
// UI mutations only fire when:
// - This is a foreground (user-driven) flush (!keepalive),
// AND
// - The user is still looking at the item we're flushing
// (item.id === itemId).
// Background (keepalive=true) cleanup flushes after
// navigation MUST NOT touch saveStatus / lastSaveTime —
// those slots belong to whatever item the user is now on,
// and stamping them from a stale flush leaves the new page
// pinned in 'Saving...' indefinitely. Per Codex review
// round 2.
const isForegroundCurrent = (): boolean =>
!keepalive && !!item && item.id === itemId;
if (isForegroundCurrent()) {
saveStatus = 'saving';
editorStore.setLastSaveTime(Date.now());
}
try {
await api.items.flushCollabContent(ws, itemId, toSave, { keepalive });
// lastFlushedContent is per-item; only seed it if the
// item we just flushed is still the active one.
// Otherwise a stale flush could pollute the new page's
// dedupe state.
if (item && item.id === itemId) {
lastFlushedContent = toSave;
}
if (isForegroundCurrent()) {
editorStore.setLastSaveTime(Date.now());
editorStore.setDirty(false);
showSaved();
}
return 'flushed';
} catch {
if (isForegroundCurrent()) {
saveStatus = 'idle';
toastStore.show('Failed to save content', 'error');
}
return 'failed';
}
}
// flushCollabNow fires the pending flush IMMEDIATELY (cancelling
// the 5s timer). Takes the explicit ctx the cleanup captured —
// reading editorInstance.storage at this instant is correct
// because Svelte runs parent $effect cleanups BEFORE child
// {#key}-driven unmounts, so the OLD editor (whose markdown we
// want) is still mounted. Used by $effect cleanup + beforeunload
// to land any in-flight markdown before the provider tears down.
function flushCollabNow(ctx: { wsSlug: string; itemId: string }, keepalive: boolean): boolean {
clearTimeout(collabFlushTimer);
collabFlushTimer = undefined;
if (!editorInstance) return false;
let md: string;
try {
md = (editorInstance.storage as any).markdown?.getMarkdown?.() ?? '';
} catch {
return false;
}
// runCollabFlush is async but its return value is irrelevant
// for synchronous callers — fire-and-forget under
// keepalive=true is the contract on the unmount path.
void runCollabFlush(ctx.wsSlug, ctx.itemId, md, keepalive);
return true;
}
// Latest raw markdown that hasn't yet been PATCHed. Tracked
// alongside contentDebounceTimer so toggling out of raw mode
// (via flushRawIfPending below) can synchronously land the
@@ -696,6 +886,14 @@
api.items.update(wsSlug, reqItemId, { content: toSave }).then((updated) => {
if (!item || item.id !== reqItemId) return;
editorStore.setLastSaveTime(Date.now());
// Raw saves change items.content via a path the
// collab dedupe doesn't see. Without resetting
// lastFlushedContent, a later collab flush could
// dedupe a content == lastFlushedContent that no
// longer reflects server state and skip the PATCH,
// leaving items.content stuck on the raw save.
// Per Codex review round 7.
lastFlushedContent = null;
// Stale-response guard: only swap in the server's
// snapshot when no newer raw edit landed during the
// PATCH. Otherwise RawMarkdownEditor's content-prop
@@ -778,6 +976,12 @@
return false;
}
editorStore.setLastSaveTime(Date.now());
// Raw saves change items.content via a path the
// collab dedupe doesn't see; reset
// lastFlushedContent so a future collab flush
// can't skip a real PATCH. Per Codex review
// round 7.
lastFlushedContent = null;
// Only swap in the server's snapshot when no
// newer raw edit arrived during the await.
// RawMarkdownEditor mirrors `item.content` into
@@ -1402,41 +1606,90 @@
<button
class="mode-btn"
class:active={rawMode}
onclick={() => {
// When toggling FROM rich+collab TO raw,
// the editor's live markdown is the
// canonical state — items.content has
// been intentionally stale since
// handleContentUpdate is suppressed
// while the provider is connected
// (TASK-1260 will close this with a
// proper 5s flush). Capture the live
// markdown so RawMarkdownEditor seeds
// from Y.Doc state rather than stale
// items.content. Per Codex review round
// 9.
if (collabProvider && editorInstance) {
onclick={async () => {
// Toggle rich+collab raw. We need to
// land the live Y.Doc state in
// items.content BEFORE activating raw
// mode so the raw editor (and any
// subsequent navigation) sees the
// current markdown. The provider stays
// connected during the await, which means
// a concurrent peer (e.g. same user's
// other tab) can keep editing the Y.Doc
// while we flush — those edits would be
// lost from the seed if we captured md
// once. Loop-flush until stable: re-read
// the editor's current markdown after
// each PATCH; if it changed, flush again.
// Capped at 3 iterations to bound the
// transition under aggressive concurrent
// typing. Per Codex review round 5.
if (collabProvider && editorInstance && item) {
const ws = wsSlug;
const itemId = item.id;
const ed = editorInstance;
try {
const md = (editorInstance.storage as any).markdown?.getMarkdown?.();
if (typeof md === 'string') {
rawSeedMarkdown = md;
// Pre-populate the pending
// queue so the first
// auto-save actually
// persists the live state
// to items.content (the
// no-room path will then
// prune the op-log + write
// items.content under the
// per-item lock).
rawPendingMarkdown = md;
editorStore.setDirty(true);
// keepalive=false on the explicit
// toggle — the await is
// foreground/synchronous and
// keepalive's ~64KB body cap can
// reject larger payloads,
// silently leaving items.content
// stale. Cleanup-driven flushes
// still use keepalive=true; this
// path doesn't need it because
// the user clicked a button and
// is expecting to wait. Per
// Codex review round 8.
let md = (ed.storage as any).markdown?.getMarkdown?.();
let lastFlushed: string | null = null;
let aborted = false;
for (let i = 0; i < 3; i++) {
if (typeof md !== 'string') break;
const result = await runCollabFlush(ws, itemId, md, false);
if (result === 'failed') {
// PATCH errored — refuse
// to enter raw mode
// rather than silently
// seed from stale state.
// runCollabFlush already
// surfaced the toast.
// Per Codex review round 8.
aborted = true;
break;
}
// 'flushed' or 'deduped' —
// items.content matches md.
// Treat both as a successful
// flush for seeding purposes.
lastFlushed = md;
if (result === 'deduped') break;
const mdAfter = (ed.storage as any).markdown?.getMarkdown?.();
if (mdAfter === md) break;
md = mdAfter;
}
if (aborted) return;
// Bail if the user navigated to a
// different item while we were
// awaiting flushes — applying
// rawMode + rawSeedMarkdown to
// the new page would be wrong.
// Per Codex review round 7.
if (!item || item.id !== itemId) return;
if (lastFlushed !== null) rawSeedMarkdown = lastFlushed;
} catch {
// Fall through; RawMarkdownEditor
// will seed from item.content.
}
}
// Cancel any pending timer-driven flush
// scheduled by edits during the await
// window — left armed, it would fire
// post-rawMode and PATCH a stale rich
// markdown over a subsequent raw save.
// Per Codex review round 5.
clearTimeout(collabFlushTimer);
collabFlushTimer = undefined;
rawMode = true;
}}
title="Raw markdown editor"