Skip to content

RangeDecorations cause DOM reconciliation crash on Android (removeChild) #2257

@christianhg

Description

@christianhg

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions