Staged rich text
Staging Yjs content through update callbacks
works for programmatic edits — an agent appending to a draft. An interactive
editor is different: Tiptap or ProseMirror holds a Y.Doc and renders
keystrokes from it. For that, the session hands the editor a real,
long-lived Y.Doc.
The staged Y.Doc
Section titled “The staged Y.Doc”session.getYDoc({ docId, yjsId }) hands back a copy of the live Y.Doc,
seeded from live state. Bind y-prosemirror to it and the editor is editing the
copy directly, while the live document stays untouched. Reading a copy stages
nothing — the first local edit promotes it into the
changeset, which is where the copy’s
identity and lifecycle live. Repeat calls return the same copy, and so do two
separate sessions on the same target.
Because the copy is just another synced Y.Doc of the host document, everything Yjs already does, it does here for free: edits sync as ordinary Yjs updates, two sessions’ editors converge on each other’s staged content before either commits, and offline editing and exact state-vector catch-up ride the host document’s sync — with no delta capture or debounced fold in between.
The copy also tracks the live target during the session: concurrent upstream edits to the live document are mirrored into it as they arrive, so an editor sees what changed upstream instead of only discovering it at commit. That relayed upstream is a local overlay — never persisted into the copy or synced to other participants, and re-derived on each acquire, so a server-side session survives Durable Object hibernation without holding a live subscription across it.
At commit, the copy’s full state merges back into the live target — cleanly,
because the copy was seeded from live, so re-applying it no-ops the seed and
adds only the staged edits. session.discard() has nothing to un-apply: the
copy was forked from the live document, never applied to it. Either way the
changeset entry clears and the copy is reclaimed; re-acquire after a discard
and you get a fresh copy, seeded again from current live state.
Cursors ride presence
Section titled “Cursors ride presence”session.getYjsAwareness({ docId, yjsId }) is the cursor half: one shared
y-protocols Awareness per staged doc, bridging the copy. Editors in the same
session instance get caret visibility with no transport at all. For a persisted
session, the cursors ride this session’s own
presence cell — the sys:awareness field of its
self in the target’s presence document — so they travel between participants’
instances too.
Two properties are deliberate:
- Ephemeral by construction. Presence never touches storage: cursors vanish on disconnect and don’t survive reload. The staged content does.
- Resolvable only by participants. The cursors are positions into the copy’s CRDT structs, and only session participants hold those structs. A non-participant who sees the cell cannot resolve them — they point into content it doesn’t have.
Why this matters for review
Section titled “Why this matters for review”This is the missing half of sessions as agent workflow: an agent stages a draft, a human opens a real editor on the staged text and refines it — with live cursors if a second reviewer is in the session — and nothing touches the live document until commit. Review stops being read-only.