docs/plans/2026-05-11-slate-v2-scroll-selection-visibility-ralplan.md
Slate Ralplan is ready for user review and ralph execution.
The video report looks like autoscroll, but the worse bug is stale selection: after the user scrolls and clicks a lower paragraph, typing can still follow an older model selection and scroll back to that older caret. The accepted plan is selection import first, scroll request second, post-update visibility third.
Live Slate v2 has already moved since this plan was opened: the default scroll
helper now owns a rectangle walker, and a scroll-into-view browser row exists.
Those are treated as execution artifacts to verify and refine under ralph, not
as Slate Ralplan work. Slate Ralplan's job is complete because the remaining
implementation and proof owners are explicit.
The additional scroll-algorithm pass is now recorded below. It keeps the same north star but tightens the exact algorithm contract before execution.
ralph execution may rewrite internal Slate React
runtime code and add a small unopinionated scroll policy API; raw Slate must
not depend on Plate product behavior.Principles:
Chosen shape:
scrollSelectionIntoView as the public escape
hatch, but feed it correct post-update DOM ranges.Rejected alternatives:
scroll-into-view-if-needed and only fix cleanup: too local; it misses
stale selection and nested policy gaps.Live Slate v2 after the prior execution attempt:
.tmp/slate-v2/packages/slate-react/src/components/editable.tsx:420 owns a
scrollRectIntoViewIfNeeded parent walker with a fixed visibility margin..tmp/slate-v2/packages/slate-react/src/components/editable.tsx:513 owns
defaultScrollSelectionIntoView; it measures the collapsed focus range and
falls back to the leaf rect without mutating DOM methods..tmp/slate-v2/packages/slate-react/src/editable/runtime-before-input-events.ts:239
still calls syncSelectionForBeforeInput..tmp/slate-v2/packages/slate-react/src/editable/selection-controller.ts:291
still carries model-selection preference as a boolean plus source, not a
reasoned freshness token..tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts:564
still gates insertText DOM selection import on
preferModelSelectionForInput..tmp/slate-v2/playwright/integration/examples/scroll-into-view.test.ts:1
exists, but the row currently selects the final block programmatically. ralph
should tighten it against the user path: scroll, click visible lower text,
type, scroll away, click/type again..tmp/slate-v2/site/examples/ts/scroll-into-view.tsx:20 creates the nested
scroll-parent repro surface.User evidence:
.tmp/issue-scroll.mp4 shows repeated scroll/click/type cycles..tmp/issue-scroll-frames/frame-5.jpg, frame-8.jpg, and frame-10.jpg
show the user reaches a lower paragraph, types, and the viewport returns
toward the older location.Prior learning:
docs/solutions/ui-bugs/2026-04-22-slate-react-keydown-must-import-dom-selection-before-model-owned-navigation.md
already records the same class: browser-visible selection movement must be
imported before model-owned navigation.docs/solutions/logic-errors/2026-05-11-slate-react-scroll-range-measurement-must-restore-dom-methods.md
is now only partial; it fixes method cleanup but not the root stale-selection
failure.docs/solutions/performance-issues/2026-04-11-shell-promotion-must-move-selection-into-the-promoted-island-or-it-is-just-cosmetic.md
reinforces the rule: visible editing location must become model selection.| System | Evidence | Mechanism | Slate target | Verdict |
|---|---|---|---|---|
| ProseMirror | ../prosemirror-view/src/index.ts:178, ../prosemirror-view/src/domcoords.ts:32, ../raw/prosemirror/packages/state/src/transaction.ts:204 | transaction carries scroll intent; view scrolls post-update selection rect through parent chain; non-scroll updates preserve anchors | add commit/request-scoped scroll intent and custom rect walker | agree |
| Lexical | ../lexical/packages/lexical/src/LexicalEvents.ts:760, ../lexical/packages/lexical/src/LexicalSelection.ts:2637, ../lexical/packages/lexical/src/LexicalUtils.ts:1399 | beforeinput applies DOM target range when safe; selection reconciliation chooses DOM selection for native events; scroll accepts a rect | import DOM selection before text insertion unless model-owned reason is explicit | partial |
| CodeMirror | node_modules/.pnpm/@[email protected]/node_modules/@codemirror/view/dist/index.d.ts:861, :1125, :1365 | scroll is a transaction effect; measurements are batched; scroll margins model obscured areas | schedule read/write geometry and add margin policy | agree |
| Tiptap | ../tiptap/packages/core/src/commands/scrollIntoView.ts:15, ../tiptap/packages/core/src/commands/focus.ts:36 | product command delegates to ProseMirror transaction scroll; focus can opt out | keep a simple app-facing customization boundary | partial |
| Milkdown | ../raw/milkdown/repo/packages/prose/src/toolkit/position/index.ts:53, :74 | UI positioning uses ProseMirror coordsAtPos; command paths use tr.scrollIntoView() | treat Milkdown as ProseMirror confirmation, not a new engine pattern | agree |
| Obsidian | ../raw/obsidian/developer/en/Reference/TypeScript API/Editor/scrollIntoView.md:16 | product API exposes scrollIntoView(range, center?) | keep raw Slate lower-level; Plate can expose product commands | diverge |
Compiled research:
docs/research/sources/editor-architecture/scroll-selection-visibility-runtime.md
records the source comparison from this pass.Keep Editable's scrollSelectionIntoView?: (editor, domRange) => void for
compatibility. Do not add a new public scrollPolicy prop in the first
execution slice.
Add an internal policy object behind the default helper:
nearest by defaultskip-scroll commit tag, composition repair, app overrideDo not expose ProseMirror transactions or CodeMirror effects directly. Promote
the policy object to public API only after the #4995 customization row has
browser proof that the existing callback is insufficient.
syncSelectionForBeforeInput should import current DOM selection for
in-editor insertText before model-owned fallback unless model preference is
backed by a current explicit source such as internal-control, programmatic
export, or repair.setEditableModelSelectionPreference should carry a reason/timestamp, not
just a boolean, so stale preference cannot suppress later user selection.requestCaretVisibility path on the selection runtime. Inputs:
range, reason, margin, threshold, mode, and phase.Rect
through scroll parents.selection-controller/selection-reconciler; the existing public callback
still receives a correct post-update DOM range.skip-scroll for remote selection
changes unless the local user explicitly follows a remote cursor.No new fixed issue claim is allowed from this plan yet.
Related issue sync completed from cached ledgers; no live GitHub discovery was needed, and no ledger row should be promoted before the browser red row exists:
#5826: closest match. Dossier says refocus/selection-scroll family; exact
long-editor refocus autoscroll closure is not claimed. Test-candidate map is
ready-now and should be covered by the first browser row.#4995: related scroll customization pressure. Coverage matrix keeps it
related; test-candidate map says not a direct red-test target in the current
contract.#5639: mobile/RTL repeated scroll pressure. Matrix-only future proof until
raw iPhone/RTL proof exists.#5291: Android cursor jump in tall block. Long-form proof-route backlog;
do not claim from desktop Chromium.#5524: vertical navigation across soft breaks. Related selection pressure,
but current dossier routes exact closure to core caret/navigation unless DOM
bridge proof shows otherwise.#5806: custom inline gesture selection. Related to React selection
reconciliation, but exact drag/slide-selection browser proof is not claimed.#5711: DOM point resolution crash family. Related to fail-closed DOM import,
but exact iOS closure needs matching browser proof.#4961: focus after insert. Adjacent React focus/input runtime ownership;
exact insert-and-focus replay still needed.No changes to docs/slate-v2/references/pr-description.md are justified in
this planning pass. No fixed issue claim is legal yet.
Next pass:
docs/slate-v2/ledgers/fork-issue-dossier.md and
docs/slate-v2/ledgers/issue-coverage-matrix.md unchanged unless proof rows
change status| Row | Test owner | Behavior |
|---|---|---|
| Stale click selection before typing | .tmp/slate-v2/playwright/integration/examples/scroll-into-view.test.ts tighten existing row | scroll nested editor, click lower paragraph, type, assert text lands in clicked paragraph and caret remains visible |
| Repeat scroll/click/type | same browser file | repeat the reported cycle three times; assert scroll does not chase old selection |
| DOM selection import before beforeinput | .tmp/slate-v2/packages/slate-react/test/selection-controller-contract.test.ts or new focused test | insertText imports current in-editor DOM selection when model preference is stale |
| Internal control undo/text input | existing editable-void/read-only rows | internal-control preference still protects native controls |
| Zero rect fallback | .tmp/slate-v2/packages/slate-react/test/editable-behavior.test.tsx | empty/line-break caret still reveals using fallback rect without mutating element methods |
| Nested parent scrolling | .tmp/slate-v2/packages/slate-react/test/rendering-strategy-and-scroll.test.tsx | inner and outer scroll containers receive minimal deltas |
| Scroll margin | new unit row | sticky chrome margin shrinks visible rect |
| Composition | existing IME rows plus new skip-scroll row if needed | composition repair does not import/scroll stale non-composed selection |
/examples/scroll-into-view route, not a synthetic unit-only
DOM.#5639 from desktop Chromium.| Objection | Answer | Verdict |
|---|---|---|
| “This is just a bug in the example.” | The example is only the repro shell; the stale selection path is in slate-react beforeinput/import logic. | keep |
“Existing scrollSelectionIntoView is customizable.” | It customizes the final scroll callback, not the selection freshness or correct measured target. | keep |
| “A custom rect walker is overkill.” | ProseMirror, Lexical, and CodeMirror all own their scroll math/lifecycle; delegating through method mutation already produced a repeat bug. | keep |
| “Do not steal ProseMirror.” | The plan steals lifecycle discipline only: intent, post-update rect, parent walk, and anchor preservation. | keep |
| “A new policy prop is API bloat.” | Correct. First slice keeps the policy internal and keeps the existing callback. Public promotion waits for exact customization proof. | revise |
| “DOM selection import before input can break internal controls.” | Internal-control, composition, shell, and programmatic-export reasons must remain model/native-owned guards; this is the first unit-test row after the browser repro. | keep |
| “One browser row cannot close mobile/RTL scroll issues.” | Correct. #5639 and #5291 stay related/backlog until raw device proof exists. | keep |
| “The scroll walker might force layout on every keystroke.” | It runs only on explicit visibility requests, not every state change; the target budget is one range rect and one ancestor walk per reveal. | keep |
Verdict: a boolean preferModelSelectionForInput is too weak for this bug
class. The execution slice needs a reasoned freshness token, not just
model-versus-DOM preference.
Algorithm contract:
dom-current selection token with event frame and source.internal-control,
composition, programmatic-repair, shell-export, or
model-transform.insertText, current in-editor DOM selection wins unless a
still-current model-owned reason explicitly owns the frame.Execution owner:
.tmp/slate-v2/packages/slate-react/src/editable/input-state.ts.tmp/slate-v2/packages/slate-react/src/editable/selection-controller.ts.tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts.tmp/slate-v2/packages/slate-react/src/editable/runtime-before-input-events.tsVerdict: scroll must be a request attached to committed selection visibility, not a side effect of every selection check.
Algorithm contract:
range, reason, phase, mode, margin,
threshold, and skip/force.beforeinput.skip-scroll unless the local
user follows that remote cursor.Execution owner:
.tmp/slate-v2/packages/slate-react/src/editable/selection-controller.ts.tmp/slate-v2/packages/slate-react/src/editable/dom-repair-queue.ts.tmp/slate-v2/packages/slate-react/src/editable/runtime-repair-engine.ts.tmp/slate-v2/packages/slate-react/src/editable/runtime-root-engine.tsVerdict: the rect walker is the right direction, but the execution pass should make its policy explicit and prove it through nested parents.
Algorithm contract:
Execution owner:
.tmp/slate-v2/packages/slate-react/src/components/editable.tsx.tmp/slate-v2/packages/slate-react/test/rendering-strategy-and-scroll.test.tsx.tmp/slate-v2/packages/slate-react/test/editable-behavior.test.tsxVerdict: the current browser row is useful but not sufficient; programmatic selection can bypass the exact stale click path from the video.
Required first row:
/examples/scroll-into-view.Execution owner:
.tmp/slate-v2/playwright/integration/examples/scroll-into-view.test.ts.tmp/slate-v2/site/examples/ts/scroll-into-view.tsxVerdict: no new public API in the first execution slice.
Keep:
scrollSelectionIntoView?: (editor, domRange) => void#4995 as related pressure onlyCut:
scrollPolicy prop in the first slicePromote a public policy only if a focused #4995 browser row proves the
existing callback cannot express the app need.
Execution must not regress:
No fixed issue claim is legal until the matching proof row exists.
Ralplan owns this owner map only. ralph owns implementation, verification, and
any source/test edits.
.tmp/issue-scroll.mp4 behavior on
/examples/scroll-into-view.First browser row:
.tmp/slate-v2/playwright/integration/examples/scroll-into-view.test.ts.tmp/slate-v2/site/examples/ts/scroll-into-view.tsx#5826 related proof; no fixed claim until the row
passes after implementationFirst runtime slice:
.tmp/slate-v2/packages/slate-react/src/editable/input-state.ts: replace the
stale boolean-only model preference with reason/source freshness data..tmp/slate-v2/packages/slate-react/src/editable/selection-controller.ts:
update setEditableModelSelectionPreference and expose a single internal
helper for stale-preference checks..tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts:
import current in-editor DOM selection for insertText when model preference
is stale, while preserving internal-control/composition/programmatic guards..tmp/slate-v2/packages/slate-react/src/editable/runtime-before-input-events.ts:
pass the freshness-aware preference instead of the raw boolean.Second runtime slice:
.tmp/slate-v2/packages/slate-react/src/components/editable.tsx: replace
temporary getBoundingClientRect mutation with a rect-based default helper..tmp/slate-v2/packages/slate-react/src/editable/selection-controller.ts: queue
caret visibility requests after DOM selection export..tmp/slate-v2/packages/slate-react/test/editable-behavior.test.tsx: keep the
two-scroll regression row and add zero-rect fallback proof..tmp/slate-v2/packages/slate-react/test/rendering-strategy-and-scroll.test.tsx:
prove nested parent deltas and internal policy margins..tmp/slate-v2/packages/slate-react/test/selection-controller-contract.test.ts:
prove stale model preference expires on explicit user DOM selection.Cut line:
EditableTextBlocksProps or add public API until the existing
callback proves insufficient under a focused #4995 browser row.From /Users/zbeyens/git/slate-v2:
bun test ./packages/slate-react/test/selection-controller-contract.test.ts ./packages/slate-react/test/selection-runtime-contract.test.ts ./packages/slate-react/test/editable-behavior.test.tsx ./packages/slate-react/test/rendering-strategy-and-scroll.test.tsx
bun --filter slate-react typecheck
bun lint:fix
bun test:integration-local --grep "scroll into view"
From /Users/zbeyens/git/plate-2 for planning artifacts only:
pnpm lint:fix
bun run completion-check
| Dimension | Score | Evidence |
|---|---|---|
| React 19.2 runtime performance | 0.94 | request queue + O(parent depth) target; read/write timing called out; no per-block listeners |
| Slate-close unopinionated DX | 0.94 | keeps scrollSelectionIntoView; first policy stays internal |
| Plate/slate-yjs migration backbone | 0.88 | margins and skip-scroll are specified; remote-follow remains a later proof row |
| Regression-proof testing | 0.95 | user-path browser row, stale-selection unit, rect walker, zero-rect, and failure-mode rows are named |
| Research evidence completeness | 0.95 | ProseMirror, Lexical, CodeMirror, Tiptap, Milkdown, Obsidian local evidence compiled and issue-synced |
| Composability/minimalism | 0.94 | public API bloat cut from first slice; internal policy avoids prop churn |
Total: 0.94.
| Pass | Status | Evidence added | Plan delta | Open issues | Next owner |
|---|---|---|---|---|---|
| Current-state and ecosystem source pass | complete | live Slate v2 owners, video frames, compiled research note, ProseMirror/Lexical/CodeMirror/Tiptap/Milkdown/Obsidian source reads | created plan and target architecture | exact browser red row pending | issue/accounting pass |
| Related issue sync | complete | exact dossier, coverage-matrix, live-open, v2-sync, and test-candidate rows read for #5826, #4995, #5639, #5291, #5524, #5806, #5711, and #4961 | no fixed claims; #5826 identified as first browser red row | proof rows still pending | maintainer objection closure |
| Maintainer objection closure | complete | steelman/high-risk pressure applied to API bloat, internal-control safety, mobile claim scope, and layout cost | first slice keeps policy internal; public API promotion deferred | exact implementation files/tests still pending | execution plan closure |
| Execution plan closure | complete | current live source re-read, execution owner map, browser row owner, runtime file owners, and driver gates named | plan status set to ready for user review; execution moved to ralph | no fixed issue claims until exact browser proof passes | ralph execution after user invocation |
| Additional scroll-algorithm pass | complete | selection authority, scroll request lifecycle, geometry walker, browser proof, API cut, and failure-mode passes recorded | tightened execution contract before ralph; no source edits from ralplan | user-path browser proof still belongs to ralph | ralph execution after user invocation |
| Ralph execution closure | complete | repeated manual scroll-away browser row, reasoned model-selection preference, DOM repair caret visibility, rect parent walker, dependency cut, focused unit/type/lint/build proof | scroll-into-view-if-needed removed; public API unchanged; issue claims remain conservative | mobile/RTL/raw-device and remote-cursor rows stay unclaimed | none |
Slate Ralplan closure:
>= 0.92, no dimension below 0.85Ralph execution gates:
.tmp/slate-v2scroll-into-view-if-needed method-mutation dependency remains in default
caret visibility path