-
Notifications
You must be signed in to change notification settings - Fork 16
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