Back to Plate

DOM selection bridges must stay cheap on selectionchange

docs/solutions/performance-issues/2026-05-08-dom-selection-bridges-must-stay-cheap-on-selectionchange.md

53.0.64.0 KB
Original Source

DOM selection bridges must stay cheap on selectionchange

Problem

DOM selection import needs explicit ownership classification, but selectionchange is a hot browser path. A clean bridge can become slow if it allocates structured reason objects, scans arbitrary DOM, or exposes classifier details through public React APIs.

Symptoms

  • The plan wants fail-closed classification for foreign, stale, nested, or app-owned DOM selection ranges.
  • The runtime already throttles native selectionchange, but classification still runs on a frequent browser event.
  • Plate, Yjs, cursor overlays, and public hooks need deterministic model selection output, not raw DOM range ownership details.

What Didn't Work

  • Returning rich result objects from the hot path by default. That is useful for tests and debug traces, but it is avoidable churn for ordinary browser events.
  • Treating public hooks as the right place for bridge reasons. Apps should not author DOM selection policy to keep Slate usable.
  • Solving migration pressure by leaking raw DOM ranges into collaboration adapters. That couples deterministic editor state to transient browser state.

Solution

Keep the bridge private and make the hot path primitive:

  • classify with finite string reasons and Range | null;
  • do cheap containment/root/path checks before conversion;
  • avoid full DOM scans in normal selectionchange handling;
  • reserve rich debug objects for tests, traces, or non-hot diagnostics;
  • keep React listeners in the runtime root;
  • expose app-facing state through existing selector hooks, not a new bridge API;
  • send Plate/Yjs/cursor overlays model Range | null state only.

The execution plan records the specific source owners:

  • .tmp/slate-v2/packages/slate-react/src/editable/runtime-selection-engine.ts owns native selectionchange throttling.
  • .tmp/slate-v2/packages/slate-react/src/editable/selection-runtime.ts and .tmp/slate-v2/packages/slate-react/src/hooks/use-editor-selector.tsx own selection fanout filters.
  • .tmp/slate-v2/packages/slate-react/src/editable/selection-controller.ts owns fast DOM selection range creation, fail-closed import, model export, and scroll timing.
  • .tmp/slate-v2/packages/slate-react/src/editable/runtime-root-engine.ts owns React listener/effect wiring.

Why This Works

selectionchange can fire often and can be triggered by both user action and runtime repair. The editor should decide ownership before importing DOM state, but that decision should not allocate a fresh object graph or traverse broad DOM on every event.

Keeping bridge reasons private also preserves Slate's API shape. Public consumers care whether the model selection is a Range or null; they should not need to understand stale DOM, nested editor, shadow-root, or app-owned selection classifications.

Prevention

  • Add classifier unit tests for foreign, stale, app-owned, nested, and shadow DOM selections before adding browser rows.
  • Add selection runtime contract tests that prove classification happens before conversion and that listener ownership stays in the runtime root.
  • Add one issue-shaped browser proof before any Fixes #... claim.
  • Reject bridge changes that add broad DOM scans, per-event rich objects, public classifier hooks, or raw DOM range outputs to collaboration adapters.