Skip to content
Documentation GitHub
Development Guides

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 snapshot
let 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 untouched
repo.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

apps/desktop/src-react/lib/loro-sync/loroHelpers.ts
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

crates/application/src/page/save_block_content.rs
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:

  1. Updates blocks.content (text), blocks.content_loro (BLOB), and blocks.content_loro_version
  2. Regenerates pages.raw_markdown from ALL blocks on the page for FTS5
  3. 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

crates/infrastructure/sqlite/src/workspace/page/block_content.rs
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

apps/desktop/src-react/lib/loro-sync/loroHelpers.ts
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

apps/desktop/src-react/lib/loro-sync/LoroSyncExtension.ts
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 PagesWrite capability

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:

apps/desktop/tests/fixtures/tauri-mock.ts
interface MockState {
blobStore: Record<string, number[]>;
// ...
}

The mock intercepts Tauri commands:

  • save_block_content stores bytes in state.blobStore[slug]
  • get_block_content_snapshot retrieves bytes from state.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 contentBytes

createLoroDocWithWikiLink(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

FilePurpose
crates/application/src/page/save_block_content.rsSave use case (validates BLOB, delegates)
crates/application/src/page/get_block_snapshot.rsLoad use case (checks permission, delegates)
crates/infrastructure/sqlite/src/workspace/page/block_content.rsSQLite storage (BLOB + FTS in single transaction)
crates/infrastructure/sqlite/src/loro_utils.rsFallback synthesis, format versioning, sync utils
apps/desktop/src-react/lib/loro-sync/LoroSyncExtension.tsTipTap extension (LoroSyncPlugin + UndoPlugin)
apps/desktop/src-react/lib/loro-sync/loroHelpers.tsloadLoroDoc() and exportSnapshot() helpers
apps/desktop/tests/fixtures/tauri-mock.tsPlaywright mock with blobStore
tests/e2e/tests/helpers/loro-helpers.tsE2E helpers for building LoroDoc snapshots
apps/codex/src/content/docs/systems/content/loro-crdt-system.mdxSystem architecture doc
docs/solutions/architecture/crdt-blob-passthrough-pipeline.mdPost-mortem on binary passthrough invariant

See Also

Was this page helpful?