Skip to content

Feat/range decoration shifts in mutation#2451

Draft
pepahlavacek wants to merge 28 commits intomainfrom
feat/range-decoration-shifts-in-mutation
Draft

Feat/range decoration shifts in mutation#2451
pepahlavacek wants to merge 28 commits intomainfrom
feat/range-decoration-shifts-in-mutation

Conversation

@pepahlavacek
Copy link
Copy Markdown

@pepahlavacek pepahlavacek commented Apr 1, 2026

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_node and merge_node into sequences of lower-level operations (remove_text + insert_node, or remove_node + insert_node), and Point.transform would clamp or invalidate decoration endpoints during intermediate steps, losing their correct positions.

Architecture

1. Split and Merge Context

SplitContext and MergeContext interfaces on PortableTextSlateEditor carry metadata about the semantic operation being performed, so decoration tracking can reason about the high-level intent rather than individual Slate ops.

SplitContext records splitOffset, splitChildIndex, originalBlockKey, newBlockKey, originalSpanKey, and newSpanKey.

MergeContext records deletedBlockKey, targetBlockKey, targetBlockTextLength, deletedBlockIndex, targetBlockIndex, and targetOriginalChildCount.

These are set in the behavior layer (behavior.abstract.split.ts, behavior.abstract.delete.ts) via effect actions 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.ts

2. Context-aware range transformation

packages/editor/src/internal-utils/move-range-by-operation.ts has 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 the remove_text/remove_node phase of a split, returns the range unchanged (preserving original offsets). During the insert_node phase for the new block, uses adjustPointAfterSplit to correctly place points in the new block using the un-clamped offsets.

    This is specifically for the insert.break code path, which emits remove_text + insert_node. Other split paths (e.g., block object insertion) use Slate's split_node directly, which Point.transform handles correctly without context.

  • moveRangeByMergeAwareOperation: During remove_node, preserves decoration points on the deleted block while still transforming other points. During insert_node, maps points from the deleted block to their correct positions in the target block using adjustPointAfterMerge. Uses pre-computed mergeDeletedBlockFlags to avoid false positives from path collisions after index shifting.

  • isOperationInsideRange: Detects when a text operation modifies content inside a range without moving boundaries, enabling the contentChanged reason.

3. Direct pre-transformation in applySplitNode / applyMergeNode

For operations that Slate decomposes internally (these utilities handle the PTE-specific decomposition of split_node/merge_node):

  • Pre-transforms decoratedRanges with correct affinities before the decomposed ops fire.
  • Sets _suppressDecorationSendBack to skip the normal decoration event pipeline for these intermediate ops.
  • Fires onMoved and 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.ts

4. 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:

  • apply interceptor reordered: Operations are applied to Slate before notifying the decoration machine, preventing lookups against stale state.
  • Remote batch detection: On the first remote op, decoration ranges are snapshotted. A microtask is scheduled. During the batch, onMoved callbacks are suppressed (suppressCallback flag).
  • reconcile decorations state: After the batch completes (microtask fires), the machine diffs pre-batch snapshots against current state and fires a single onMoved per changed decoration, rather than one per Slate op.

5. Multi-block paste

packages/editor/src/behaviors/behavior.abstract.insert.ts uses split + merge instead of delete + reinsert for multi-block paste when the cursor is mid-block. This preserves decoration tracking because splitContext and mergeContext are 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.ts handles decoration transforms during undo/redo operations. The history items store splitContext and mergeContext alongside their operations, and the pre-transform function replays the context-aware transforms (using transformPointForSplit / transformPointForMerge) to keep decorations in sync during history traversal.

7. Overlapping decorations rendering

packages/editor/src/editor/render.leaf.tsx and packages/editor/src/editor/Editable.tsx: The leaf type uses rangeDecorations (array). The DecoratedRange type has a merge function that collects multiple decorations onto a leaf, allowing overlapping range decorations to all render their components (nested wrapping).

Shift output: rangeDecorationShifts on MutationEvent

Every site that resolves a decoration shift calls both the decoration's onMoved callback and pushDecorationShift(), which accumulates a RangeDecorationShift record on slateEditor.pendingDecorationShifts. These pending shifts are drained and attached to the MutationEvent when mutations flush.

This allows consumers to handle decoration shifts transactionally alongside the patches that caused them, rather than reacting to onMoved callbacks that fire mid-operation.

RangeDecorationShift type

