CardMirror embed persistence — why a loaded file can't go blank

The rules that decide when an edit in the CardMirror embed becomes a write to the cloud, and what happens when a document can't be loaded. This is the fix for "after a file is loaded it sometimes disappears and goes blank" — which was never only a display bug: the blank was reported as the document's new content and saved over the file.

  • Routes: /reason-editor, and Flow's speech-doc panels (same embed)
  • Packages: debate-editor (src/react/change-reporter.ts, src/react/singleton.ts, src/react/html-bridge.ts), apps/debate-ai.com (lib/reason-docs/save-queue.ts, components/reason-docs/ReasonDocsProvider.tsx)
  • Storage: the documents D1 table, through /api/doc/documents/:id

The shape of the problem

CardMirror's engine (packages/debate-editor/src/editor/index.ts) is a page-level singleton that assumes it owns the window: one EditorView, one current document, its own file/recovery machinery. The React embed hands the host app's document to that same view and treats what comes back out as the document's content — onChange(html) in, PUT /api/doc/documents/:id out.

Everything below follows from those two facts meeting: the engine replaces its editor state on its own initiative, and whatever is in the view when it does gets saved as the user's file.

The engine itself is upstream CardMirror (the packages/debate-editor-cm submodule), assembled with debate-ai.com's patch into packages/debate-editor/src/ at install time. The two seams the embed leans on — adoptEmbeddedDoc (tells the engine which document the host mounted) and the hostPlugins() slot in buildEditorPlugins (keeps the change reporter across every plugin rebuild) — are debate-ai.com edits to upstream's editor/index.ts, recorded in packages/debate-editor/patches/debate-ai.patch and carried forward by scripts/sync-upstream.mjs --rebase on each upstream bump. An upstream sync that moved or dropped either one breaks this page's guarantees, so check both after resolving a conflict in index.ts.

An edit is a transaction, nothing else

change-reporter.ts sorts every doc change into three kinds and only the first reaches the host:

ChangeReported?
A transaction that changed the docYes — a real edit
A transaction tagged LOAD_METANo — the singleton loading host content
The whole editor state (or view) replacedNo — and see below

Plugin state.apply runs for transactions only, so an edit counter kept there rises on a real edit and resets to init when the state is replaced wholesale. That is the whole discrimination. And because ProseMirror does not call a plugin view's update when the state is replaced — it destroys the plugin views and builds new ones, and the engine's mountView builds a whole new EditorView besides — the plugin's view() factory running again is the signal that the state underneath was swapped out.

Deleting everything in a document is still a transaction, so a user who empties a file still empties it in the cloud. What can no longer do that is the engine mounting its blank starter doc.

A blank the user didn't ask for is put back

When the state is replaced by something other than the singleton's own load and what landed is blank while the host's document wasn't, the singleton re-loads the host's document into the new view (restoreIfEngineBlankedTheDoc). A replacement carrying real content — the ribbon's own Open, a joined collaboration session — is left alone.

The engine also gets told which document is mounted (adoptEmbeddedDoc). Its currentDoc is the fallback content for any remount it does on its own, and in an embed it otherwise still held the blank starter from boot — so mountView(currentDoc) on a spawned-session mount or a damaged-file fallback remounted that blank over the host's file. That is one concrete way the pane used to go blank; declaring the mounted doc removes it at the source, and the restore above covers the paths that don't consult currentDoc at all.

Content that doesn't parse is not content

html-bridge.ts used to answer "this didn't parse" and "this document is empty" with the same blank doc. For a host whose stored document IS that HTML string, the difference is everything: the blank mounted, got reported as the new content, and the file was gone.

parseHtml now reports which it was. Non-empty input that comes out blank — foreign markup from a previous editor, a truncated fragment — is a failed load: the singleton keeps whatever is already on screen rather than blanking it, files a durable status notice, and stops reporting that document entirely, so nothing typed into the pane can overwrite the intact copy on the server. A later load of the same document that parses cleanly lifts the block.

Footnotes were also being dropped by every round trip: a footnote ref serializes to an empty <sup> carrying its text in data-content, and ProseMirror collects mark rules before node rules, so the generic superscript mark rule matched first and the footnote came back as a mark on no text. The node's parseDOM rule now carries priority: 60. Ordinary superscript text is unaffected — that rule only matches .pmd-footnote-ref.

Edits are filed under the document they were made in

Change reporting is debounced ~400 ms. The debounce is flushed before the live binding changes (claim of a different key, release on pane close) and when the page is hidden or unloaded. Without that flush, switching documents inside the window either dropped the last edits or reported them under the document you switched to.

