Back to Plate

Slate v2 should use read/update as the public runtime lifecycle

docs/research/decisions/slate-v2-read-update-runtime-architecture.md

53.0.63.8 KB
Original Source

Slate v2 should use read/update as the public runtime lifecycle

Decision

Slate v2 should expose:

ts
editor.read(fn)
editor.update(fn, options?)

as the public runtime lifecycle.

tx.resolveTarget() remains internal. It is not normal plugin/app DX.

Why

The strongest cross-editor evidence points to the same discipline:

  • Lexical uses read/update lifecycle and dirty reconciliation.
  • ProseMirror uses transactions plus centralized DOM selection import/export.
  • Tiptap packages commands/extensions into excellent product DX but still relies on ProseMirror transaction discipline underneath.

Slate v2 should combine those lessons without copying their models:

txt
Slate model + operations
read/update lifecycle
transaction-owned target freshness
commit metadata
React-optimized live reads and dirty regions
extension namespace ergonomics
browser gauntlet proof

Public API Target

ts
editor.read((state) => {
  state.selection.get()
  state.value.get()
  state.marks.get()
})

editor.update((tx) => {
  tx.value.replace({
    children,
    marks: null,
    selection: null,
  })
  tx.nodes.unwrap({ match: isList })
  tx.nodes.set({ type: 'list-item' })
  tx.nodes.wrap({ type: 'bulleted-list', children: [] })
})

Grouped state and tx methods stay primitive and flexible. This is closer to Slate's durable value than adding a semantic method for every custom node type, without keeping flat editor mutation methods as normal public DX.

Whole-document replacement is also a transaction write. The public shape is editor.update((tx) => tx.value.replace(input)). Static Editor.replace, public editor.replace, and public editor.reset are not part of the target app-author API.

Internal Contract

txt
editor.update
  -> active transaction
  -> implicit target resolves once if needed
  -> internal write registry uses the transaction target when `at` is omitted
  -> operations
  -> EditorCommit
  -> history/collaboration/render/DOM repair

Hard Cuts

  • public mutable editor.selection, editor.children, editor.marks, editor.operations
  • public Transforms.* as primary docs/examples API
  • public editor.apply and editor.onChange as extension points
  • command policy objects
  • ReactEditor.runCommand
  • child-count chunking as product runtime
  • semantic-method explosion for every custom node type

Extension Direction

Use extension namespaces and optional product-layer chain sugar:

ts
defineEditorExtension({
  key: 'todo',
  tx: {
    todo(tx) {
      return {
        toggle() {
          tx.nodes.set({ type: 'todo', checked: true })
        },
      }
    },
  },
})

Optional later:

ts
editor.chain().setNodes(props).wrapNodes(wrapper).run()

chain().run() must be sugar over editor.update, not a separate runtime.

React Runtime Direction

React consumes:

  • live reads
  • dirty runtime ids
  • dirty top-level ranges
  • EditorCommit
  • source-scoped projection dirtiness
  • direct DOM sync capability results

React does not own:

  • document model truth
  • operation/collaboration semantics
  • selection import policy
  • mutation lifecycle

Battle-Test Requirement

No release-quality claim without generated browser gauntlets that assert:

  • model tree/text
  • model selection
  • visible DOM
  • DOM selection/caret where observable
  • commit metadata
  • no illegal kernel transition
  • follow-up typing

Status

Accepted as the final architecture direction for the current Slate v2 runtime plan.

The read/update lifecycle is no longer just architecture prose: focused write-boundary contracts, public-surface hard cuts, generated destructive browser gauntlets, and persistent-profile soak now exercise the current runtime. Final closure still requires the full bun test:integration-local sweep and keeps raw Android/iOS mobile proof scoped unless a device-lab gate provides direct artifacts.