What this post covers
When editing a document of stacked blocks on the web — like Notion or Google Docs — instead of "one editor for the whole document," this covers the pattern of splitting into host (the document owner) / guest (the focused-block visitor), and the four problems that arose on top of it.
The simplest implementation is "the whole document = one rich-text editor." Typing is easy but it soon hits a wall — permissions, subscriptions, and saving get tied to "the tree the editor holds," it's hard to attach different UI per block (embeds, widgets), and even with hundreds of blocks one editor must keep all of them alive. So you split roles: the Host is the document's real owner (SoT) holding the block list, patches, permissions, and saving; the Guest is a visitor editor that edits only the one block where the cursor currently is. Unfocused blocks are drawn as read-only HTML, the guest mounts only when a click brings focus in, and when focus leaves the guest returns its content to the host (commit) and disappears.
Day summary
| Task | What I wanted | What I did | Result |
|---|---|---|---|
| Host/Guest | Document changes only through a patch gateway — guest owns only the focused block | inject → commit → keymap delegation; commit-before-delegate before structural ops | stale commits don't overwrite changed host state |
| External store | Re-render without flakes when binding state outside React | give snapshots a monotonic revision (iso#n) | re-renders aren't dropped even on same-ms mutations |
| Undo | do/inverse 2-stack instead of copying the whole snapshot | { undo, redo } 2-stack; mutually exclude record and normalizer during replay | memory saved + redo collapse prevented |
| DnD·copy | Complement the guest's one-block limit with browser-native | HTML5 DnD + host patch; keep the handle glyph out of the copy path via a ::before pseudo | cross-block move possible, decorative glyph not mixed into copy |
1. Host / Guest — the document is owned by patches
Background (concepts)
- Source of Truth (SoT): "where the real data lives." Document structure and patches are owned by the host.
- Guest editor: a rich-text engine like ProseMirror/TipTap edits only the currently focused block.
What the situation was
When the guest (rich-text engine) behaves like "the whole document," block splitting, type changes, and permission checks become dependent on the guest's internal state. When requirements like "this block only read-only" or "this block only live-subscribed" arrive later, you fight the editor API. I wanted to receive document changes only through a patch gateway.
Core work
- Put a patch-apply gateway on the host. All document changes go in only as patches like
[{ op: "update", id, content }, …]. - The guest receives only the
contentof the focused block: inject (on focus, host puts the current content into the guest) → commit (on unfocus/eviction, guest returns content to host) → keymap delegation (structural keys like "convert to heading" are not handled by the guest but passed to a host callback). - Keys are also split into layers — text/marks (bold, character input) are Guest, structure (block type change/move) is Host, and no-focus (multi-block selection mode) is the Host selection mode.
- Stale-commit prevention: if an old guest issues a late
blurcommit right after a structural op, it overwrites already-changed host state with old content. So before a structural op, always commit the current guest content first (commit-before-delegate), then apply the host patch, and retire that lease.
Takeaways
- The document is the SoT of host patches, and the editor is merely the guest of the focused block.
2. useSyncExternalStore — the trap of "judging it unchanged even though it changed"
Background (concepts)
useSyncExternalStore: the official hook for binding a store outside React to the UI.
What the situation was
For stores outside React — like the focused block id or layout state — you use useSyncExternalStore to bind them to the UI. Even when React is told the store "changed," if the value returned by getSnapshot() is Object.is-equal to the previous one, it doesn't re-render. If the snapshot contains only a millisecond timestamp like new Date().toISOString(), then when "mount + immediate update" happen back-to-back within the same 1ms, the two snapshot strings are identical, so React sees "no change" and doesn't draw the UI. I wanted to re-render without flakes.
Core work
Attach an always-increasing revision to the snapshot.
Components that go through SSR must also pass getServerSnapshot.
Takeaways
subscribewas called ≠ the snapshot value differs — don't just use a timestamp, use a revision.
→ useSyncExternalStore snapshot identity · SSR hydration mismatch
3. Undo/Redo — "forward/back" pairs instead of snapshots
Background (concepts)
- do/inverse 2-stack: if an edit is already expressed as a "patch," you only need its inverse to undo it. Manage it with two stacks, undo and redo.
What the situation was
Copying the whole document onto a stack is memory-heavy and easily breaks structural sharing and memoization. I wanted to go with a do/inverse 2-stack instead of copying the whole snapshot.
Core work
One entry = { undo: inverse patch, redo: original patch }.
- User edits → the apply gateway computes do and inverse together → push to the undo stack, clear the redo stack.
- Undo → pop the undo stack → apply
undo→ push to the redo stack. - Redo → pop the redo stack → apply
redo→ push to the undo stack.
During undo/redo replay, don't record to history again, and turn off the auto normalizer too — because invariants clash with the redo state and redo collapses.
Takeaways
- A single apply gateway must exist before the Undo UI for the inverse to be trustworthy.
4. Drag·copy — don't fight the browser
Background (concepts)
- HTML5 Drag and Drop: when the guest is alive only on one focused block, solve cross-block moves with browser-native DnD.
- Pseudo content: putting a decorative glyph in
::before { content }rather than DOM text keeps it out ofSelection.toString()and copy.
What the situation was
The guest is alive only on one focused block, making it hard to move text across blocks (problem A). Also, putting the handle glyph ⠿ as DOM text mixes it into copy (problem B). I wanted to complement the guest's one-block limit with browser-native means.
Core work
- Problem A (cross-block move): use HTML5 Drag and Drop across the whole container, and reflect the drop result via a host patch.
preventDefault()ondragoveris required; swapping DOM mid-drag breaks the session, so clean up the guest at drag start; E2E verifies the path by dispatchingDragEventdirectly. - Problem B (handle glyph): put
⠿as::before { content: "⠿" }pseudo content to exclude it from the copy path.
Takeaways
- Don't reimplement what the browser does well (selection, drag) — fill only the missing gaps with host patches.
- Decorative glyphs go in pseudo content, not DOM text — so they don't mix into
Selection.toString().
→ HTML5 drag and drop · Selection.toString() and pseudo content