The content prop coming back from the host is matched against a bounded ring of recently emitted strings, not just the last one: the host's state round-trips through React, so a stale echo can arrive after a newer one, and re-applying it would throw away whatever was typed in between.

Reaching the cloud

lib/reason-docs/save-queue.ts owns the write side. One debounce per document, with merged patches — the previous single shared timer meant the next edit cancelled whatever was still waiting, so renaming a file and then typing in another one silently dropped the rename, and switching documents inside the debounce dropped the edits to the one you left.

  • flushAll({ keepalive: true }) on pagehide and on the tab going hidden, so closing a tab mid-sentence doesn't lose the sentence.
  • A failed write stays queued and retries with backoff, merging any newer edit over the failed patch so a retry never resurrects stale content.
  • onStateChange drives the editor's header: Saving…, Unsaved changes, or Couldn't save — retrying… — the last of which used to show as a Saving… that never resolved.
  • A document being deleted has its queued writes cancelled, so no PUT lands after the DELETE.
  • moveDocument (dragging a file to a new folder) queues its parentId patch through the same queue as title/content edits, so a re-parent gets the same retry-with-backoff and pagehide/tab-hide flush.

What the embed doesn't pop up on open

The site mounts CardMirror on nearly every visit, so the engine's first-launch and crash-recovery surfaces are switched off in the embed (debate-ai.com edits to upstream, in patches/debate-ai.patch):

  • Recover drafts sidebar — runStartupRecoveryInner returns before openRecoverySidebar when BOOT_EMBEDDED. Journals are left on disk, untouched; the mode-switch auto-reopen above that check still runs.
  • UI tour and Verbatim nudge — not auto-started when detectEmbedded(). Help → UI tour still starts the tour by hand.
  • "Receiving cards in-browser needs your account" notice — shown once per browser (localStorage key cardmirror.pairingAccountRequiredSeen) instead of on every mount.

The outline nav pane still opens beside the document as before.

.cmir files open in read mode

Opening any .cmir document turns read mode on, the same state the ribbon's Read mode button toggles:

  • REASON editor — ReasonEditorScreen passes defaultReadMode for Topic Starters and uploaded documents (stored as .cmir); HTML rows written in the editor still open editable.
  • importCmir on the embed's imperative handle claims with readMode: true.
  • Engine file open — loadFileInPlace sets readMode after mounting a .cmir (Open, recents, drag-and-drop, command-palette file search).

It's applied once, when a document is mounted (claim's new-key branch), so turning read mode off sticks for that document until another one opens.

Tests

  • packages/debate-editor/test/singleton-read-mode.test.ts — read mode carries across doc switches, and a claim with readMode (a .cmir open) turns it on without re-forcing it on a re-claim of the live doc
  • packages/debate-editor/test/change-reporter.test.ts — edit vs. load vs. state replacement, including a whole new EditorView
  • packages/debate-editor/test/html-bridge.test.ts — card/footnote round trip, and parseHtml's empty-vs-lost distinction
  • apps/debate-ai.com/lib/reason-docs/__tests__/save-queue.test.ts — every way the old shared timer lost work
  • packages/debate-editor/test/status-notices.test.ts — the unreadable-doc notice's Download button (rendered only when a payload is attached, survives a coalesced repeat, Blob/anchor download fires on click)
  • packages/debate-editor/test/singleton.test.ts — sanitizeForFilename's handling of path separators and other filename-unsafe characters
  • packages/debate-editor/test/engine-boot.test.ts — boots the real engine inside RIBBON_HTML the way the singleton does, so an upstream sync that needs a new element the embed markup lacks fails in CI instead of at load
  • packages/debate-editor/test/upstream-sync.test.ts — the patch applies to the pinned upstream commit and no edit under the generated src/ went unrecorded

Known gaps

  • The singleton refuses to save a document it couldn't read, which also means work typed into that pane is discarded. Closed: markUnreadable now attaches the raw HTML that failed to parse to the notice as a download payload, and the notice panel renders a "Download" button next to Copy/Dismiss that saves it as unreadable-<key>.html (<key> run through sanitizeForFilename, since it's a free-form document title, not already filename-safe). The server's stored copy is still untouched and still the one thing to trust — this only rescues whatever was in the pane that would otherwise just be discarded when the user navigates away.
  • Flow's speech docs ride the same embed and so inherit every guard here, but their own persistence (inside the Flow object, via /api/flows) has no equivalent per-document queue.