portabletext / portabletext/editor

RangeDecorations cause DOM reconciliation crash on Android (removeChild)

Open
#2,257 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
TypeScript
Stars
279
Forks
21
Avg merge
14h 23m
Merged PRs (30d)
133

Description

Summary

Dynamic rangeDecorations cause a crash on Android when decorations change structure during native input events. The root cause is deeper than restoreDOM() — it's a conflict between Android's IME, PTE's DOM restoration, and React's reconciliation.

Repro

  1. Pass rangeDecorations that change structure based on content (e.g., decorating *italic* text with a styled span)
  2. On Android, type a character that causes a decoration to appear or disappear
  3. 💥 Failed to execute 'removeChild' on 'Node'

Root cause

The crash involves three systems racing:

Frame timeline on Android:

  1. User types/deletes → Android IME immediately mutates the DOM (before JS runs)
  2. MutationObserver captures the mutations
  3. React re-render triggered (value changed → rangeDecorations recomputed → decoration structure changed)
  4. getSnapshotBeforeUpdaterestoreDOM() tries to undo IME mutations via insertBefore/removeChild
  5. React applies its VDOM diff to the DOM

The crash happens at step 4 or step 5:

  • Step 4: restoreDOM() calls removeChild(node) but the node was already moved/removed by React's decoration update. The try-catch fix in the canary handles this.
  • Step 5 (the deeper issue): Even when step 4 errors are caught, restoreDOM() may have partially restored the DOM. React's VDOM diff was computed against the pre-mutation state, but the actual DOM is now a mix of IME mutations and partially-restored nodes. React calls removeChild on a node that doesn't exist where it expects → crash.

On desktop this doesn't happen because keyboard events fire before DOM changes, giving PTE a chance to intercept.

Why rangeDecorations specifically

RangeDecorations create wrapper <span> elements around text content. When a decoration is added or removed, React needs to restructure the DOM tree (add/remove wrapper elements, split/merge text nodes). This structural change is what makes restoreDOM() fail — it's trying to undo text-level mutations while the element structure has changed underneath.

Static decorations that don't change based on content are fine. The crash only happens when decoration structure changes in the same render as an IME mutation.

Same crash class in behaviors

Behaviors that use delete.text during insert.break events hit the same race — the text-level op conflicts with Android's native Enter handling. The workaround is using block-level ops (insert.block + delete.block) instead.

Consumer workaround

Debounce rangeDecorations recomputation on Android by one frame:

const [decorationValue, setDecorationValue] = useState(value)

useEffect(() => {
  if (IS_ANDROID) {
    const raf = requestAnimationFrame(() => setDecorationValue(value))
    return () => cancelAnimationFrame(raf)
  } else {
    setDecorationValue(value)
  }
}, [value])

const rangeDecorations = useMemo(
  () => buildDecorations(decorationValue),
  [decorationValue]
)

This ensures the IME mutation and decoration DOM restructuring never happen in the same frame, so restoreDOM() only deals with text-level mutations (which it handles correctly).

Suggested fixes in PTE

Pragmatic: Detect when rangeDecorations changed structure since the last render. If they did, skip restoreDOM() entirely and clear the mutation buffer. The DOM will be "wrong" for one frame but React will reconcile it correctly.

Long-term: Use the CSS Highlight API for rangeDecorations instead of wrapper <span> elements. This avoids DOM restructuring entirely — highlights are CSS overlays, not DOM nodes. No structural DOM changes = no crash.

Environment

  • @portabletext/editor: 6.0.0 / 6.0.1-canary.2
  • Android Chrome (multiple devices)
  • Dynamic rangeDecorations that change based on content

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Reproduce the issue on Android Chrome with dynamic rangeDecorations, then trace the interaction between MutationObserver, getSnapshotBeforeUpdate, restoreDOM(), and React reconciliation. Compare the pragmatic restoreDOM skip proposal with the CSS Highlight API direction; done means the removeChild crash no longer occurs during decoration changes and the behavior remains correct.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, react, typescript
Domain
frontend, mobile-dev
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
32/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.