Work with Loro CRDT
Task-oriented guide for reading, writing, and testing CRDT-backed block content.
When You Need This
- Editing or saving block content in the editor
- Adding new CRDT-backed fields or content types
- Working with grid layouts (multi-cell editors)
- Writing integration or E2E tests that involve block content
Prerequisites
- Familiarity with the vertical slice architecture (see Adding a Tauri Command)
- Development environment set up (see Getting Started)
- For frontend work: understanding of TipTap/ProseMirror basics
The Binary Passthrough Rule
This is the most important rule when working with Loro in this codebase:
The frontend’s CRDT binary snapshot is the source of truth. The backend stores it unchanged. Text is a derived projection for search indexing.
A LoroDoc binary snapshot carries structural information that plain text cannot represent: operation history, version vectors, author IDs, and timestamps. Re-serializing from text destroys all of this silently — content appears correct, but undo/redo resets, sync breaks, and version history collapses.
Anti-pattern (BROKEN — destroys CRDT history)
// WRONG: Creates a brand-new LoroDoc from text, discarding the real snapshotlet blob = text_to_loro_bytes(&content_text);repo.save_block_content_blob(slug, &blob, &content_text)?;Correct pattern (preserves CRDT history)
// RIGHT: Pass the frontend's binary snapshot through untouchedrepo.save_block_content_blob(slug, &content_bytes, &content_text)?;The only acceptable use of text_to_loro_bytes() is at read time for pre-migration content where the BLOB column is
NULL or empty. This creates a fresh single-operation CRDT graph, which is correct for legacy data with no prior history.
How to Save Block Content
The save path flows from the TipTap editor through to SQLite in five steps.
1. Editor triggers a debounced save
The LoroSyncPlugin keeps the in-memory LoroDoc in sync with ProseMirror transactions. After 1.5 seconds of
inactivity, the save fires.
2. Frontend exports the snapshot
export function exportSnapshot(doc: LoroDoc): number[] { const bytes = doc.export({ mode: "snapshot" }); return Array.from(bytes);}The Uint8Array is converted to number[] because Tauri’s JSON-based IPC serializes Vec<u8> as a number array.
3. Tauri command receives both binary and text
The command receives content_bytes: Vec<u8> (the CRDT snapshot) and content_text: String (materialized markdown for
FTS). It passes both through to the use case without modification.
4. Use case validates and delegates
pub fn execute( &self, guard: &PermissionGuard, slug: &str, content_blob: &[u8], content_text: &str,) -> PageResult<()> { guard.require(Capability::PagesWrite)?; if content_blob.is_empty() { return Err(PageRepositoryError::Validation( "Block content BLOB cannot be empty".into(), )); } self.repository.save_block_content_blob(slug, content_blob, content_text)}5. SQLite stores BLOB and regenerates FTS
In a single transaction, the repository:
- Updates
blocks.content(text),blocks.content_loro(BLOB), andblocks.content_loro_version - Regenerates
pages.raw_markdownfrom ALL blocks on the page for FTS5 - Updates
pages.updated_at
See crates/infrastructure/sqlite/src/workspace/page/block_content.rs:47 for the full implementation.
How to Load Block Content
The load path is the reverse of the save path.
1. Frontend requests the snapshot
On page or cell mount, the frontend calls get_block_content_snapshot (or get_block_content_snapshot_by_id for grid
cells).
2. Repository reads the BLOB
match row { Some((Some(blob), _)) if !blob.is_empty() => Ok(Some(blob)), Some((_, text)) => { // Fallback: synthesize LoroDoc from text (pre-migration only) let content = text.as_deref().unwrap_or(""); let (bytes, _version) = text_to_loro_bytes(content); Ok(Some(bytes)) } None => Ok(None),}If the BLOB exists: return it directly. If NULL or empty (pre-migration data): synthesize from text. If no row: return
None.
3. Frontend loads the LoroDoc
export function loadLoroDoc(bytes: number[]): LoroDoc | null { if (!bytes || bytes.length === 0) return null; try { const uint8 = new Uint8Array(bytes); return LoroDoc.fromSnapshot(uint8); } catch (err) { logger.warn({ event: "loro_doc_snapshot_load_failed", error: String(err) }); return null; }}Returns null on empty or invalid input so callers can create a fresh LoroDoc as fallback.
4. LoroDoc is wired to the editor
LoroSync.configure({ doc: myLoroDoc })LoroSyncPlugin initializes the ProseMirror editor state from the LoroDoc contents. LoroUndoPlugin replaces TipTap’s
built-in undo/redo with Loro-backed undo/redo that persists across sessions.
How to Work with Grid Layouts
Grid layouts use CSS Grid with named areas. Each named area maps to a block with its own independent LoroDoc.
The per-cell isolation rule
Never share a LoroDoc across grid cells. Each cell loads and saves independently.
This ensures:
- Undo in one cell does not affect another cell
- Each cell has its own debounce timer
- Sync operates at block granularity
Cell load/save
Grid cells use the _by_id variants of the commands:
- Load:
get_block_content_snapshot_by_id(slug, blockId) - Save:
save_block_content_by_id(slug, blockId, contentBytes, contentText)
The GridLayout component renders a CSS Grid container. Each BlockCell independently mounts its own TipTap editor
with its own LoroSync extension. Empty areas show an EmptyCell placeholder — blocks are created on-demand via
create_block_in_area.
How to Test CRDT Round-Trips
Integration tests (Rust)
Create a LoroDoc, save via the repository, reload, and verify the BLOB survives:
#[test]fn test_loro_blob_preserves_operation_history() { let mut doc = LoroDoc::new(); let text = doc.get_text("content"); text.insert(0, "hello").unwrap(); text.insert(5, " world").unwrap();
let blob = doc.export(ExportMode::Snapshot).unwrap(); // Save blob through the repository, then reload let restored = LoroDoc::new(); restored.import(&blob).unwrap();
assert_eq!(restored.get_text("content").to_string(), "hello world");}Application layer tests
The SaveBlockContentUseCase tests verify:
- Empty BLOB is rejected (
test_save_block_content_rejects_empty_blob) - Arguments pass through to the repository unchanged (
test_save_block_content_forwards_arguments_to_repository) - Permission guard enforces
PagesWritecapability
See crates/application/src/page/save_block_content.rs:174 for the full test suite.
How to Use the Frontend Mock
For Playwright tests, tauri-mock.ts provides a blobStore that simulates the backend’s BLOB storage:
interface MockState { blobStore: Record<string, number[]>; // ...}The mock intercepts Tauri commands:
save_block_contentstores bytes instate.blobStore[slug]get_block_content_snapshotretrieves bytes fromstate.blobStore[slug]
This lets Playwright tests verify full CRDT round-trips without a real backend.
How to Use E2E Helpers
The tests/e2e/tests/helpers/loro-helpers.ts file provides two helper functions for constructing LoroDoc snapshots with
ProseMirror-compatible node structure:
createLoroDocWithText(text)
Creates a snapshot with a single paragraph containing plain text:
import { createLoroDocWithText } from './helpers/loro-helpers';
const contentBytes = createLoroDocWithText('Hello world');// POST to save_block_content bridge route as contentBytescreateLoroDocWithWikiLink(options)
Creates a snapshot with optional leading text and a WikiLink atom node:
import { createLoroDocWithWikiLink } from './helpers/loro-helpers';
const contentBytes = createLoroDocWithWikiLink({ leadingText: 'See also ', pageName: 'My Page', pageSlug: 'my-page',});Both functions build the full loro-prosemirror node structure (doc > paragraph > text/wikiLink) with the correct map
keys (nodeName, attributes, children) that the sync plugin expects. The returned number[] can be sent directly
to the backend.
Common Mistakes
1. Re-serializing from text (data loss)
Calling text_to_loro_bytes() on saved content destroys the CRDT operation graph. Content appears to work but
undo/redo, sync, and version history all break silently. See The Binary Passthrough Rule
above.
2. Sharing a LoroDoc across grid cells
Using one LoroDoc for multiple cells causes merge conflicts and cross-cell undo interference. Each cell must have its
own LoroDoc instance loaded via get_block_content_snapshot_by_id.
3. Forgetting to update raw_markdown alongside the BLOB
Every save must update both the BLOB and the FTS index. The repository handles this in a single transaction — if you
add a new save path, make sure it follows the same pattern of regenerating pages.raw_markdown from all blocks.
4. Using the text column as source of truth
blocks.content (TEXT) and pages.raw_markdown (TEXT) are derived projections. They exist for FTS5 search and
debugging. The blocks.content_loro BLOB is the only source of truth for content.
Key Files Reference
| File | Purpose |
|---|---|
crates/application/src/page/save_block_content.rs | Save use case (validates BLOB, delegates) |
crates/application/src/page/get_block_snapshot.rs | Load use case (checks permission, delegates) |
crates/infrastructure/sqlite/src/workspace/page/block_content.rs | SQLite storage (BLOB + FTS in single transaction) |
crates/infrastructure/sqlite/src/loro_utils.rs | Fallback synthesis, format versioning, sync utils |
apps/desktop/src-react/lib/loro-sync/LoroSyncExtension.ts | TipTap extension (LoroSyncPlugin + UndoPlugin) |
apps/desktop/src-react/lib/loro-sync/loroHelpers.ts | loadLoroDoc() and exportSnapshot() helpers |
apps/desktop/tests/fixtures/tauri-mock.ts | Playwright mock with blobStore |
tests/e2e/tests/helpers/loro-helpers.ts | E2E helpers for building LoroDoc snapshots |
apps/codex/src/content/docs/systems/content/loro-crdt-system.mdx | System architecture doc |
docs/solutions/architecture/crdt-blob-passthrough-pipeline.md | Post-mortem on binary passthrough invariant |
See Also
- Loro CRDT System — architecture and design decisions
- CRDT Blob Passthrough Pipeline — the post-mortem that established the binary passthrough rule
- Per-Cell CRDT Isolation — grid layout architecture
- Block Content System — broader block content architecture
Was this page helpful?
Thanks for your feedback!