What this post covers
Building a block-based rich-text editor, ordering, concurrent editing, undo, IME, testing, and scoped UI all overlap in one day. The topics look different, but the common goal is one: reliably record, undo, and verify editing intent. This organizes, per task, what situation each work was needed for and why, what was chosen, and what was learned.
Day summary
| Task | What I wanted | What I did | Result |
|---|---|---|---|
| Order | Deterministic order even under concurrent insert — without renumber | fractional position key + neighbor anchor (after/before) + (position, clientId, opId) tie-break | ordering kept without renumber conflict on middle/concurrent inserts |
| Collab | optimistic + rollback → re-apply on remote arrival | store inverse patch in the outbox, merge authority is server seq | rebase possible on remote arrival, re-apply failure not silently dropped |
| undo | Explicit delegation to document undo when guest can't handle it | chainCommands(inlineUndo, delegateDocumentUndo) + Playwright verification | undo blocked by preventDefault delegated to the document |
| IME | Defer commit during composing | composing flag + guest mount-once | CJK breakage during composition resolved |
| Portal | Keep scoped CSS — consider inline render for small menus | small toolbar/image control rendered inline with Portal off | scoped styles that leaked out of the Portal kept |
| Jest | Load an ESM-only dependency in CJS Jest | vendor TS shim + moduleNameMapper | stable import even from a nested install path |
1. Fractional ordering
Background (concepts)
- Block editor: a document UI where each paragraph, heading, and list is a "block" (Notion/Google Docs style).
What the situation was
Storing sibling-block order as an array index causes frequent renumber conflicts on middle inserts, concurrent inserts, and offline replay.
Core work
Put a sortable string position as the storage SoT, and express intent in the patch not as "which index" but as a neighbor anchor (after/before). Concurrent inserts create deterministic order with a (position, clientId, opId) tie-break. On subtree move/promote, position reuse breaks sibling sort, so bounds-based reassignment is needed. As a result, ordering was kept without renumber conflict on middle/concurrent inserts.
Takeaways
- Ordering is ultimately a contract problem like anchor and origin.
- On subtree move, position reuse breaks sibling sort, so bounds-based reassignment is needed.
2. Optimistic outbox rebase
Background (concepts)
- Optimistic UI: a pattern that reflects changes locally before the server responds, to show them fast.
What the situation was
After an optimistic apply that shows locally first, when a change from the server/another client arrives, you must roll back and re-apply (rebase).
Core work
Each pending entry in the outbox must store its inverse patch so that rollback→re-apply rebase is possible on remote arrival. Merge authority is better placed on the server seq than on the wall-clock. Re-apply failure is not silently dropped. As a result, rebase became possible on remote arrival, and re-apply failures are no longer quietly discarded.
Takeaways
- Undo and collab are both contract problems like inverse and origin.
- Re-apply failure is not silently dropped.
3. Keyboard undo delegation
Background (concepts)
- contenteditable: the browser's built-in editable region. The guest rich-text engine preventDefaults Ctrl/Cmd+Z while focused to block native undo.
What the situation was
The guest rich-text engine preventDefaults Ctrl/Cmd+Z while focused to block native undo. Even when the inline history is empty, the event behaves as if "consumed," so a defaultPrevented guard in the parent handler alone can't reach document undo.
Core work
Explicitly fall back with chainCommands(inlineUndo, delegateDocumentUndo) and verified with Playwright. As a result, undo blocked by preventDefault was delegated to the document.
Takeaways
- Undo is a contract problem like inverse and origin.
- jsdom green can't be grounds for preventDefault, so real-browser E2E is needed.
→ Contenteditable keyboard history delegation · Two-stack inverse undo
4. IME composition
Background (concepts)
- IME: composition input for Korean, Japanese, etc. Commit must be deferred during
compositionstart~compositionend.
What the situation was
If commit/serialize/re-render cut in during composition, Korean breaks. jsdom tests don't faithfully reproduce composition.
Core work
Before a custom composition event, a composing flag to defer commit + guest mount-once comes first. As a result, CJK breakage during composition was resolved.
Takeaways
- jsdom doesn't faithfully reproduce composition, so verify IME grounds with real-browser E2E.
→ IME composition in contenteditable
5. Radix Portal vs scoped CSS
Background (concepts)
- Radix Popover Portal: a pattern that renders popover content outside the DOM tree. It escapes the scoped CSS ancestor.
What the situation was
[data-scope] .popover styles are not applied to Portal content.
Core work
Small toolbar/image control popovers keep scope with Portal off, inline render, and tests query the inner control after opening the trigger. As a result, scoped styles that leaked out of the Portal were kept.
Takeaways
- For a small editor menu using scoped CSS, consider inline rendering first.
6. Jest + ESM-only dependency
Background (concepts)
- ESM-only package: an npm package that provides only ES Modules, no CommonJS. It throws import errors in CJS Jest.
What the situation was
An ESM-only npm package throws an import error in CJS Jest.
Core work
A vendor TS shim + moduleNameMapper can be more stable than transformIgnorePatterns on a nested install path. As a result, it imported stably even from a nested install path.
Takeaways
- test green and tsc green are separate.