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
documentsD1 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:
| Change | Reported? |
|---|---|
| A transaction that changed the doc | Yes — a real edit |
A transaction tagged LOAD_META | No — the singleton loading host content |
| The whole editor state (or view) replaced | No — 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 })onpagehideand 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.
onStateChangedrives the editor's header:Saving…,Unsaved changes, orCouldn't save — retrying…— the last of which used to show as aSaving…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 itsparentIdpatch through the same queue as title/content edits, so a re-parent gets the same retry-with-backoff andpagehide/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 —
runStartupRecoveryInnerreturns beforeopenRecoverySidebarwhenBOOT_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 (
localStoragekeycardmirror.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 —
ReasonEditorScreenpassesdefaultReadModefor Topic Starters and uploaded documents (stored as.cmir); HTML rows written in the editor still open editable. importCmiron the embed's imperative handle claims withreadMode: true.- Engine file open —
loadFileInPlacesetsreadModeafter 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 withreadMode(a.cmiropen) turns it on without re-forcing it on a re-claim of the live docpackages/debate-editor/test/change-reporter.test.ts— edit vs. load vs. state replacement, including a whole newEditorViewpackages/debate-editor/test/html-bridge.test.ts— card/footnote round trip, andparseHtml's empty-vs-lost distinctionapps/debate-ai.com/lib/reason-docs/__tests__/save-queue.test.ts— every way the old shared timer lost workpackages/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 characterspackages/debate-editor/test/engine-boot.test.ts— boots the real engine insideRIBBON_HTMLthe way the singleton does, so an upstream sync that needs a new element the embed markup lacks fails in CI instead of at loadpackages/debate-editor/test/upstream-sync.test.ts— the patch applies to the pinned upstream commit and no edit under the generatedsrc/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:markUnreadablenow attaches the raw HTML that failed to parse to the notice as adownloadpayload, and the notice panel renders a "Download" button next to Copy/Dismiss that saves it asunreadable-<key>.html(<key>run throughsanitizeForFilename, 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
Flowobject, via/api/flows) has no equivalent per-document queue.
