Feat/range decoration shifts in mutation#2451
Conversation
When a block is split (Enter key), the editor uses delete+insert operations instead of Slate's native split_node. This caused RangeDecorator to lose decoration positions because Point.transform would clamp decorations at the deletion boundary before seeing the corresponding insert. This fix adds a shared context mechanism: 1. SplitContext type stores split metadata (block keys, span keys, split offset) 2. Split behavior sets context before delete+insert sequence 3. RangeDecorator checks context during operations and uses split-aware transformation 4. Points after the split offset move to the new block instead of being clamped Key changes: - Add SplitContext interface to PortableTextSlateEditor - Add slateEditor to effect action payload for state access - Add moveRangeBySplitAwareOperation() for context-aware transforms - Update RangeDecorator to use split-aware operation handling Note: This covers block splits. Merge and paste operations may need similar treatment. Test coverage in: packages/editor/tests/range-decorations-operations.test.tsx Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
During a split, the editor emits remove_text then insert_node. The previous approach tried to let Point.transform clamp points during remove_text, then fix them up during insert_node. But this lost the original offsets, causing decorations to be moved to offset 0 instead of their correct positions. The fix: - During remove_text: Return the range UNCHANGED to preserve original offsets - During insert_node: Use original offsets to compute correct positions: - Points at or before splitOffset stay in original block - Points after splitOffset move to new block at (offset - splitOffset) Example: "hello dolly this is louis" with decoration spanning entire line, split at offset 11 (after "dolly"): - anchor at 0: stays at block 0, offset 0 - focus at 25: moves to block 1, offset 14 (25-11) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The root cause of decoration loss was that toSlateRange() clamps offsets to text length. During a split: 1. remove_text deletes text from the block 2. When processing the operation, we called toSlateRange() which read the CURRENT document state (text already removed) 3. Original offset 25 got clamped to 11 (new text length) 4. We lost the information needed to place the decoration in the new block The fix: during splits, use the cached slate range on the decoratedRange object instead of re-computing from EditorSelection. The cached range has the original offsets from before the document changed. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Tests cover: - Josef's repro case (decoration spanning entire line, split in middle) - remove_text phase: verifies range is returned UNCHANGED - insert_node phase: verifies correct offset calculation for new block - Edge cases: points at/before/after split offset, multi-block decorations Also fixed type errors by using proper mock node casting. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Adds a "Decorations" tab to the playground inspector that shows: - All active range decorations - Anchor and focus points with path and offset - Whether selection is backward This helps debug decoration behavior during splits and other operations. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…king Add merge context support and fix all remaining failing test scenarios: MergeContext (from previous builder's uncommitted work): - MergeContext interface in slate-editor.ts (mirrors SplitContext for merges) - moveRangeByMergeAwareOperation in move-range-by-operation.ts - Merge context lifecycle in behavior.abstract.delete.ts - mergeContext initialization in create-slate-editor.tsx - Timing fix: apply operation before sendBack in range-decorations-machine.ts - Always use cached Slate Range (not re-computed from EditorSelection) - Multi-PTE sync tests and Josef's repro scenarios (~1100 lines of new tests) Edge case fixes: - Scenario 1.3: differentiate anchor/focus in adjustPointAfterSplit (anchor at split offset moves to new block, focus stays) - Scenario 5.1: use editor.send for insert.text instead of userEvent.type (userEvent.type clicks element first, resetting cursor position) - Scenario 3.4: same editor.send fix for paste replacement - Scenario 4.3: use placement:'auto' to trigger mid-block split path (split_node ops handled natively by Point.transform, no splitContext needed) Note: splitContext is specifically needed for the insert.break code path which uses remove_text + insert_node (where Point.transform clamps offsets incorrectly during remove_text). The operation.insert.block.ts mid-block path uses split_node operations which Point.transform handles correctly. 21 tests passing, 0 failures, 2 skipped (by design). Co-Authored-By: nuum-lead <nuum-lead@miriad.ai>
The previous adjustPointAfterMerge only handled child index 0, silently dropping decorations on any other child (e.g., bold/italic spans). Fix: add targetOriginalChildCount to MergeContext. Child N from the deleted block maps to index targetOriginalChildCount + N in the target block. Each insert_node event is matched to the correct child. Adds Scenario 2.3 test: backspace merge with decoration on second child of a multi-span block (italic 'world' at child index 1). 22 tests passing, 0 failures, 2 skipped. Co-Authored-By: reviewer <reviewer@miriad.ai>
During remote batches, decoration transforms via Point.transform still run incrementally (correct for same-span offset shifts), but onMoved callbacks are suppressed. After the batch + normalization completes, a single reconciliation fires via microtask that: 1. Re-resolves each decoration from EditorSelection keys against the final document state 2. Compares with pre-batch snapshot to detect changes 3. Fires a single onMoved per changed decoration with origin: 'remote' This fixes two bugs: - onMoved firing mid-batch against inconsistent document state - origin: 'local' hardcoded for all callbacks including remote ops Key design decisions: - Microtask timing guarantees no user input between batch and reconcile (JS event loop: synchronous → microtasks → tasks) - pendingReconciliation flag stays true through normalization ops that follow the remote batch (they're consequences, not independent edits) - Decorations are kept alive during batch even if Point.transform returns null for intermediate ops — reconciliation re-resolves them 22 tests passing, 0 failures, 2 skipped. Co-Authored-By: contrarian <contrarian@miriad.ai> Co-Authored-By: reviewer <reviewer@miriad.ai>
Explain why preserving decorations with stale ranges during remote batches is safe: reconciliation re-resolves from EditorSelection keys, either finding the block (correct position) or not (invalidation). The stale cached range is never exposed to consumers.
Two additive changes to the onMoved callback: 1. Return value (EditorSelection | void): When origin is 'remote', consumers can return an EditorSelection to override the auto-resolved position. Enables W3C annotation consumers to re-resolve from their source of truth when remote structural ops truncate/invalidate. Return value is only honored for remote callbacks — local transforms are already correct via split/merge context (documented in JSDoc). 2. previousSelection field: Every onMoved call now includes the decoration's selection before the edit, alongside the new selection. Consumers can diff previous vs new to decide how to respond. Pre-batch snapshot stores EditorSelection alongside Slate Range. Object identity preserved via in-place selection mutation during remote batches for correct Map lookup at reconciliation time. Both changes are backwards-compatible — existing consumers that return void and don't read previousSelection continue to work unchanged. Lazy slateRangeToSelection: only computed for changed decorations in the reconciliation handler, not for every decoration on every batch.
Adds a toggle (WandSparkles icon) in the editor footer that demonstrates the onMoved return value API for remote decoration fix-up. Toggle OFF (default): Remote ops truncate decorations — consumer accepts the auto-shifted newSelection. Shows the problem. Toggle ON: The onMoved callback checks origin === 'remote', searches the document for the original decorated text (captured at creation time), and returns a corrected EditorSelection spanning the found text. Simulates what a real W3C annotation resolver would do. Files changed: - playground-machine.ts: remoteFixUp context + toggle event - range-decoration-button.tsx: originalText capture + fix-up logic - editor.tsx: WandSparkles toggle button in footer - editors.tsx: wire remoteFixUp from playground machine
…conciliation Two changes to prevent selection corruption during remote batches: 1. Skip in-place selection mutation when suppressCallback is true. Point.transform may produce wrong intermediate results without splitContext/mergeContext (e.g. paste-restructure ops). The Slate range tracking for rendering continues, but the EditorSelection is preserved for reconciliation. 2. Use pre-batch snapshot selection for reconciliation re-resolution. The pre-batch snapshot is the authoritative source for what the selection was before remote ops arrived, ensuring toSlateRange resolves against the correct block/span keys. Also fixes the existing 'Decoration in Editor A survives when Editor B splits block' test which was failing due to a Playwright locator issue (multiple range-decoration elements for cross-block decorations). Adds test: 'Remote split does not corrupt decoration selection for reconciliation' — verifies decoration survives when Editor B splits and patches arrive at Editor A without splitContext. 25/25 tests passing, 2 skipped.
…conciliation When reconciliation detects no structural change (changed=false), the cached Slate range from Point.transform has the correct shifted offsets for same-span text insertions. Previously, line 492 unconditionally overwrote with freshSlateRange from toSlateRange, which re-resolves using the original (pre-batch) offsets — undoing Point.transform's correct work. The fix splits reconciliation into two paths: - changed=true: structural change (split/merge/paste) — use toSlateRange's re-resolved position (authoritative for structural ops) - changed=false: no structural change — trust Point.transform's cached Slate range (correct for same-span offset shifts) Also fixes the 'Editor B survives when Editor A types' test which was passing for the wrong reason: both editors shared the same decoration objects via editableProps, so Editor A's local onMoved updated the selection before Editor B's reconciliation ran, masking the bug. Fix: added editablePropsB option to createTestEditors so each editor can have independent decoration arrays. Editor A now has no decorations. 25/25 tests passing, 2 skipped.
Adds an optional `id` field to the RangeDecoration interface for stable matching to external data (e.g., annotation/comment IDs). PTE preserves it and passes it through in onMoved details via the existing rangeDecoration object — no internal logic changes needed. Also sets id on the playground demo decoration for visibility in the onMoved callback.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
📦 Bundle Stats —
|
| Metric | Value | vs main (ef6daba) |
|---|---|---|
| Internal (raw) | 779.8 KB | +32.8 KB, +4.4% |
| Internal (gzip) | 146.5 KB | +5.7 KB, +4.0% |
| Bundled (raw) | 1.37 MB | +32.8 KB, +2.4% |
| Bundled (gzip) | 306.9 KB | +5.7 KB, +1.9% |
| Import time | 96ms | +1ms, +1.3% |
@portabletext/editor/behaviors
| Metric | Value | vs main (ef6daba) |
|---|---|---|
| Internal (raw) | 467 B | - |
| Internal (gzip) | 207 B | - |
| Bundled (raw) | 424 B | - |
| Bundled (gzip) | 171 B | - |
| Import time | 2ms | +0ms, +1.5% |
@portabletext/editor/plugins
| Metric | Value | vs main (ef6daba) |
|---|---|---|
| Internal (raw) | 2.5 KB | - |
| Internal (gzip) | 910 B | - |
| Bundled (raw) | 2.3 KB | - |
| Bundled (gzip) | 839 B | - |
| Import time | 8ms | -0ms, -1.6% |
@portabletext/editor/selectors
| Metric | Value | vs main (ef6daba) |
|---|---|---|
| Internal (raw) | 60.5 KB | - |
| Internal (gzip) | 9.5 KB | - |
| Bundled (raw) | 56.9 KB | - |
| Bundled (gzip) | 8.7 KB | - |
| Import time | 6ms | +0ms, +2.1% |
@portabletext/editor/utils
| Metric | Value | vs main (ef6daba) |
|---|---|---|
| Internal (raw) | 24.2 KB | - |
| Internal (gzip) | 4.7 KB | - |
| Bundled (raw) | 22.2 KB | - |
| Bundled (gzip) | 4.4 KB | - |
| Import time | 6ms | -0ms, -0.1% |
🗺️ . · ./behaviors · ./plugins · ./selectors · ./utils · Artifacts
Details
- Import time regressions over 10% are flagged with
⚠️ - Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.
|
No dependency changes detected. Learn more about Socket for GitHub. 👍 No dependency changes detected in pull request |
9541a9e to
1047cee
Compare
Range Decoration Shifting
~7,900 lines added across 28 files (~5,850 of which are tests).
builds on #2410
Problem
Range decorations (used for things like comment/annotation highlights overlaid on the editor text) were breaking when the underlying document structure changed through block splits (pressing Enter), block merges (Backspace/Delete at block boundaries), and multi-block paste operations. The core issue: Slate decomposes
split_nodeandmerge_nodeinto sequences of lower-level operations (remove_text+insert_node, orremove_node+insert_node), andPoint.transformwould clamp or invalidate decoration endpoints during intermediate steps, losing their correct positions.Architecture
1. Split and Merge Context
SplitContextandMergeContextinterfaces onPortableTextSlateEditorcarry metadata about the semantic operation being performed, so decoration tracking can reason about the high-level intent rather than individual Slate ops.SplitContextrecordssplitOffset,splitChildIndex,originalBlockKey,newBlockKey,originalSpanKey, andnewSpanKey.MergeContextrecordsdeletedBlockKey,targetBlockKey,targetBlockTextLength,deletedBlockIndex,targetBlockIndex, andtargetOriginalChildCount.These are set in the behavior layer (
behavior.abstract.split.ts,behavior.abstract.delete.ts) viaeffectactions before the raise sequence and cleared after.Files:
packages/editor/src/types/slate-editor.ts,packages/editor/src/behaviors/behavior.abstract.split.ts,packages/editor/src/behaviors/behavior.abstract.delete.ts2. Context-aware range transformation
packages/editor/src/internal-utils/move-range-by-operation.tshas three main functions:moveRangeByOperation: Uses backward affinity on the end-point of non-collapsed ranges so inserting text at a boundary doesn't expand the decoration.moveRangeBySplitAwareOperation: During theremove_text/remove_nodephase of a split, returns the range unchanged (preserving original offsets). During theinsert_nodephase for the new block, usesadjustPointAfterSplitto correctly place points in the new block using the un-clamped offsets.This is specifically for the
insert.breakcode path, which emitsremove_text+insert_node. Other split paths (e.g., block object insertion) use Slate'ssplit_nodedirectly, whichPoint.transformhandles correctly without context.moveRangeByMergeAwareOperation: Duringremove_node, preserves decoration points on the deleted block while still transforming other points. Duringinsert_node, maps points from the deleted block to their correct positions in the target block usingadjustPointAfterMerge. Uses pre-computedmergeDeletedBlockFlagsto avoid false positives from path collisions after index shifting.isOperationInsideRange: Detects when a text operation modifies content inside a range without moving boundaries, enabling thecontentChangedreason.3. Direct pre-transformation in
applySplitNode/applyMergeNodeFor operations that Slate decomposes internally (these utilities handle the PTE-specific decomposition of
split_node/merge_node):decoratedRangeswith correct affinities before the decomposed ops fire._suppressDecorationSendBackto skip the normal decoration event pipeline for these intermediate ops.onMovedand pushes decoration shifts after the decomposed sequence completes.Files:
packages/editor/src/internal-utils/apply-split-node.ts,packages/editor/src/internal-utils/apply-merge-node.ts4. Batched remote operation handling
The range decorations state machine (
packages/editor/src/editor/range-decorations-machine.ts) handles remote operations with a batch-and-reconcile approach:applyinterceptor reordered: Operations are applied to Slate before notifying the decoration machine, preventing lookups against stale state.onMovedcallbacks are suppressed (suppressCallbackflag).reconcile decorationsstate: After the batch completes (microtask fires), the machine diffs pre-batch snapshots against current state and fires a singleonMovedper changed decoration, rather than one per Slate op.5. Multi-block paste
packages/editor/src/behaviors/behavior.abstract.insert.tsuses split + merge instead of delete + reinsert for multi-block paste when the cursor is mid-block. This preserves decoration tracking becausesplitContextandmergeContextare set for each phase and decorations transform correctly through the split-paste-merge sequence.6. Undo / redo
packages/editor/src/internal-utils/pre-transform-decorations-for-history.tshandles decoration transforms during undo/redo operations. The history items storesplitContextandmergeContextalongside their operations, and the pre-transform function replays the context-aware transforms (usingtransformPointForSplit/transformPointForMerge) to keep decorations in sync during history traversal.7. Overlapping decorations rendering
packages/editor/src/editor/render.leaf.tsxandpackages/editor/src/editor/Editable.tsx: The leaf type usesrangeDecorations(array). TheDecoratedRangetype has amergefunction that collects multiple decorations onto a leaf, allowing overlapping range decorations to all render their components (nested wrapping).Shift output:
rangeDecorationShiftsonMutationEventEvery site that resolves a decoration shift calls both the decoration's
onMovedcallback andpushDecorationShift(), which accumulates aRangeDecorationShiftrecord onslateEditor.pendingDecorationShifts. These pending shifts are drained and attached to theMutationEventwhen mutations flush.This allows consumers to handle decoration shifts transactionally alongside the patches that caused them, rather than reacting to
onMovedcallbacks that fire mid-operation.RangeDecorationShifttypepreviousSelection— the decoration's selection before the shiftnewSelection— the auto-resolved position after the shift (ornullif the decoration was invalidated)origin—'local'for edits made in this editor instance,'remote'for synced changesreason—'moved'when boundary points shifted or the range was invalidated,'contentChanged'when boundaries are unchanged but text inside the range was modifiedDeduplication
pushDecorationShift()deduplicates by decoration identity (reference equality). When the same decoration shifts multiple times before the mutation flush, the earliestpreviousSelectionis kept and the latestnewSelection/reasonwins. This means consumers always see the net effect: where the decoration was before the transaction started and where it ended up.End-to-end data flow
sequenceDiagram participant Sites as Shift sites participant Acc as slateEditor.pendingDecorationShifts participant MM as Mutation machine participant EA as Editor actor participant Relay as Relay actor participant Consumer as editor.on('mutation') Sites->>Acc: pushDecorationShift() Note over Sites: range-decorations-machine<br/>apply-merge-node<br/>pre-transform-decorations-for-history MM->>Acc: splice(0) — drain shifts MM->>EA: emit { type: 'mutation', patches, snapshot, rangeDecorationShifts } EA->>EA: 'emit mutation event' action EA->>Relay: send(event) Relay->>Consumer: emit(event) Note over Consumer: event.rangeDecorationShifts<br/>available alongside event.patchesAccumulation —
range-decorations-machine.ts,apply-merge-node.ts, andpre-transform-decorations-for-history.tscallpushDecorationShift()at every point whereonMovedfires. Each call appends toslateEditor.pendingDecorationShifts(with deduplication).Mutation machine flush — In
mutation-machine.ts, the'emit mutations'action callsslateEditor.pendingDecorationShifts.splice(0)(drains the accumulator) and attaches the resulting array asrangeDecorationShiftson the emitted{ type: 'mutation' }event.Editor actor forwarding — In
create-editor.ts, amutationActor.on('*')subscription catches mutation events and sends{ type: 'mutation', patches, value, rangeDecorationShifts }to theeditorActor.Editor machine to Relay — The editor machine's
'emit mutation event'action emits the event. TheneditorActor.on('*')increate-editor.tsforwardsmutationevents to therelayActor.Consumer subscription —
editor.on('mutation', handler)is wired torelayActor.on(event, ...), which delivers the fullMutationEvent(includingrangeDecorationShifts) to the consumer.onMovedcallbackThe
onMovedcallback onRangeDecorationstill fires synchronously at each shift site. Its return type isvoid— it cannot override the auto-resolved position. It remains useful for eagerly updating local React state (e.g., updating therangeDecorationsprop to reflect the new selection), but for transactional handling alongside patches,rangeDecorationShiftson the mutation event is the recommended approach.API surface
MutationEventRangeDecorationonMovedreturn type isvoid(no override mechanism).idis an optional stable identifier for matching to external data (e.g., annotation/comment ID). PTE preserves it and passes it through in shift details.RangeDecorationOnMovedDetailseffectactionThe
effectbehavior action type exposesslateEditorfor setting transient state (e.g.,splitContext,mergeContext) around behavior raise sequences.Tests
~5,850 lines of new tests:
packages/editor/src/internal-utils/move-range-by-operation.test.ts(773 lines): Unit tests for split-aware and merge-aware range transformation functions.packages/editor/tests/range-decorations-operations.test.tsx(~5,800 lines): Integration tests covering splits, merges, paste, remote operations, undo/redo, overlapping decorations, andrangeDecorationShiftson mutation events.Playground enhancements
apps/playground/src/range-decoration-debugger.tsx): Shows live decoration state (selection points, active status).apps/playground/src/patches-list.tsx):rangeDecorationShiftsare rendered alongside patches in the feed, withmoved/contentChangedbadges and origin labels.