Creating Prose Helpers
A prose helper is a custom Tiptap/ProseMirror node that lives in the written body (not the canvas) and renders something richer than text — a citation, a bibliography, math, a callout, a footnote, an embed. Citations were the first; this guide is the paved path for the next one.
The hard part isn't the Tiptap node — it's making it survive everywhere a document goes:
Each arrow broke at least once while citations were built. The kit below is what makes them not break for the next helper.
The prose-helper kit
| Layer | What it gives you | Where |
|---|---|---|
| Serialization discipline | A self-describing node that survives reload in WebKitGTK/Tauri (jsdom tests alone miss this) | the rules below + src/tiptap/CitationExtension.ts as the worked example |
| Projection foundation | Derived/async-rendered content that doesn't corrupt local persistence | src/tiptap/proseProjection.ts |
| Relay round-trip | The node survives the collaborative Y.Doc (HTML → Y.Doc → HTML) | relay/src/sync/prose_schema.rs (CUSTOM_PROSE_NODES), prose_parse.rs, prose_html.rs, hydration.rs |
| Export | Renders in PDF + MCP get_prose | src/utils/pdfExportUtils.ts |
Pick your category first
This decides the effort:
- Self-contained (math, callouts, footnotes, embeds) — all the helper's data lives in its own node attributes/content. → You need only the four layers above. This is the common case and is cheap.
- Data-backed (citations point at a shared reference library) — the node references out-of-band document data. → You also need a Y.Doc shared type for that data, or it won't sync in collab and concurrent edits will clobber. See Data-backed helpers. Most helpers are not this.
Building a self-contained helper
1. Define the node (serialization rules)
Custom prose nodes must serialize robustly or they vanish on reload in WebKit/Tauri (they survive jsdom, so unit tests alone won't catch it). The rules, learned the hard way:
- Store state in
data-*attributes, each with its ownparseHTML/renderHTMLinaddAttributes— no bare auto-rendered attrs. - Never name an attribute
content— it's a reserved ProseMirror schema keyword. - Array-form
renderHTML(['span', {...}]), never adocument.createElementDOM node (fragile across serializers). - Childless atoms (
atom: true) — don't nest block children in a leaf's DOM; cache any rendered HTML in adata-*attr instead. - Normalize newlines out of attribute values.
Register the node in sharedProseExtensions (src/ui/TiptapEditor.tsx) so both the standalone and collaborative editors load it.
A permanent getHTML → setContent round-trip test guards all of the above — copy the citation node's test.
2. Projection (only if the rendered content is derived)
If your node renders something computed asynchronously (a formatted citation, a KaTeX render), it caches that output back into a data-* attr so static consumers (PDF/MCP/offline) show it. That write-back is NOT a user edit — treat it as a projection or it drives autosave and corrupts local persistence (the slice-5.5 bug).
Use src/tiptap/proseProjection.ts:
- Tag the write-back transaction:
tr.setMeta(PROSE_PROJECTION_META, true)(plustr.setMeta('addToHistory', false)). - In the editor's
onUpdate, route projection transactions throughrichTextStore.setContentSilently(a non-dirtying mirror) instead ofsetContent. Do not wrap the write-back inwithAutoSaveSuppressed— that latchesisDirtyand swallows the next real edit's autosave. - Bail the write-back if
isAutoSaveSuppressed()(no dispatch during load/new/switch).
Deriving from other prose
If the node's render depends on the rest of the document — the cited-only bibliography lists only the references whose citationInline nodes are present — subscribe to the editor's update event in the nodeView and gate the re-render on a cheap derived key (e.g. the sorted set of cited ids). The gate matters: your own projection write-back fires update too, so re-rendering unconditionally loops. Recompute the key, compare, and only re-render on a real change.
3. Relay round-trip (so it survives collab)
The relay's HTML↔Y.Doc prose pipeline only knows a fixed node set; an unknown node is dropped (inline → unwrapped to text; block → unwrapped to children). <img> is the precedent for a void-with-attrs node that survives. Mirror it:
relay/src/sync/prose_schema.rs— add a row toCUSTOM_PROSE_NODES:(pm_type, html_tag, marker_data_attr). This is the single source of truth for the three coordinates; the parser usescustom_node_pm()to detect it.prose_parse.rs(HTML → PM) — recognize yourtag[data-marker]before the unknown-node fallback and emit aPmNodewith the PM attr names (camelCase — they must match the editor node'saddAttributeskeys, not thedata-*names). Inline nodes go incollect_inline; block nodes inmap_block_element.prose_html.rs(PM → HTML) — add ablock_forarm returningBlock::Void(your_html(el, txn)); write attrs in a fixed order, escaped withescape_attr. Model it oncitation_html/image_html.hydration.rs— add your node type toblock_has_substanceso a block that's childless-but-meaningful (or a paragraph containing only your inline atom) seeds instead of being mistaken for the empty-page placeholder.
A new node attribute needs the relay round-trip to be durable in collab
A custom attribute (beyond the ones already wired) survives local docs via getHTML/JSON and live collab via peer Y.Xml attribute sync — but the relay's HTML↔Y.Doc pipeline drops attrs it doesn't serialize, so a relay cold-rehydrate (eviction + rejoin, or a restart) resets the attr to its node default. Add it to prose_html + prose_parse (the steps above) to make it fully durable. A view preference whose default is the safe direction — the bibliography's scope resets to cited-only, which never hides a cited reference and loses no library data — can ship best-effort without the relay change; real content must round-trip.
No PROTOCOL_VERSION bump and no document migration — this is purely additive (the frames stay lib0-v1 sync; an older client just doesn't round-trip the new node). Add a full HTML → html_to_blocks → build_prose_node → fragment_to_html round-trip test — that's the regression that proves it survives the collaborative pipeline, not just jsdom.
TIP
The parse/serialize handlers are explicit per node today (the two citation nodes differ in shape: inline-atom-with-text-child vs childless block). When a third or fourth node makes them feel repetitive, data-drive them off CUSTOM_PROSE_NODES — it's a clean, low-risk refactor. mathInline/mathBlock is the named next entry.
4. Export
Register a renderer in src/utils/pdfExportUtils.ts (pdfNodeRenderers.register
- handle the node in
extractSegments) so the node appears in PDF export and is carried through MCPget_prose.
Data-backed helpers
If your node references document-level data outside the node (as a citation references the shared reference library), that data must become a Y.Doc shared type too — otherwise it won't sync in collaboration and concurrent edits will clobber it a whole field at a time. The reference library is the worked example (references Y.Map + referenceOrder Y.Array):
- Relay owns it: seed on hydrate (
hydration.rs, idempotent + run on the binary-sidecar backstop path too), re-assert on flatten (flatten.rs), and let MCP writes hit the live Y.Doc when resident. - Client binds it:
src/collaboration/YjsDocument.ts(shared-type accessors- a coarse change observer) ↔ the feature store, wired in
useCollaborationSync.ts(remote → bulk-reload underrunWithProvenance('remote-apply'); local → per-item writes, provenance-gated).
- a coarse change observer) ↔ the feature store, wired in
Two invariants make concurrent edits safe:
- Writes are strictly per-item (
map.set/deleteby id) — never a whole-map rewrite from a store snapshot (that wipes a concurrent writer's not-yet-seen entry). - Reads bulk-reload the merged map — so the store (and any autosave) always reflects every writer.
The hard rules (don't relearn these)
- A projection write-back is not a user edit. Tag it; mirror it silently. (Local-persistence corruption otherwise.)
- Serialize robustly —
data-*atoms, arrayrenderHTML, childless, no reserved attr names. (WebKit reload-vanish otherwise.) - Round-trip through the relay — unknown nodes are dropped on the collab Y.Doc path. (Citations/bibliography degraded to plain text until this landed.)
- Out-of-band data needs a shared type. (No live sync + concurrent-add clobber otherwise.)
Checklist
- [ ] Tiptap node, registered in
sharedProseExtensions, follows the 5 serialization rules - [ ]
getHTML → setContentround-trip test - [ ] (if derived) projection write-back via
PROSE_PROJECTION_META+setContentSilently - [ ]
CUSTOM_PROSE_NODESrow +prose_parsearm +prose_htmlarm +block_has_substanceentry - [ ] Relay HTML → Y.Doc → HTML round-trip test
- [ ] PDF/
get_proserenderer - [ ] (data-backed only) Y.Doc shared type + binding + the two invariants + a concurrency test
