Back to Plate

Slate bookmarks through replace_children should follow surviving text before same-position fallback

docs/solutions/logic-errors/2026-05-14-slate-bookmarks-replace-children-should-follow-surviving-text.md

53.0.64.2 KB
Original Source

Slate bookmarks through replace_children should follow surviving text before same-position fallback

Problem

Persistent annotation anchors failed after a fragment insert before the anchored text. The document still contained the logical text, but the bookmark backing the annotation resolved to null, so the React annotation store projected none.

Symptoms

  • persistent-annotation-anchors expected comment-anchor:8-11, but rendered none.
  • A package regression for root runtime order changes expected 1.0:8|1.0:11, but got none.
  • After the first fix attempt, projection threw Cannot project a range outside the committed snapshot because replacement paths were rebased incorrectly.

What Didn't Work

  • Treating this as an annotation-store candidate refresh bug was too shallow. The store was refreshing, but the underlying bookmark had already been nulled.
  • Generic RangeApi.transform(..., replace_children) was not enough. It must fail closed for ordinary refs inside a replaced child window, but bookmarks are durable anchors and can preserve more intent.
  • Prefixing replacement paths with op.path.concat(op.index) was wrong for child-list replacement. It produced paths like [0,1,0] instead of rebasing the replacement-window child index to [1,0].

Solution

Keep generic point/range refs conservative, but give bookmarks a replace_children transform that:

  1. detects points inside the replaced child window,
  2. first maps them to a unique surviving text occurrence in newChildren,
  3. falls back to same relative path/offset when no surviving text match exists,
  4. fails closed when neither mapping is valid.

The core regression should use the public editor path, not React:

ts
const bookmark = Editor.bookmark(
  editor,
  createRange({ path: [0, 0], offset: 1 }, { path: [0, 0], offset: 4 })
)

editor.update((tx) => {
  tx.selection.set({
    anchor: { path: [0, 0], offset: 0 },
    focus: { path: [0, 0], offset: 0 },
  })
  tx.fragment.insert([
    { type: 'paragraph', children: [{ text: 'intro-a' }] },
    { type: 'paragraph', children: [{ text: 'intro-b' }] },
  ])
})

assert.deepEqual(bookmark.resolve(), {
  anchor: { path: [1, 0], offset: 8 },
  focus: { path: [1, 0], offset: 11 },
})

Keep the React annotation-store test as the integration proof that refreshed projections can see the rebased bookmark after root runtime ids change.

Why This Works

replace_children is intentionally broad: it can represent a paste, canonical remote reconcile, or fragment replacement. Ordinary refs inside the replaced window cannot assume semantic continuity, so they should still null.

Bookmarks are different. They are durable annotation-style anchors. If the old text leaf survives uniquely inside the replacement, the bookmark can follow that text. If the replacement is a canonical same-position swap, the bookmark can preserve the same relative path and offset. If neither condition is true, failing closed remains the correct behavior.

Prevention

  • When persistent anchors fail after paste or fragment insertion, inspect the bookmark transform before patching projection stores.
  • Add core bookmark tests for document-operation rebasing, then React store tests for projection refresh. Do not let React tests be the only proof.
  • For replace_children, keep ordinary refs conservative and make bookmark behavior explicit. Durable anchors and normal refs do not have the same contract.
  • Path rebasing for replacement windows must adjust the first relative child index: op.path.concat(op.index + relativeChildIndex, childPath).