type RangeDecorationShift = {
  rangeDecoration: RangeDecoration
  previousSelection: EditorSelection
  newSelection: EditorSelection
  origin: 'remote' | 'local'
  reason: 'moved' | 'contentChanged'
}
  • previousSelection — the decoration's selection before the shift
  • newSelection — the auto-resolved position after the shift (or null if the decoration was invalidated)
  • origin'local' for edits made in this editor instance, 'remote' for synced changes
  • reason'moved' when boundary points shifted or the range was invalidated, 'contentChanged' when boundaries are unchanged but text inside the range was modified

Deduplication

pushDecorationShift() deduplicates by decoration identity (reference equality). When the same decoration shifts multiple times before the mutation flush, the earliest previousSelection is kept and the latest newSelection / reason wins. 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.patches
Loading
  1. Accumulationrange-decorations-machine.ts, apply-merge-node.ts, and pre-transform-decorations-for-history.ts call pushDecorationShift() at every point where onMoved fires. Each call appends to slateEditor.pendingDecorationShifts (with deduplication).

  2. Mutation machine flush — In mutation-machine.ts, the 'emit mutations' action calls slateEditor.pendingDecorationShifts.splice(0) (drains the accumulator) and attaches the resulting array as rangeDecorationShifts on the emitted { type: 'mutation' } event.

  3. Editor actor forwarding — In create-editor.ts, a mutationActor.on('*') subscription catches mutation events and sends { type: 'mutation', patches, value, rangeDecorationShifts } to the editorActor.

  4. Editor machine to Relay — The editor machine's 'emit mutation event' action emits the event. Then editorActor.on('*') in create-editor.ts forwards mutation events to the relayActor.

  5. Consumer subscriptioneditor.on('mutation', handler) is wired to relayActor.on(event, ...), which delivers the full MutationEvent (including rangeDecorationShifts) to the consumer.

onMoved callback

The onMoved callback on RangeDecoration still fires synchronously at each shift site. Its return type is void — it cannot override the auto-resolved position. It remains useful for eagerly updating local React state (e.g., updating the rangeDecorations prop to reflect the new selection), but for transactional handling alongside patches, rangeDecorationShifts on the mutation event is the recommended approach.

API surface

MutationEvent

type MutationEvent = {
  type: 'mutation'
  patches: Array<Patch>
  value: Array<PortableTextBlock> | undefined
  rangeDecorationShifts: Array<RangeDecorationShift>
}

RangeDecoration

interface RangeDecoration {
  component: (props: PropsWithChildren) => ReactElement
  selection: EditorSelection
  onMoved?: (details: RangeDecorationOnMovedDetails) => void
  id?: string
  payload?: Record<string, unknown>
}
  • onMoved return type is void (no override mechanism).
  • id is an optional stable identifier for matching to external data (e.g., annotation/comment ID). PTE preserves it and passes it through in shift details.

RangeDecorationOnMovedDetails

interface RangeDecorationOnMovedDetails {
  rangeDecoration: RangeDecoration
  previousSelection: EditorSelection
  newSelection: EditorSelection
  origin: 'remote' | 'local'
  reason: 'moved' | 'contentChanged'
}

effect action

The effect behavior action type exposes slateEditor for 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, and rangeDecorationShifts on mutation events.

Playground enhancements

  • Range decoration debugger panel (apps/playground/src/range-decoration-debugger.tsx): Shows live decoration state (selection points, active status).
  • Shift cards in patch feed (apps/playground/src/patches-list.tsx): rangeDecorationShifts are rendered alongside patches in the feed, with moved / contentChanged badges and origin labels.
  • Multiple colors for decorations, hover-to-highlight, active decoration tracking.

Builder Agent and others added 28 commits February 9, 2026 17:41
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.
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 1, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
portable-text-editor-documentation Ready Ready Preview, Comment Apr 2, 2026 2:29pm
portable-text-example-basic Ready Ready Preview, Comment Apr 2, 2026 2:29pm
portable-text-playground Ready Ready Preview, Comment Apr 2, 2026 2:29pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 1, 2026

⚠️ No Changeset found

Latest commit: 1047cee

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

📦 Bundle Stats — @portabletext/editor

Compared against main (ef6dabae)

@portabletext/editor

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.

@socket-security
Copy link
Copy Markdown

socket-security bot commented Apr 2, 2026

No dependency changes detected. Learn more about Socket for GitHub.

👍 No dependency changes detected in pull request

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant