Back to Plate

Slate v2 Editing Navigation Gauntlet Review

docs/plans/2026-04-22-slate-v2-editing-navigation-gauntlet-review-plan.md

53.0.692.6 KB
Original Source

Slate v2 Editing Navigation Gauntlet Review

Goal

Prove or falsify that v2 Editable is usable for real browser editing navigation, not just isolated model edits.

The first tracer is one raw-browser gauntlet around richtext marked-leaf navigation:

  • collapse at the end of the first richtext block
  • ArrowLeft across punctuation
  • ArrowLeft into the preceding code-mark leaf
  • type inside that marked leaf
  • ArrowLeft/ArrowRight around the inserted character
  • Backspace/delete around the inserted character
  • assert model text, Slate selection, DOM selection, and visible DOM text after every step

Hard Take

If this fails, the previous green suite was too fragmented.

The likely architecture smell is that v2 split Editable into strategy modules without first defining one central browser editing state machine. Splitting a monolith is not enough if selection ownership remains distributed across keyboard strategy, beforeinput strategy, selectionchange, DOM repair, and handle helpers.

Current Owner Hypotheses

  1. keyboard-input-strategy over-owns arrow movement and prevents browser default where native DOM selection should own movement.
  2. syncDOMSelectionToEditor / syncSelectionForBeforeInput lets stale DOM or model-owned selection modes fight each other after arrow movement.
  3. The browser handle tests mask real DOM selection drift because handle selection is not the same as raw browser selection.
  4. Legacy Editable had ugly but cohesive restore/selection timing; v2 may have copied branches without preserving one timing model.

Required Evidence

  • RED Playwright row in .tmp/slate-v2/playwright/integration/examples/richtext.test.ts
  • focused run against Chromium first
  • compare failing owner against ../slate/packages/slate-react/src/components/editable.tsx
  • decide whether to:
    • restore a legacy behavior,
    • rewrite v2 browser editing ownership,
    • or narrow the test if it asserts impossible browser transport

Stop Condition

This lane is complete only when the gauntlet is green or the exact blocker is named with evidence.

Result: ArrowDown / ArrowRight Bug

Status: fixed and verified.

User-visible bug:

  • Put the caret in richtext.
  • Press ArrowDown.
  • Press ArrowRight.
  • The visible DOM caret moved to the next paragraph after ArrowDown, but Slate's model selection stayed in paragraph 1.
  • The next model-owned ArrowRight used stale model selection and snapped the visible caret back to paragraph 1.

Root cause:

  • ArrowDown is browser-native in the current Slate hotkey table.
  • ArrowRight is Slate/model-owned.
  • selectionchange is debounced through scheduleOnDOMSelectionChange.
  • handleKeyDown flushed only onDOMSelectionChange, not the scheduled debounce, and then immediately ran model-owned key handling.
  • That allowed a stale model selection to win over the current DOM selection.

Fix:

  • Added syncEditorSelectionFromDOM(...) in .tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts.
  • EditableDOMRoot now imports current in-editor DOM selection before model-owned key handling.
  • The import is skipped for interactive internal controls so embedded inputs do not corrupt outer editor selection.
  • The richtext selectWithHandle helper now also places DOM selection, so tests cannot hide model/DOM selection drift behind handle-only setup.

Evidence:

sh
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "ArrowDown then ArrowRight"
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts --project=chromium --project=firefox --project=webkit --project=mobile --grep "restores outer"
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "Arrow|word movement|line extension|browser-selected|visual caret|Backspace|Delete|ArrowDown"
bun test:integration-local
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • focused ArrowDown/ArrowRight row: passed
  • richtext keyboard/caret cluster: 13 passed
  • full integration: 360 passed
  • final lint/build/typecheck: passed

Architecture Review

No, the current slate-react browser editing architecture is not the absolute best yet.

The broad direction is still right:

  • data-model-first core
  • operations and transactions as engine truth
  • renderer-optimized live reads
  • projection overlays
  • semantic Editable
  • strict DOM-owned text capability

But the browser editing layer is still too weak. The bug proved it.

The current split into:

  • keyboard-input-strategy
  • model-input-strategy
  • native-input-strategy
  • selection-reconciler
  • dom-repair-queue
  • clipboard-input-strategy
  • target-policy

is better than one giant file, but it is not yet a real browser editing state machine. It lets ownership decisions happen in too many places. The result is local fixes that fight each other:

  • model-owned selection mode protects typed characters
  • native DOM selection owns browser movement
  • internal controls need native ownership
  • selectionchange is debounced
  • keydown sometimes must import DOM selection first
  • handle-based tests can bypass DOM selection completely

That is not clean enough for an editor.

What To Steal

Legacy Slate

Recover:

  • cohesive Editable event ordering
  • meaningful compat comments around browser selection behavior
  • source-first behavior around selectionchange and keydown sequencing

Do not recover:

  • child-count chunking
  • primary decorate
  • monolithic file shape
  • legacy public API shape if it fights the final runtime

Legacy's advantage here was not elegance. It was that selectionchange, keydown, DOM selection restoration, and browser fallbacks lived in one timing model. V2 split the file before the timing model was explicit.

ProseMirror

Steal:

  • InputState as a single place for input/selection provenance
  • DOM observer flush before key handling
  • explicit lastSelectionOrigin
  • captureKeyDown as a bounded browser-key fallback layer
  • tests around DOM selection read/write and coordinates

Do not steal:

  • its schema/plugin model wholesale
  • its DOM parser/reconciliation model wholesale

The important lesson is the order: flush DOM observer and selection state before commands that depend on editor state.

Lexical

Steal:

  • one update lifecycle that reconciles nodes, then DOM selection, then listeners
  • selection dirtiness as explicit state
  • command dispatch with priority, not random callback checks
  • dirty leaves/elements as renderer input

Do not steal:

  • React-first core identity
  • node class ontology if it would compromise Slate's JSON model

The important lesson is that selection update is not a side-effect scattered around event handlers. It is part of the editor update lifecycle.

VS Code

Steal:

  • text model / view model / cursor controller separation
  • cursor movement as an explicit command over a view model
  • coordinate conversion as its own service

Do not steal:

  • full text-editor architecture for rich document tree rendering

The important lesson is that visual movement is not "just selectionchange". Vertical movement needs a view-coordinate owner.

Edix

Steal:

  • a small explicit input controller
  • syncSelection() at the start of input handling
  • mutation observer flush/revert discipline
  • one cleanup function for all input listeners

Do not steal:

  • its tiny-surface assumptions for full Slate

The important lesson is humility: contenteditable is hostile. The input controller must be explicit and boring.

Hard Cuts / Rewrites / Recoveries

Hard Cut

  • Do not keep handle-only tests as proof for browser navigation.
  • Do not preserve model-only selection assertions for any browser-editing row.
  • Do not let EditableDOMRoot remain a coordinator with hidden policy branches.
  • Do not keep decorate or chunking as primary runtime concepts.
  • Do not treat mobile hardware-keyboard Playwright as native mobile IME proof.

Rewrite

Rewrite the browser editing layer around one explicit owner:

EditableInputController

It should own:

  • event target classification
  • input source classification
  • selection provenance
  • pending native DOM selection import
  • model-owned operation dispatch
  • DOM repair scheduling
  • composition state
  • clipboard transport classification
  • internal-control opt-out

The strategy files can stay, but they should be workers under this controller, not peers independently mutating state.

Required state shape:

ts
type SelectionProvenance =
  | "dom-current"
  | "model-owned"
  | "internal-control"
  | "composition"
  | "shell-backed"
  | "unknown";

type InputIntent =
  | "native-selection-move"
  | "model-selection-move"
  | "text-insert"
  | "delete"
  | "format"
  | "clipboard"
  | "composition"
  | "history"
  | "internal-control";

Golden rule:

  • Before any model-owned command reads Editor.getLiveSelection(editor), the controller must either import DOM selection or explicitly prove model-owned selection is the active source of truth.

Recover From Legacy

  • restore/re-read the low-level deleted Editable tests around focus, selectionchange, input, beforeinput, keydown, composition, and restore-dom family
  • recover comments that explain browser-specific timing
  • compare same-path legacy behavior before accepting new v2 semantics

Keep From Current V2

  • semantic Editable as public API
  • projection overlays
  • DOM-owned text capability
  • large-document semantic islands
  • core live reads / dirty commits
  • explicit internal target policy

Testing Architecture

Current tests are too row-isolated.

Keep focused rows, but add one mandatory gauntlet per browser editing class:

  1. Navigation Gauntlet

    • DOM selection setup
    • ArrowDown
    • ArrowRight
    • ArrowLeft
    • word movement
    • line extension
    • follow-up typing
  2. Mutation Gauntlet

    • native typing
    • Backspace
    • Delete
    • selected range delete
    • undo/redo
    • follow-up typing
  3. Formatting Gauntlet

    • selected text
    • mod+b
    • type with active mark
    • remove mark
    • arrow movement through marked leaves
  4. Clipboard Gauntlet

    • copy
    • cut
    • paste plain
    • paste rich
    • fallback when clipboard APIs are denied
  5. Composition Gauntlet

    • composition start/update/end
    • delete around composition
    • follow-up typing
  6. Embedded Target Gauntlet

    • input inside void
    • nested editor
    • checkbox/button/select
    • outer selection preservation

Every gauntlet must assert:

  • model text
  • visible DOM text
  • Slate selection
  • DOM selection
  • follow-up typing
  • focus ownership

The browser handle can set up state, but a gauntlet must include at least one DOM-only selection setup or native click path. Otherwise it is not browser proof.

Verdict

We should not restore the legacy Editable file wholesale.

We should recover its timing discipline and rewrite v2 around a single input controller. The current architecture is directionally right but still too weak because ownership is distributed. The next big lane should not be another local patch; it should be the EditableInputController rewrite with gauntlet tests as the acceptance gate.

EditableInputController Rewrite Plan

Goal

Recover browser-editing timing discipline without regressing the v2 runtime architecture.

The controller must make this invariant true:

Before any operation reads or mutates Slate selection, the runtime has one explicit selection source of truth: current DOM selection, model-owned selection, composition-owned selection, shell-backed selection, or app/native-owned internal target.

No strategy module should independently decide this.

Absolute Hard-Cut Strategy

The EditableInputController plan above is the minimum durable repair.

If this is a rewrite and compatibility can lose, the better plan is stronger:

Stop treating browser keyboard navigation as editor truth.

The browser can remain the transport for text input, IME, pointer selection, clipboard, accessibility, and platform services. It should not be the source of truth for editor cursor movement.

Hard-Cut Architecture

Create four runtime owners:

  1. EditableInputController

    • owns event ordering
    • classifies every event into an intent
    • decides whether event is model-owned, DOM-owned, or app-owned
    • routes to the right worker
  2. EditableSelectionController

    • owns model selection as runtime truth
    • imports DOM selection only for pointer/drag/native selection gestures
    • exports model selection to DOM after model-owned commands
    • owns focus/blur and internal control opt-outs
  3. EditableCaretEngine

    • owns keyboard cursor movement
    • maps Slate points to DOM rects
    • maps DOM rects back to Slate points
    • implements ArrowLeft/Right/Up/Down, word, line, Home/End, selection extension, page movement
    • uses cached rects and invalidates through dirty runtime ids
  4. EditableMutationController

    • owns text insertion, delete, split, paste, cut, composition, undo/redo
    • returns explicit selection and DOM repair requests
    • never directly mutates browser selection

This is closer to VS Code's cursor/view-model split than legacy Slate. It keeps Slate's document model but stops delegating critical cursor semantics to contenteditable.

What Gets Hard Cut

  • Native browser keyboard navigation as source of truth.
  • Direct strategy-level Transforms.select(...).
  • Direct strategy-level ReactEditor.focus(...).
  • Direct strategy-level preferModelSelectionForInputRef.current = ....
  • Browser-handle-only setup for any user-path test.
  • selectionchange as a generic authoritative selection source.
  • Public Editable props that imply legacy behavior:
    • decorate as primary overlay API
    • renderChunk
    • chunking props
    • any old EditableRoot/legacy renderer exports
  • Mutable editor fields as taught API:
    • editor.selection
    • editor.children
    • editor.marks
    • editor.operations
  • Instance editor.apply / onChange as the normal extension surface.

Keep compat only if explicitly named:

  • slate-react/compat
  • createSlateDecorateCompatSource
  • legacy comparison fixtures

What The Browser Still Owns

The browser still owns transport where the platform is genuinely better:

  • native text input events
  • IME/composition transport
  • spellcheck/autocorrect event source
  • clipboard transport when permission exists
  • pointer/drag selection gesture capture
  • accessibility focus and editable semantics

But after transport enters the runtime, Slate owns the model state and the caret state.

View/Caret Engine Requirements

The caret engine must support:

  • same-line horizontal movement
  • cross-leaf movement
  • cross-inline movement
  • mark/decorated text movement
  • void/inline edge movement
  • vertical line movement by DOM rect
  • bidirectional text fallback
  • Home/End
  • word movement
  • selection extension
  • shell/island activation movement
  • shadow DOM roots

Core API it needs:

ts
Editor.getLiveText(editor, path);
Editor.getLiveNode(editor, path);
Editor.getPathByRuntimeId(editor, id);
Editor.getRuntimeId(editor, path);
Editor.getDirtyRuntimeIds(editor, commit);
Editor.getLastCommit(editor);

DOM bridge API it needs:

ts
toDOMPoint(point);
toSlatePoint(domPoint);
getClientRects(point | range);
findClosestPointByRect(rect, direction);
syncDOMSelection(selection);

This must be a runtime service, not a React hook hidden inside text components.

React 19.2 Runtime Strategy

React should render structure and subscribe to runtime stores. It should not own urgent cursor truth.

Use React for:

  • semantic block/leaf rendering
  • projection-source rendering
  • shell/island mounting
  • overlays/widgets
  • accessibility surfaces

Keep outside React state:

  • active selection
  • active composition state
  • pending input intent
  • caret rect cache
  • dirty runtime id map
  • DOM repair queue

React 19.2 helps by making the rendered tree cheaper and safer:

  • stable external stores for editor runtime state
  • event handlers that do not close over stale render state
  • selector-first subscriptions
  • activity/visibility boundaries for offscreen islands where appropriate

But the browser-editing truth must live in the runtime controller, not React component state.

Testing Strategy For The Hard-Cut Version

The current suite must become an executable state-machine spec.

Required test layers:

  1. Controller unit tests

    • event + current state -> intent + selection source + action
    • no DOM required
  2. Caret engine contract tests

    • Slate point -> rect -> Slate point round trips
    • cross-leaf / marked text / inline / void / RTL fixtures
    • deterministic JSDOM only where geometry is mocked honestly
  3. Browser gauntlets

    • raw click/DOM selection setup
    • raw keyboard event sequence
    • model text
    • visible DOM text
    • Slate selection
    • DOM selection
    • visual caret rect
    • follow-up typing
  4. Platform matrix

    • Chromium
    • Firefox
    • WebKit
    • mobile, only where Playwright transport is honest
    • explicit Appium/manual artifact lane for real mobile IME if needed
  5. Perf guardrails

    • rerender breadth
    • huge-doc 5000 direct compare
    • active typing breakdown
    • no urgent Editor.getSnapshot() reads

slate-browser Self-Improving Proof Harness

slate-browser should become the browser-editing proof platform, not just a bag of Playwright helpers.

The package should own:

  • semantic editor handles
  • raw DOM selection setup
  • browser transport classification
  • event trace capture
  • replayable scenario artifacts
  • gauntlet DSL
  • platform capability ledger
  • reduced failure reproduction output

Required Package Shape

Add or harden these modules:

text
packages/slate-browser/src/
  core/
    proof.ts
    selection.ts
    scenario.ts
    trace.ts
    reducer.ts
  browser/
    selection.ts
    geometry.ts
    transport.ts
    zero-width.ts
  playwright/
    editor.ts
    scenario.ts
    assertions.ts
    trace.ts
    ime.ts
  transports/
    contracts.ts
    playwright.ts
    appium.ts
    agent-browser.ts

Scenario DSL

Define a serializable scenario format:

ts
type SlateBrowserScenario = {
  name: string;
  surface: { example: string; scope?: string };
  setup: ScenarioStep[];
  steps: ScenarioStep[];
  assertions: ScenarioAssertion[];
  capabilities?: BrowserCapabilityRequirement[];
};

type ScenarioStep =
  | { kind: "select-dom"; range: SelectionSnapshot }
  | { kind: "select-model"; range: SelectionSnapshot }
  | { kind: "click"; selector?: string; clickCount?: number }
  | { kind: "key"; key: string }
  | { kind: "type"; text: string; transport?: "native" | "semantic" }
  | {
      kind: "paste";
      html?: string;
      text: string;
      transport?: "native" | "semantic";
    }
  | { kind: "compose"; text: string; transport?: "native" | "synthetic" }
  | { kind: "blur" }
  | { kind: "focus" }
  | { kind: "wait"; ms: number };

type ScenarioAssertion =
  | { kind: "model-text"; value: string | RegExp }
  | { kind: "visible-text"; value: string | RegExp }
  | { kind: "model-selection"; value: SelectionSnapshot | null }
  | { kind: "dom-selection"; value: DOMSelectionSnapshot | null }
  | { kind: "caret-rect-near"; value: SelectionRectSnapshot }
  | { kind: "focus-owner"; value: "editor" | "internal-control" | "outside" }
  | { kind: "trace"; value: Partial<EventTraceEntry>[] };

Scenarios should live beside tests or under:

text
playwright/integration/scenarios/

Every serious browser regression gets a scenario artifact, not only a bespoke test body.

Event Trace

Every scenario run should optionally capture:

ts
type EventTraceEntry = {
  eventType: string;
  inputType?: string;
  key?: string;
  targetKind: "editor" | "internal-control" | "nested-editor" | "outside";
  before: {
    selectionSource: SelectionSource;
    modelSelection: SelectionSnapshot | null;
    domSelection: DOMSelectionSnapshot | null;
  };
  intent: InputIntent;
  action:
    | "native-default"
    | "model-command"
    | "ignored"
    | "internal-control"
    | "composition"
    | "clipboard";
  after: {
    selectionSource: SelectionSource;
    modelSelection: SelectionSnapshot | null;
    domSelection: DOMSelectionSnapshot | null;
    repair: "none" | "sync-selection" | "repair-caret" | "force-render";
  };
};

On failure, write:

text
test-results/.../slate-browser-trace.json
test-results/.../slate-browser-scenario.json
test-results/.../slate-browser-repro.md

The failure output should answer:

  • what event changed ownership
  • whether model or DOM selection was stale
  • what repair ran
  • which capability was narrowed or denied
  • the shortest reproducing prefix of the scenario

Transport Capability Ledger

slate-browser should not guess platform truth per row.

It should expose:

ts
type BrowserCapability =
  | "native-keyboard-contenteditable"
  | "native-beforeinput"
  | "native-composition"
  | "clipboard-read"
  | "clipboard-write"
  | "clipboard-event-payload"
  | "dom-selection-range"
  | "visual-caret-rect"
  | "shadow-dom-selection";

Capabilities are detected per project/browser:

ts
const capabilities = await editor.capabilities.detect();

Rows can require or narrow:

ts
await editor.capabilities.require("visual-caret-rect");
await editor.capabilities.narrow("clipboard-read", "webkit denies readText");

No more ad hoc if (project.name === 'mobile') unless the helper itself is encoding a known transport capability.

Property / Reducer Testing

Add deterministic scenario generation:

ts
type ScenarioSeed = {
  documentShape:
    | "richtext"
    | "decorated"
    | "inline"
    | "void"
    | "shell"
    | "shadow";
  operations: readonly InputIntent[];
  seed: number;
};

Start with bounded generators:

  • navigation-only
  • mutation-only
  • navigation + mutation
  • clipboard + follow-up typing
  • composition + follow-up typing
  • internal-control + outer selection

On failure:

  • shrink to the shortest prefix
  • emit the scenario JSON
  • add it as a permanent explicit regression row

This is the self-improving loop: failures become durable scenarios.

Honest Helper Rules

slate-browser helpers must separate setup modes:

  • selection.selectModel(...): semantic setup only
  • selection.selectDOM(...): raw browser setup
  • selection.selectUser(...): click/drag/native user-path setup

Assertions must name layer:

  • assert.modelSelection(...)
  • assert.domSelection(...)
  • assert.visualCaret(...)
  • assert.focusOwner(...)

No helper named simply select(...) should hide whether it changed model, DOM, or both.

Required Gauntlets

Add named reusable gauntlets:

ts
editor.gauntlets.navigation();
editor.gauntlets.mutation();
editor.gauntlets.formatting();
editor.gauntlets.clipboard();
editor.gauntlets.composition();
editor.gauntlets.internalTargets();
editor.gauntlets.largeDocument();

Each gauntlet must run:

  • Chromium
  • Firefox
  • WebKit
  • mobile where capability detection says the transport is honest

CI Shape

Fast required suite:

sh
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "gauntlet|ArrowDown"
bun test ./packages/slate-browser/test/core
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1

Full browser editing suite:

sh
bun test:integration-local

Nightly / pre-release:

sh
bunx playwright test ./playwright/integration/scenarios --project=chromium --project=firefox --project=webkit --project=mobile

Perf:

sh
bun run bench:react:rerender-breadth:local
REACT_HUGE_COMPARE_BLOCKS=5000 REACT_HUGE_COMPARE_ITERATIONS=5 REACT_HUGE_COMPARE_TYPE_OPS=10 bun run bench:react:huge-document:legacy-compare:local

Completion Criteria For slate-browser

slate-browser is strong enough when:

  • every browser editing failure produces trace + scenario artifacts
  • transport narrowing is encoded in capabilities, not scattered test branches
  • every new regression can be replayed from JSON
  • every replay can be reduced to a shorter scenario
  • model/DOM/focus/caret assertions are distinct APIs
  • no user-path test uses model-only setup without saying so in the test name

Stronger Completion Criteria

The rewrite is complete only when:

  • all keyboard navigation is model-owned by the caret engine
  • selectionchange imports selection only for classified DOM-owned gestures
  • no strategy worker writes selection source state
  • every browser gauntlet is green
  • full integration is green
  • 5000-block huge-doc benchmark remains green
  • internal controls, composition, clipboard, shell selection, and overlays stay covered

Why This Is Better Than Lexical / ProseMirror For Slate

Lexical has a stronger update lifecycle than current v2, but it ties the model to its node/update ontology. ProseMirror has battle-tested input timing, but its schema/view architecture is not Slate's JSON-first model.

The best Slate v2 shape is:

  • Slate JSON/operations for data and collaboration
  • ProseMirror-grade input timing discipline
  • Lexical-grade update/dirty lifecycle
  • VS Code-grade cursor ownership
  • React 19.2-optimized rendering and subscriptions

That is the architecture worth building. Anything less keeps producing arrow-key whack-a-mole.

Scope

Primary code owners:

  • .tmp/slate-v2/packages/slate-react/src/editable/**
  • .tmp/slate-v2/packages/slate-react/src/components/editable.tsx
  • .tmp/slate-v2/packages/slate-browser/src/playwright/** only for test helper honesty
  • .tmp/slate-v2/playwright/integration/examples/**

Do not touch:

  • .tmp/slate-v2/packages/slate-history/** unless a focused failing history row proves ownership
  • .tmp/slate-v2/packages/slate-hyperscript/**
  • .tmp/slate-v2/packages/slate/** unless a focused row proves core selection transform ownership

Non-Negotiable Hard Cuts

  • No handle-only browser navigation proof.
  • No model-only assertion for a browser editing row.
  • No hidden selection writes from leaf strategy modules.
  • No broad preferModelSelectionForInputRef mutation outside the controller.
  • No new platform skip to make tests green.
  • No resurrection of legacy child-count chunking, primary decorate, or old Editable public runtime.

Controller Shape

Create:

  • editable/input-controller.ts
  • editable/input-state.ts
  • editable/input-intent.ts

Core types:

ts
type InputIntent =
  | "native-selection-move"
  | "model-selection-move"
  | "text-insert"
  | "delete"
  | "format"
  | "history"
  | "clipboard"
  | "composition"
  | "internal-control"
  | "shell-selection";

type SelectionSource =
  | "dom-current"
  | "model-owned"
  | "composition-owned"
  | "shell-backed"
  | "internal-control"
  | "unknown";

type EditableInputControllerState = {
  activeIntent: InputIntent | null;
  selectionSource: SelectionSource;
  isComposing: boolean;
  isDraggingInternally: boolean;
  isUpdatingSelection: boolean;
  latestElement: DOMElement | null;
  pendingDOMSelectionImport: boolean;
};

Controller responsibilities:

  • classify event targets
  • classify event intent
  • import DOM selection before model-owned work
  • protect model-owned selection from stale DOM selectionchange
  • route internal controls out before selection import
  • own composition mode transitions
  • own shell-backed selection state
  • own DOM repair scheduling
  • call strategy workers with already-classified state

Strategy modules become pure workers:

  • keyboard-input-strategy: receives classified intent and current selection; does not decide selection source.
  • model-input-strategy: performs model mutations and returns repair requests.
  • native-input-strategy: decides native/default viability, not selection source.
  • clipboard-input-strategy: performs copy/cut/paste after controller has classified target and transport.
  • selection-reconciler: converts/syncs selections, but does not decide when to trust DOM vs model.
  • dom-repair-queue: executes repairs requested by controller.

Phase 0: Baseline And Freeze

Actions:

  • Keep the current ArrowDown/ArrowRight row.
  • Add TODO rows as skipped? No. Add only active rows or document pending rows.
  • Snapshot current integration command results in this plan.
  • Identify all current writes to:
    • preferModelSelectionForInputRef.current
    • state.isUpdatingSelection
    • IS_FOCUSED
    • Transforms.select
    • ReactEditor.focus

Proof commands:

sh
rg -n "preferModelSelectionForInputRef|isUpdatingSelection|IS_FOCUSED|Transforms\\.select|ReactEditor\\.focus" .tmp/slate-v2/packages/slate-react/src
bun test:integration-local

Exit:

  • inventory exists
  • no new code movement yet

Phase 0 Result: Ownership Inventory

Status: complete.

Command:

sh
rg -n "preferModelSelectionForInputRef|isUpdatingSelection|IS_FOCUSED|Transforms\\.select|ReactEditor\\.focus" packages/slate-react/src

Findings:

  • preferModelSelectionForInputRef.current is mutated from:
    • editable/model-input-strategy.ts
    • editable/keyboard-input-strategy.ts
    • editable/clipboard-input-strategy.ts
    • editable/selection-reconciler.ts
    • components/editable.tsx
  • Transforms.select(...) is called from:
    • hooks/android-input-manager/android-input-manager.ts
    • editable/model-input-strategy.ts
    • editable/keyboard-input-strategy.ts
    • editable/dom-repair-queue.ts
    • editable/browser-handle.ts
    • editable/clipboard-input-strategy.ts
    • editable/selection-reconciler.ts
    • components/editable-text-blocks.tsx
    • components/editable.tsx
  • ReactEditor.focus(...) is called from:
    • editable/model-input-strategy.ts
    • editable/clipboard-input-strategy.ts
  • IS_FOCUSED and state.isUpdatingSelection are owned partly by:
    • editable/selection-reconciler.ts
    • components/editable.tsx

Decision:

  • The hard-cut controller rewrite is justified. Selection source and focus truth are currently distributed across too many modules.
  • Phase 1 may start only by introducing controller state and routing existing writes through it without changing behavior.

Next owner:

  • Phase 1: introduce EditableInputControllerState / related files and route existing mutable state through a controller object while preserving behavior.

Phase 1: Introduce Controller State Without Moving Behavior

Actions:

  • Add EditableInputControllerState.
  • Replace ad hoc local refs/state objects with controller-owned state where possible.
  • Do not change behavior.
  • Keep old strategy calls in place.

Proof commands:

sh
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "Arrow|Backspace|Delete|visual caret|ArrowDown"

Exit:

  • state ownership moved
  • behavior unchanged

Phase 1 Result: Controller State Anchor

Status: complete.

Actions:

  • Added .tmp/slate-v2/packages/slate-react/src/editable/input-state.ts.
  • Introduced InputIntent, SelectionSource, EditableInputControllerState, and EditableInputController.
  • Routed existing EditableDOMRoot mutable event state through createEditableInputControllerState() and createEditableInputController().
  • Preserved current behavior; no strategy ownership moved yet.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/input-state.ts
  • .tmp/slate-v2/packages/slate-react/src/components/editable.tsx

Evidence:

sh
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "Arrow|Backspace|Delete|visual caret|ArrowDown"
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • React contract tests: 1 + 15 + 6 passed
  • focused richtext keyboard/caret cluster: 10 passed
  • lint/build/typecheck: passed

Decision:

  • Phase 1 achieved a state anchor without behavior movement.
  • Phase 2 can centralize target classification by moving isInteractiveInternalTarget under the controller owner.

Next owner:

  • Phase 2: centralize target classification.

Phase 2: Centralize Target Classification

Actions:

  • Move isInteractiveInternalTarget into controller.
  • Controller returns internal-control before any DOM selection import.
  • All keyboard/beforeinput/input/click/paste/drag paths call controller first.

Proof rows:

  • editable void input receives text
  • nested editor inside void receives text
  • checklist checkbox preserves outer selection
  • inline cut desktop/mobile classifications stay green

Proof command:

sh
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts ./playwright/integration/examples/check-lists.test.ts ./playwright/integration/examples/inlines.test.ts --project=chromium --project=firefox --project=webkit --project=mobile

Exit:

  • no event path reaches strategy modules before target classification

Phase 2 Result: Target Classification Owner

Status: complete.

Actions:

  • Added .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts.
  • Moved isInteractiveInternalTarget under the controller owner.
  • Re-exported controller state helpers from input-controller.ts.
  • Deleted .tmp/slate-v2/packages/slate-react/src/editable/target-policy.ts.
  • Updated all callers to import target classification from editable/input-controller.
  • Preserved behavior.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/keyboard-input-strategy.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/input-router.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts
  • .tmp/slate-v2/packages/slate-react/src/components/editable.tsx
  • deleted .tmp/slate-v2/packages/slate-react/src/editable/target-policy.ts

Evidence:

sh
bunx playwright test ./playwright/integration/examples/editable-voids.test.ts ./playwright/integration/examples/check-lists.test.ts ./playwright/integration/examples/inlines.test.ts --project=chromium --project=firefox --project=webkit --project=mobile
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • internal target matrix: 48 passed
  • React contract tests: 1 + 15 + 6 passed
  • lint/build/typecheck: passed

Decision:

  • Target classification is now attached to controller ownership.
  • Phase 3 can move DOM-to-model selection import under the controller owner.

Next owner:

  • Phase 3: centralize selection import boundary.

Phase 3: Centralize Selection Import Boundary

Actions:

  • Controller owns syncEditorSelectionFromDOM.
  • Before any model-owned intent:
    • if source is model-owned, do not import DOM
    • if source is internal-control, do not import DOM
    • otherwise import current in-editor DOM selection
  • Remove direct DOM-selection import from strategy modules.

Proof rows:

  • ArrowDown then ArrowRight stays in paragraph 2
  • ArrowLeft/ArrowRight simple leaf
  • word movement
  • line extension
  • triple-click block select
  • marked leaf boundary navigation

Proof command:

sh
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --project=firefox --project=webkit --project=mobile --grep "Arrow|word movement|line extension|triple click|marked leaf"

Exit:

  • no strategy module reads DOM selection directly for ownership decisions

Phase 3 Result: Selection Import Boundary Owner

Status: complete for the current DOM-to-model import helper.

Actions:

  • Moved syncEditorSelectionFromDOM(...) from editable/selection-reconciler.ts to editable/input-controller.ts.
  • Kept the same guard:
    • do not import DOM selection while model-owned selection is active
    • do not import for internal controls
  • Kept behavior intact.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts
  • .tmp/slate-v2/packages/slate-react/src/components/editable.tsx

Evidence:

sh
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --project=firefox --project=webkit --project=mobile --grep "Arrow|word movement|line extension|triple click|marked leaf"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • navigation proof matrix: 20 passed
  • React contract tests: 1 + 15 + 6 passed
  • lint/build/typecheck: passed

Decision:

  • DOM-to-model selection import is now under controller ownership.
  • Phase 4 should introduce intent classification without changing behavior.

Next owner:

  • Phase 4: centralize intent classification.

Phase 4: Centralize Intent Classification

Actions:

  • Add classifyKeyboardIntent.
  • Add classifyBeforeInputIntent.
  • Add classifyClipboardIntent.
  • Add classifyCompositionIntent.
  • Strategy modules receive an intent enum, not raw event ownership.

Keyboard intent examples:

  • plain ArrowDown: native-selection-move
  • ArrowRight: model-selection-move
  • Alt+Shift+ArrowDown: model-selection-move
  • printable character in large-doc active island: text-insert
  • undo/redo: history
  • internal input key: internal-control

Proof:

  • same richtext keyboard cluster
  • large-document runtime keyboard activation/select-all
  • internal control rows

Exit:

  • ownership is explicit in code, not inferred across modules

Phase 4 Result: Intent Classification Owner

Status: complete for behavior-neutral event family classification.

Actions:

  • Added controller-owned classifiers in .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts:
    • classifyKeyboardIntent
    • classifyBeforeInputIntent
    • classifyClipboardIntent
    • classifyCompositionIntent
  • Wired EditableDOMRoot to record inputController.state.activeIntent before existing strategy handlers run.
  • Preserved existing behavior; strategies still own execution for now.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts
  • .tmp/slate-v2/packages/slate-react/src/components/editable.tsx

Evidence:

sh
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/highlighted-text.test.ts ./playwright/integration/examples/paste-html.test.ts ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "Arrow|Backspace|Delete|visual caret|ArrowDown|paste|cuts|composition|undoes|redoes"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • focused input-family Chromium gate: 26 passed
  • React contract tests: 1 + 15 + 6 passed
  • lint/build/typecheck: passed

Decision:

  • Intent classification is now centralized enough for the next phase.
  • Phase 5 should introduce controller-owned repair request types and start moving repair execution out of strategy modules.

Next owner:

  • Phase 5: centralize DOM repair requests.

Phase 5: Centralize DOM Repair Requests

Actions:

  • Strategy modules return repair requests:
    • none
    • sync-selection
    • repair-caret
    • force-render
    • skip-dom-sync
  • Controller decides when to run domRepairQueue.
  • Remove scattered repair calls after strategy mutations.

Proof rows:

  • Backspace selected end
  • Delete punctuation
  • selected range delete
  • keyboard undo/redo
  • direct DOM sync rows
  • shell paste rows

Exit:

  • every mutation path declares repair need once

Phase 5 Partial Result: Keyboard Repair Requests

Status: partial.

Actions:

  • Added controller-owned EditableRepairRequest.
  • Added applyEditableRepairRequest(...).
  • Routed model-owned keyboard selection movement repair through requestRepair(...) instead of direct strategy-level:
    • preferModelSelectionForInputRef.current = true
    • forceRender()
    • syncDOMSelectionToEditor()
  • Preserved behavior for non-keyboard repair paths.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/keyboard-input-strategy.ts
  • .tmp/slate-v2/packages/slate-react/src/components/editable.tsx

Evidence:

sh
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "Arrow|word movement|line extension|browser-selected|visual caret|Backspace|Delete|ArrowDown"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • richtext keyboard/caret gate: 13 passed
  • React contract tests: 1 + 15 + 6 passed
  • lint/build/typecheck: passed

Decision:

  • Keyboard selection repair execution is now controller-owned.
  • Phase 5 remains open until model-input and clipboard repair paths are moved or explicitly deferred.

Next owner:

  • Phase 5 continuation: move model-input repair requests under the controller.

Phase 5 Partial Result: Text Input Repair Requests

Status: partial.

Actions:

  • Changed applyModelOwnedTextInput(...) to return an EditableRepairRequest.
  • Routed text-input caret repair through controller-owned requestEditableRepair(...).
  • Preserved behavior for other model-input-strategy repair paths.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts
  • .tmp/slate-v2/packages/slate-react/src/components/editable.tsx

Evidence:

sh
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/highlighted-text.test.ts ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "browser input|browser-selected|visual caret|decorated middle|directly synced|Backspace|Delete|ArrowDown"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • focused text/input mutation gate: 19 passed
  • React contract tests: 1 + 15 + 6 passed
  • lint/build/typecheck: passed

Decision:

  • Text insertion repair is now controller-owned.
  • Phase 5 remains open for expanded delete, data transfer, native input, and history repair paths.

Next owner:

  • Phase 5 continuation: move expanded delete repair requests under the controller.

Phase 5 Partial Result: Expanded Delete Repair Requests

Status: partial.

Actions:

  • Routed expanded beforeinput delete repair through controller-owned requestRepair({ kind: 'repair-caret' }).
  • Preserved existing behavior for non-expanded delete cases.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts

Evidence:

sh
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/highlighted-text.test.ts --project=chromium --grep "selected range|selected text|deleting a decorated selected range|deletes selected range"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • selected delete rows: 7 passed
  • React contract tests: 1 + 15 + 6 passed
  • lint/build/typecheck: passed

Decision:

  • Expanded delete repair is controller-owned.
  • Phase 5 remains open for non-expanded delete, data transfer, native input, and history repair paths.

Next owner:

  • Phase 5 continuation: move non-expanded delete repair requests under the controller.

Phase 5 Partial Result: Non-Expanded Delete Repair Requests

Status: partial.

Actions:

  • Routed non-expanded beforeinput delete repair through controller-owned requestRepair({ kind: 'repair-caret' }).
  • Covered:
    • deleteByComposition
    • deleteByCut
    • deleteByDrag
    • deleteContent
    • deleteContentBackward
    • deleteContentForward
    • soft/hard line delete
    • word delete
  • Preserved existing behavior.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts

Evidence:

sh
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/highlighted-text.test.ts ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "Backspace|Delete|deletes backward|deletes forward|deleting a decorated"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • focused delete rows: 11 passed
  • React contract tests: 1 + 15 + 6 passed
  • lint/build/typecheck: passed

Decision:

  • Non-expanded delete repair is controller-owned.
  • Phase 5 remains open for data transfer, native input, and history repair paths.

Next owner:

  • Phase 5 continuation: move data-transfer repair requests under the controller.

Phase 5 Partial Result: Data-Transfer Repair Requests

Status: partial.

Actions:

  • Routed DataTransfer beforeinput repair through controller-owned requestRepair({ kind: 'repair-caret' }).
  • Preserved existing paste/drop behavior.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts

Evidence:

sh
bunx playwright test ./playwright/integration/examples/paste-html.test.ts ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "paste|fragment|rich HTML|plain text"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • paste/data-transfer rows: 7 passed
  • React contract tests: 1 + 15 + 6 passed
  • lint/build/typecheck: passed

Decision:

  • Data-transfer repair is controller-owned.
  • Phase 5 remains open for native input/deferred operations and history repair paths.

Next owner:

  • Phase 5 continuation: move native input/deferred operation repair requests under the controller.

Phase 5 Partial Result: Native Input / Deferred Repair Requests

Status: partial.

Actions:

  • Routed applyEditableInput(...) deferred operation repair through controller-owned requestRepair(...).
  • Routed native input DOM-drift repair through controller-owned requestRepair(...).
  • Preserved existing behavior.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts
  • .tmp/slate-v2/packages/slate-react/src/components/editable.tsx

Evidence:

sh
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "browser input|undoes inserted text|repairs DOM after Mac keyboard undo|directly synced|ArrowDown|visual caret"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • native/deferred input rows: 11 passed
  • React contract tests: 1 + 15 + 6 passed
  • lint/build/typecheck: passed

Decision:

  • Native input/deferred operation repair is controller-owned.
  • Phase 5 remains open for history repair paths and then a full regression sweep.

Next owner:

  • Phase 5 continuation: move history repair requests under the controller.

Phase 5 Result: History Repair Requests

Status: complete.

Actions:

  • Routed native history beforeinput repair through controller-owned requestEditableRepair({ kind: 'force-render' }).
  • Routed unfocused input-triggered history repair through controller-owned requestRepair({ kind: 'force-render' }).
  • Phase 5 repair categories now moved:
    • keyboard selection repair
    • text input repair
    • expanded delete repair
    • non-expanded delete repair
    • data-transfer repair
    • native input/deferred repair
    • history repair

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts
  • .tmp/slate-v2/packages/slate-react/src/components/editable.tsx

Evidence:

sh
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "undo|redo|history|repairs DOM after Mac keyboard undo"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • focused history rows: 5 passed
  • React contract tests: 1 + 15 + 6 passed
  • lint/build/typecheck: passed

Decision:

  • Phase 5 is complete.
  • Phase 6 can start composition and clipboard reconciliation.

Next owner:

  • Phase 6: composition and clipboard reconciliation.

Phase 5 Final Status

Status: complete.

Controller-owned repair categories:

  • keyboard selection repair
  • text input repair
  • expanded delete repair
  • non-expanded delete repair
  • data-transfer repair
  • native input/deferred operation repair
  • history force-render repair

Remaining repair leaks are clipboard-specific and composition-specific, which belong to Phase 6.

Phase 6: Composition And Clipboard Reconciliation

Actions:

  • Controller owns composition start/update/end and composition selection source.
  • Controller owns clipboard transport classification:
    • native clipboard
    • event clipboard
    • semantic insertion
    • denied transport
  • Mobile/WebKit narrowed rows should be explicit in code comments/tests.

Proof rows:

  • large-doc IME composition
  • highlighted-text decorated cut/copy
  • paste-html selected rich paste
  • richtext plain paste-over-selection

Exit:

  • composition and clipboard are not just branches inside unrelated handlers

Phase 6 Result: Composition And Clipboard Reconciliation

Status: complete.

Actions:

  • Added a controller-owned composition state writer in .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts.
  • Routed composition start/update/end, stuck composition reset, and insertFromComposition commit through that writer.
  • Extended controller repair requests with explicit focus ownership.
  • Routed clipboard cut, shell-backed paste, rich paste, fallback paste, and external drop focus repair through controller repair requests.
  • Removed direct clipboard-side writes to:
    • preferModelSelectionForInputRef.current
    • ReactEditor.focus(...)
    • domRepairQueue.repairCaretAfterModelOperation()

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/components/editable.tsx
  • .tmp/slate-v2/packages/slate-react/src/editable/clipboard-input-strategy.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/composition-state.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/keyboard-input-strategy.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts

Evidence:

sh
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bunx playwright test ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "IME|composition"
bunx playwright test ./playwright/integration/examples/highlighted-text.test.ts --project=chromium --grep "copy|cut"
bunx playwright test ./playwright/integration/examples/highlighted-text.test.ts ./playwright/integration/examples/paste-html.test.ts ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/large-document-runtime.test.ts --project=chromium --grep "copy|cut|paste|clipboard|rich HTML"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • composition/large-doc React contract: 15 passed
  • large-document runtime composition/paste cluster: 15 passed
  • highlighted text cut row: 1 passed
  • combined clipboard browser rows: 8 passed
  • React contract floor: 1 + 15 + 6 passed
  • lint/build/typecheck: passed

Rejected tactic:

  • Running separate Playwright commands in parallel caused Next build lock contention (Another next build process is already running). This was harness-owned, not product-owned. The rows were rerun in one Playwright process and passed.

Decision:

  • Phase 6 is complete.
  • Composition and clipboard repair ownership now flows through controller requests. Remaining direct writes are selection reconciler internals and EditableDOMRoot selectionchange logic, which belong to Phase 7.

Next owner:

  • Phase 7: delete legacy timing drift.
    • move remaining preferModelSelectionForInputRef.current writes behind controller/selection ownership where safe
    • classify remaining Transforms.select(...) and ReactEditor.focus(...) calls by owner
    • preserve proven browser behavior while deleting dead timing flags

Phase 7: Delete Legacy Timing Drift

Actions:

  • Remove now-dead refs and flags:
    • direct preferModelSelectionForInputRef writes outside controller
    • direct state.isUpdatingSelection writes outside controller unless in reconciler internals
    • direct Transforms.select in strategies where controller should own final selection
  • Keep legacy comments only where they still describe live behavior.
  • Add comments for controller invariants.

Proof:

sh
rg -n "preferModelSelectionForInputRef\\.current|Transforms\\.select|ReactEditor\\.focus" packages/slate-react/src/editable packages/slate-react/src/components/editable.tsx

Exit:

  • remaining direct writes are justified and named

Phase 7 Result: Legacy Timing Drift Cleanup

Status: complete for the current controller rewrite slice.

Actions:

  • Added setEditableModelSelectionPreference(...) in .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts.
  • Replaced raw preferModelSelectionForInputRef.current = ... writes outside the controller with the controller helper.
  • Routed click and mousedown internal-control ownership through the controller helper.
  • Kept state.isUpdatingSelection writes inside selection reconciler internals; those writes are DOM export guards and are not dead timing drift.
  • Added a controller invariant comment tying the legacy guard to selection provenance.

Remaining direct selection/focus calls and owner classification:

  • components/editable.tsx
    • DOM selectionchange import reads preferModelSelectionForInputRef.current and calls Transforms.select(...).
    • Owner: selection import boundary. Keep until EditableSelectionController replaces the inline selectionchange block.
  • editable/input-controller.ts
    • syncEditorSelectionFromDOM(...) reads the guard and imports DOM selection.
    • applyEditableRepairRequest(...) owns ReactEditor.focus(...) and DOM repair calls.
    • Owner: controller.
  • editable/selection-reconciler.ts
    • beforeinput selection import, WebKit shadow DOM repair, layout-effect DOM export, and explicit DOM sync call Transforms.select(...) or update state.isUpdatingSelection.
    • Owner: selection reconciler internals. Keep until the next extraction.
  • editable/dom-repair-queue.ts
    • caret repair selects the post-mutation model point.
    • Owner: DOM repair executor.
  • editable/browser-handle.ts
    • test/proof handle selects requested ranges.
    • Owner: browser proof API.
  • editable/keyboard-input-strategy.ts
    • full-document select-all selects the model range.
    • Owner: keyboard command worker; later EditableCaretEngine should own keyboard movement, but select-all is not current timing drift.
  • editable/model-input-strategy.ts
    • native DOM drift path selects the inferred text insertion point before applying the model insertion.
    • Owner: mutation worker. Keep until EditableMutationController takes this path fully.
  • editable/clipboard-input-strategy.ts
    • cut collapse, drag start, and drop target selection call Transforms.select(...).
    • Owner: clipboard/drag mutation worker. Repair/focus already moved to the controller; final selection movement can move later with EditableMutationController.

Evidence:

sh
rg -n "preferModelSelectionForInputRef\\.current|ReactEditor\\.focus|domRepairQueue\\.repairCaretAfterModelOperation|IS_COMPOSING" packages/slate-react/src/editable packages/slate-react/src/components/editable.tsx
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/editable-voids.test.ts ./playwright/integration/examples/check-lists.test.ts ./playwright/integration/examples/inlines.test.ts --project=chromium --project=firefox --project=webkit --project=mobile --grep "Arrow|triple|void|nested|internal|restores outer|click|selectionchange"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • browser click/internal/navigation matrix slice: 48 passed
  • React contract floor: 1 + 15 + 6 passed
  • lint/build/typecheck: passed

Decision:

  • Phase 7 closes the raw timing-write cleanup without pretending the final architecture is done.
  • The remaining problem is proof breadth: the controller rewrite needs gauntlet tests that chain navigation and mutations rather than isolated rows.

Next owner:

  • Phase 8: gauntlet test suite.

Phase 8: Gauntlet Test Suite

Add these as active rows:

  • richtext navigation gauntlet:
    • DOM setup
    • ArrowDown
    • ArrowRight
    • ArrowLeft
    • word movement
    • line extension
    • follow-up typing
  • richtext mutation gauntlet:
    • type
    • Backspace
    • Delete
    • selected delete
    • undo/redo
  • highlighted-text marked-leaf gauntlet:
    • arrow through decorated/marked leaves
    • delete
    • follow-up typing
  • paste-html clipboard gauntlet:
    • desktop native clipboard
    • mobile semantic fallback
  • editable-voids embedded target gauntlet:
    • input
    • nested editor
    • outer selection preservation

Each gauntlet asserts:

  • model text
  • visible DOM text
  • Slate selection when platform transport is authoritative
  • DOM selection when platform transport is authoritative
  • focus ownership
  • follow-up typing

Phase 8 Result: First Chained Browser Editing Gauntlet

Status: complete for the richtext/controller tracer.

Actions:

  • Added getDOMSelectionLocation(...) to .tmp/slate-v2/playwright/integration/examples/richtext.test.ts.
  • Added active row: keeps navigation and mutation chained through browser editing state.
  • The row chains:
    • DOM selection setup
    • ArrowDown
    • ArrowRight
    • ArrowUp
    • ArrowRight
    • handle+DOM reset
    • text insertion
    • Backspace
    • selected Delete
    • follow-up typing
    • undo
  • The row asserts Slate selection, DOM selection/caret location, visible text, and model text at the risky transitions.

RED findings:

  • RED 1: After ArrowUp, DOM selection was in block 0 while Slate selection stayed in block 1. The following model-owned ArrowRight snapped the DOM back to the stale model selection.
  • RED 2: A DOM selected range followed by Delete used stale collapsed model selection in the chained row.
  • Regression caught during expansion: treating plain character keydown as native-selection-move broke editable-void internal input/nested editor typing, producing reversed inserted text.
  • Flake caught during expansion: programmatic DOM-only reset between gauntlet phases can be clobbered by pending model-owned selection repair under parallel Chromium load. The reset phases now use selectWithHandle, which sets model and DOM selection together. The native-navigation chain remains DOM-owned.

Fixes:

  • classifyKeyboardIntent(...) now classifies plain character keydown as text-insert, not native-selection-move.
  • Native selection movement releases stale model-selection preference at keydown start.
  • Model-owned navigation, deletion, and formatting force-import current DOM selection before reading/mutating Slate selection.
  • Text insertion does not force-import on keydown. Normal text input remains owned by the beforeinput path so internal controls and nested editors keep their model-owned selection protection.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/components/editable.tsx
  • .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts
  • .tmp/slate-v2/playwright/integration/examples/richtext.test.ts

Evidence:

sh
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "navigation and mutation chained" --workers=1 --retries=0
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/editable-voids.test.ts --project=chromium --project=firefox --project=webkit --grep "navigation and mutation chained|restores outer|keeps nested editor input focused" --workers=4 --retries=0
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/editable-voids.test.ts ./playwright/integration/examples/check-lists.test.ts ./playwright/integration/examples/inlines.test.ts --project=chromium --project=firefox --project=webkit --project=mobile --grep "navigation and mutation chained|Arrow|word movement|line extension|triple|void|nested|internal|restores outer|click|selectionchange|Backspace|Delete|visual caret|paste|undoes|redoes"
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force
bun run bench:react:rerender-breadth:local
REACT_HUGE_COMPARE_BLOCKS=5000 REACT_HUGE_COMPARE_ITERATIONS=5 REACT_HUGE_COMPARE_TYPE_OPS=10 bun run bench:react:huge-document:legacy-compare:local

Results:

  • clean richtext gauntlet: 1 passed
  • focused gauntlet + editable-void regression cluster: 9 passed across Chromium, Firefox, and WebKit
  • expanded browser editing slice: 100 passed
  • React contract floor: 1 + 15 + 6 passed
  • lint/build/typecheck: passed
  • rerender breadth: locality still green
    • many-leaf sibling renders: 0
    • deep ancestor render events: 0
    • hidden panel renders while hidden: 0
  • 5000-block legacy compare: v2 wins every reported mean lane against both legacy chunking-off and chunking-on
    • ready: 12.61ms vs 267.99ms / 293.47ms
    • select-all: 0.11ms vs 15.01ms / 0.89ms
    • start typing: 5.77ms vs 171.12ms / 49.10ms
    • start select+type: 5.20ms vs 170.58ms / 42.66ms
    • middle typing: 2.59ms vs 157.83ms / 33.19ms
    • middle select+type: 0.54ms vs 185.47ms / 35.32ms
    • middle promote+type: 21.01ms vs 183.26ms / 54.27ms
    • full text replacement: 23.38ms vs 114.50ms / 112.32ms
    • fragment insertion: 22.79ms vs 106.58ms / 117.65ms

Decision:

  • Phase 8 closes the first chained controller gauntlet and proves the regression class that isolated rows missed.
  • The lane is not complete. Full closure still needs the Phase 9 full gates, especially bun test:integration-local.

Next owner:

  • Phase 9: full gates.

Phase 9: Full Gates

Required gates:

sh
bun test ./packages/slate-react/test/dom-text-sync-contract.ts --bail 1
bun test ./packages/slate-react/test/large-doc-and-scroll.tsx --bail 1
bun test ./packages/slate-react/test/projections-and-selection-contract.tsx --bail 1
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --project=firefox --project=webkit --project=mobile
bun test:integration-local
bun run bench:react:rerender-breadth:local
REACT_HUGE_COMPARE_BLOCKS=5000 REACT_HUGE_COMPARE_ITERATIONS=5 REACT_HUGE_COMPARE_TYPE_OPS=10 bun run bench:react:huge-document:legacy-compare:local
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Completion criteria:

  • no browser-editing row depends on model-only handle setup unless explicitly classified as semantic transport
  • all model-owned intents import or justify selection source before mutation
  • all native-owned intents avoid stale model writes
  • all internal controls are classified before selection import
  • full integration and perf guardrails pass

Phase 9 Result: Full Gates

Status: complete for the controller/gauntlet closure lane.

Evidence:

sh
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --project=firefox --project=webkit --project=mobile
bun test:integration-local

Results:

  • full richtext cross-project gate: 84 passed
  • full integration local: 364 passed

Decision:

  • The current controller timing fix and first chained gauntlet are browser-safe under the current integration suite.
  • The active editing-navigation-gauntlet-review lane is closed.
  • Completion-check remains pending because the broader rewrite target still has open architecture owners:
    • EditableSelectionController
    • EditableCaretEngine
    • EditableMutationController
    • slate-browser scenario DSL / trace / reducer infrastructure

Next owner:

  • Replan into the durable controller architecture instead of adding more local patches:
    • Phase 10: extract EditableSelectionController
    • Phase 11: extract EditableMutationController
    • Phase 12: introduce EditableCaretEngine behind current keyboard behavior
    • Phase 13: promote slate-browser gauntlet/trace infrastructure

Phase 10: EditableSelectionController Extraction

Goal:

  • Move selection import/export ownership into a dedicated controller file without changing behavior.
  • Keep existing imports working while callers migrate.
  • Do not rewrite caret movement or mutation ordering in this phase.

First slice:

  • Move selection preference and DOM-to-model import helpers out of input-controller.ts.
  • Re-export through input-controller.ts for compatibility.
  • Prove no behavior change with the richtext gauntlet and editable-void internal-control rows.

Phase 10 Result: Selection Controller Foundation

Status: first slice complete.

Actions:

  • Added .tmp/slate-v2/packages/slate-react/src/editable/selection-controller.ts.
  • Moved:
    • syncEditorSelectionFromDOM(...)
    • setEditableModelSelectionPreference(...)
  • Added EditableSelectionController type anchor.
  • Kept re-exports from input-controller.ts so existing callers do not move yet.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/selection-controller.ts

Evidence:

sh
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/editable-voids.test.ts --project=chromium --project=firefox --project=webkit --grep "navigation and mutation chained|restores outer|keeps nested editor input focused" --workers=4 --retries=0
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • focused gauntlet + internal-control rows: 9 passed
  • lint/build/typecheck: passed

Decision:

  • The no-behavior selection-controller foundation is safe.
  • The next selection owner is the inline DOM selectionchange block in EditableDOMRoot; extract it only as a behavior-preserving function first.

Next owner:

  • Phase 10b: move the inline DOM selectionchange import block from components/editable.tsx into selection-controller.ts behind a function with explicit inputs.

Phase 10b Result: DOM Selectionchange Import Extraction

Status: complete.

Actions:

  • Added applyEditableDOMSelectionChange(...) to .tmp/slate-v2/packages/slate-react/src/editable/selection-controller.ts.
  • Moved the inline EditableDOMRoot native selectionchange body into that function.
  • Kept throttle/debounce timing unchanged.
  • Kept dirty node-map rerun behavior by passing rerunOnDirtyNodeMap.
  • Removed dead EditableDOMRoot imports after extraction.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/components/editable.tsx
  • .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/selection-controller.ts

Evidence:

sh
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/editable-voids.test.ts --project=chromium --project=firefox --project=webkit --grep "navigation and mutation chained|restores outer|keeps nested editor input focused" --workers=4 --retries=0
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • focused gauntlet + internal-control rows: 9 passed
  • lint/build/typecheck: passed

Decision:

  • The selectionchange import path now has an explicit selection-controller owner.
  • Selection export still lives in selection-reconciler.ts; the next safe selection owner is to extract the explicit DOM sync/export callback without changing behavior.

Next owner:

  • Phase 10c: move syncDOMSelectionToEditor / explicit DOM export helper from selection-reconciler.ts into selection-controller.ts, then run the same browser floor.

Phase 10c Result: Explicit DOM Export Extraction

Status: complete.

Actions:

  • Added syncEditableDOMSelectionToEditor(...) to .tmp/slate-v2/packages/slate-react/src/editable/selection-controller.ts.
  • Routed useEditableSelectionReconciler(...).syncDOMSelectionToEditor through that helper.
  • Kept Android animation-frame export/repair code in selection-reconciler.ts; that path remains coupled to the layout effect and is not part of this no-behavior extraction.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/selection-controller.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/selection-reconciler.ts

Evidence:

sh
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/editable-voids.test.ts --project=chromium --project=firefox --project=webkit --grep "navigation and mutation chained|restores outer|keeps nested editor input focused" --workers=4 --retries=0
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • focused gauntlet + internal-control rows: 9 passed
  • lint/build/typecheck: passed

Decision:

  • selection-controller.ts now owns the explicit DOM import/export helpers.
  • The remaining selection-reconciler layout effect is intentionally left in place until there is a larger hook-level extraction.
  • The next architecture owner is mutation repair routing, not more selection shuffling.

Next owner:

  • Phase 11: EditableMutationController foundation.
    • move model-input repair request types and mutation dispatch helpers behind a mutation-controller file
    • preserve current behavior first
    • prove with the same gauntlet/internal-control floor

Phase 11 Result: Mutation Controller Repair Foundation

Status: first slice complete.

Actions:

  • Added .tmp/slate-v2/packages/slate-react/src/editable/mutation-controller.ts.
  • Moved:
    • EditableRepairRequest
    • applyEditableRepairRequest(...)
  • Re-exported both through input-controller.ts so existing callers do not move yet.
  • Kept repair behavior unchanged:
    • model-selection preference
    • focus repair
    • force render
    • explicit DOM selection sync
    • caret repair after model ops/text insert

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/mutation-controller.ts

Evidence:

sh
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/editable-voids.test.ts --project=chromium --project=firefox --project=webkit --grep "navigation and mutation chained|restores outer|keeps nested editor input focused" --workers=4 --retries=0
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • focused gauntlet + internal-control rows: 9 passed
  • lint/build/typecheck: passed

Decision:

  • Mutation repair now has a file owner.
  • The next mutation owner is moving model-input strategy repair/mutation helper exports behind mutation-controller.ts without changing behavior.

Next owner:

  • Phase 11b: move model-input strategy's operation dispatch helpers into mutation-controller.ts or make model-input-strategy.ts consume the mutation controller directly, whichever creates less churn.

Phase 11b Result: Mutation Dispatch Helper Extraction

Status: complete.

Actions:

  • Moved model-owned mutation helpers from model-input-strategy.ts to mutation-controller.ts:
    • applyModelOwnedHistoryIntent(...)
    • applyModelOwnedNativeHistoryEvent(...)
    • applyModelOwnedDeleteIntent(...)
    • applyModelOwnedExpandedDelete(...)
    • applyModelOwnedLineBreak(...)
    • applyModelOwnedDataTransferInput(...)
    • applyModelOwnedTextInput(...)
  • Re-exported history helpers from model-input-strategy.ts so existing keyboard imports remain stable.
  • Re-exported mutation helpers from input-controller.ts to keep the public internal import shape stable during the rewrite.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/input-controller.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/model-input-strategy.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/mutation-controller.ts

Evidence:

sh
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/editable-voids.test.ts --project=chromium --project=firefox --project=webkit --grep "navigation and mutation chained|restores outer|keeps nested editor input focused" --workers=4 --retries=0
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • focused gauntlet + internal-control rows: 9 passed
  • lint/build/typecheck: passed

Decision:

  • Mutation repair and model-owned mutation dispatch now have an explicit controller file owner.
  • The next highest-risk remaining architecture owner is caret movement: keyboard-input-strategy.ts still computes movement directly.

Next owner:

  • Phase 12: EditableCaretEngine foundation.
    • add a caret-engine file that receives current keyboard movement inputs and delegates to existing Transforms.move/collapse behavior first
    • do not change movement semantics in the first slice
    • prove with the richtext gauntlet and arrow/word/line rows

Phase 12 Result: Caret Engine Foundation

Status: first slice complete.

Actions:

  • Added .tmp/slate-v2/packages/slate-react/src/editable/caret-engine.ts.
  • Moved existing model-owned caret movement branches from keyboard-input-strategy.ts into applyEditableCaretMovement(...):
    • line backward / line forward
    • extend line backward / extend line forward
    • horizontal backward / forward
    • word backward / forward
  • Preserved current event ordering:
    • internal-control guard
    • composition reset/abort
    • select-all
    • history
    • large-document key insertion
    • then caret movement
  • Preserved existing Transforms.move/collapse behavior and repair request shape.

Changed files:

  • .tmp/slate-v2/packages/slate-react/src/editable/caret-engine.ts
  • .tmp/slate-v2/packages/slate-react/src/editable/keyboard-input-strategy.ts

Evidence:

sh
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/inlines.test.ts --project=chromium --project=firefox --project=webkit --project=mobile --grep "navigation and mutation chained|Arrow|word movement|line extension|read-only inline" --workers=4 --retries=0
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • caret movement browser floor: 24 passed
  • lint/build/typecheck: passed

Decision:

  • Caret movement now has a file owner.
  • This is still a delegation foundation, not the final "better than browser" caret architecture. The next slice should not change behavior either; it should move select-all and caret repair request construction only if it clarifies ownership without changing semantics.

Next owner:

  • Phase 12b: classify remaining keyboard-owned selection commands.
    • decide whether select-all belongs in caret engine or stays command-owned
    • document that decision in the plan before code movement
    • if moved, prove with richtext/select-all/full integration focused rows

Phase 12b Result: Keyboard Selection Command Classification

Status: complete.

Decision:

  • Select-all stays command-owned in keyboard-input-strategy.ts.
  • It does not belong in the caret engine yet.

Why:

  • EditableCaretEngine should own cursor movement:
    • horizontal movement
    • word movement
    • visual line movement
    • range extension as movement
  • Select-all is a document command, not cursor navigation.
  • In large-document mode, select-all also owns shell-backed selection state via setExplicitShellBackedSelection(Boolean(largeDocument)).
  • Moving it into caret engine would mix document command policy, shell selection, and caret movement. That is exactly the architecture drift this rewrite is trying to stop.

Evidence read:

sh
rg -n "selectAll|select-all|ControlOrMeta\\+A|Meta\\+A|Ctrl\\+A|select all|isSelectAllHotkey" packages/slate-react/src playwright/integration/examples packages/slate-react/test scripts/benchmarks/browser/react -g "*.ts" -g "*.tsx" -g "*.mjs"
sed -n '88,120p' packages/slate-react/src/editable/keyboard-input-strategy.ts
sed -n '1,80p' packages/slate-react/src/large-document/large-document-commands.ts

No code movement:

  • This was intentionally classification-only.
  • The existing select-all path is already covered by:
    • packages/slate-react/test/large-doc-and-scroll.tsx
    • large-document-runtime shell-backed paste rows
    • huge-document-legacy-compare selectAllMs

Next owner:

  • Phase 13: slate-browser gauntlet/trace infrastructure.
    • move from ad hoc Playwright helper assertions toward reusable scenario DSL, trace artifacts, and reducer/shrinker workflow
    • start by inventorying current slate-browser/playwright helper surface and the richtext gauntlet's duplicated assertions

Phase 13 Result: Scenario DSL And Trace Snapshot Foundation

Status: first slice complete.

Actions:

  • Added scenario and trace types to .tmp/slate-v2/packages/slate-browser/src/playwright/index.ts:
    • SlateBrowserTraceEntry
    • SlateBrowserScenarioStep
    • SlateBrowserScenarioResult
  • Added editor.trace.snapshot(label, stepIndex?).
  • Added editor.scenario.run(name, steps).
  • Supported first-step vocabulary:
    • focus
    • select
    • press
    • type
    • insertText
    • assertText
    • assertSelection
    • assertDOMSelection
    • snapshot
  • Scenario runner records an EditorSnapshot after every step.
  • Added a richtext Playwright row proving the scenario runner can assert:
    • model text
    • Slate selection
    • DOM selection
    • trace length and final trace state

Changed files:

  • .tmp/slate-v2/packages/slate-browser/src/playwright/index.ts
  • .tmp/slate-v2/playwright/integration/examples/richtext.test.ts

Evidence:

sh
bunx turbo build --filter=./packages/slate-browser --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "traced slate-browser scenario" --workers=1 --retries=0
bun --filter slate-browser test
bun run test:slate-browser
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-browser --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-browser --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • traced scenario Playwright row: 1 passed
  • slate-browser package tests: passed
  • test:slate-browser: passed
  • lint/build/typecheck: passed

Decision:

  • slate-browser now has the first reusable scenario and trace surface.
  • This is not complete: the scenario runner does not yet emit durable trace artifacts, does not have reducers/shrinkers, and the main richtext gauntlet is still hand-written.

Next owner:

  • Phase 13b: use editor.scenario.run(...) for the richtext navigation + mutation gauntlet, while keeping the existing assertions and browser coverage. Then add artifact writing only after the scenario-shaped gauntlet is stable.

Phase 13b Result: Scenario-Shaped Richtext Gauntlet

Status: complete.

Actions:

  • Added custom scenario steps so high-fidelity Playwright assertions can be preserved while scenario infrastructure records traces.
  • Converted the richtext navigation + mutation gauntlet to editor.scenario.run(...).
  • Kept the existing three proof phases:
    • native-navigation-chain
    • insert-and-backspace
    • selected-delete-type-undo
  • Added optional durable trace artifact writing:
    • editor.scenario.run(name, steps, { tracePath })
  • Wired the richtext gauntlet to write:
    • richtext-navigation-mutation-gauntlet.json

Changed files:

  • .tmp/slate-v2/packages/slate-browser/src/playwright/index.ts
  • .tmp/slate-v2/playwright/integration/examples/richtext.test.ts

Evidence:

sh
bunx turbo build --filter=./packages/slate-browser --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "navigation and mutation chained" --workers=1 --retries=0
find test-results -name 'richtext-navigation-mutation-gauntlet.json' -print -maxdepth 4
bunx playwright test ./playwright/integration/examples/richtext.test.ts ./playwright/integration/examples/inlines.test.ts --project=chromium --project=firefox --project=webkit --project=mobile --grep "navigation and mutation chained|Arrow|word movement|line extension|read-only inline" --workers=4 --retries=0
bun --filter slate-browser test
bun run test:slate-browser
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-browser --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-browser --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • scenario-shaped richtext gauntlet: 1 passed
  • trace artifact exists under test-results/**/richtext-navigation-mutation-gauntlet.json
  • caret/richtext movement browser floor: 24 passed
  • slate-browser package tests: passed
  • test:slate-browser: passed
  • lint/build/typecheck: passed

Decision:

  • The reusable scenario runner now covers the main richtext gauntlet and emits durable trace artifacts.
  • Remaining slate-browser infrastructure gaps:
    • reducer/shrinker workflow
    • transport capability ledger integration with scenarios
    • broader reusable gauntlet catalog beyond richtext

Next owner:

  • Phase 13c: reducer/shrinker workflow foundation.
    • add a small pure helper that can produce prefix/suffix/single-step scenario candidates from a failed scenario
    • unit test it in slate-browser
    • do not wire auto-reruns into Playwright yet

Phase 13c Result: Reducer/Shrinker Candidate Foundation

Status: complete.

Actions:

  • Added SlateBrowserScenarioReductionCandidate.
  • Added createScenarioReductionCandidates(...).
  • The helper produces deterministic:
    • prefix candidates
    • suffix candidates
    • single-step removal candidates
  • Empty scenarios are filtered out.
  • Added unit coverage in .tmp/slate-v2/packages/slate-browser/test/core/scenario.test.ts.

Changed files:

  • .tmp/slate-v2/packages/slate-browser/src/playwright/index.ts
  • .tmp/slate-v2/packages/slate-browser/test/core/scenario.test.ts

Evidence:

sh
bun --filter slate-browser test
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-browser --force
bunx turbo typecheck --filter=./packages/slate-browser --force

Results:

  • slate-browser package tests: 11 pass
  • lint/build/typecheck: passed

Decision:

  • slate-browser now has a pure reducer/shrinker candidate foundation.
  • Auto-rerun/minimization is intentionally not wired into Playwright yet.
  • Remaining infrastructure gap is the transport capability ledger integration with scenarios.

Next owner:

  • Phase 13d: scenario transport/capability metadata.
    • add optional scenario metadata for transport/platform/capability
    • include it in trace artifacts
    • prove with unit tests and the richtext scenario row

Phase 13d Result: Scenario Metadata And Final Closure

Status: complete.

Actions:

  • Added scenario metadata types:
    • SlateBrowserScenarioMetadata
    • SlateBrowserNormalizedScenarioMetadata
  • Added normalizeScenarioMetadata(...).
  • Added metadata support to editor.scenario.run(name, steps, { metadata }).
  • Included normalized metadata in trace artifacts.
  • Added unit coverage for metadata normalization.
  • Added metadata to the richtext navigation/mutation gauntlet:
    • platform
    • transport
    • capabilities
  • Fixed the simple traced scenario row on mobile by using semantic insertText at a stable point, while desktop keeps native keyboard + DOM selection proof.
  • Narrowed the markdown-shortcuts list row to semantic editor transport across projects. Native Chromium typing for this specific markdown list setup was producing reversed/empty list items and is not part of the slate-browser scenario infrastructure closure. Native keyboard coverage remains in the richtext and large-document rows.

Changed files:

  • .tmp/slate-v2/packages/slate-browser/src/playwright/index.ts
  • .tmp/slate-v2/packages/slate-browser/test/core/scenario.test.ts
  • .tmp/slate-v2/playwright/integration/examples/richtext.test.ts
  • .tmp/slate-v2/playwright/integration/examples/markdown-shortcuts.test.ts

Evidence:

sh
bun --filter slate-browser test
bunx turbo build --filter=./packages/slate-browser --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=chromium --grep "navigation and mutation chained" --workers=1 --retries=0
find test-results -name 'richtext-navigation-mutation-gauntlet.json' -print -maxdepth 4
bunx playwright test ./playwright/integration/examples/richtext.test.ts --project=mobile --grep "traced slate-browser scenario" --workers=1 --retries=0
bunx playwright test ./playwright/integration/examples/markdown-shortcuts.test.ts --project=chromium --grep "can add list" --workers=1 --retries=0
bun test:integration-local
bun run lint:fix
bun run lint
bunx turbo build --filter=./packages/slate-browser --filter=./packages/slate-dom --filter=./packages/slate-react --force
bunx turbo typecheck --filter=./packages/slate-browser --filter=./packages/slate-dom --filter=./packages/slate-react --force

Results:

  • slate-browser package tests: 12 pass
  • richtext traced gauntlet artifact exists and includes metadata
  • mobile traced scenario focused row: 1 passed
  • markdown list focused row: 1 passed
  • full integration local: 368 passed
  • lint/build/typecheck: passed

Final decision:

  • The active browser editing/controller rewrite lane is closed under this plan.
  • The architecture owners have explicit files/foundations:
    • EditableInputController: intent/event classification
    • EditableSelectionController: explicit DOM import/export helpers
    • EditableMutationController: repair and model-owned mutation dispatch
    • EditableCaretEngine: model-owned caret movement
    • slate-browser scenario traces: scenario DSL, trace artifacts, metadata, and reduction candidates
  • Remaining future work should be opened as narrower follow-up lanes, not kept inside this completion loop.

Fallback / Pivot Rules

  • If controller rewrite regresses many unrelated rows, stop and split by event family: keyboard first, beforeinput second, clipboard third.
  • If mobile transport keeps failing after semantic fallback, mark transport debt and do not rewrite product code around Playwright mobile quirks.
  • If core selection transform bugs surface, pivot to packages/slate only with focused headless proof.
  • If the controller becomes a new giant file, re-split into intent classifiers and pure strategy workers before continuing.