portabletext / portabletext/editor
RangeDecorations cause DOM reconciliation crash on Android (removeChild)
Nobody has claimed this yet.
- 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
- Pass
rangeDecorationsthat change structure based on content (e.g., decorating*italic*text with a styled span) - On Android, type a character that causes a decoration to appear or disappear
- 💥
Failed to execute 'removeChild' on 'Node'
Root cause
The crash involves three systems racing:
Frame timeline on Android:
- User types/deletes → Android IME immediately mutates the DOM (before JS runs)
MutationObservercaptures the mutations- React re-render triggered (value changed →
rangeDecorationsrecomputed → decoration structure changed) getSnapshotBeforeUpdate→restoreDOM()tries to undo IME mutations viainsertBefore/removeChild- React applies its VDOM diff to the DOM
The crash happens at step 4 or step 5:
- Step 4:
restoreDOM()callsremoveChild(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 callsremoveChildon 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
rangeDecorationsthat change based on content
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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