Skip to content

Comments

PR 3: PT as single source of truth — childless voids + value unification#2226

Draft
christianhg wants to merge 23 commits intomainfrom
feat/childless-voids
Draft

PR 3: PT as single source of truth — childless voids + value unification#2226
christianhg wants to merge 23 commits intomainfrom
feat/childless-voids

Conversation

@christianhg
Copy link
Member

@christianhg christianhg commented Feb 20, 2026

Summary

Eliminates the dual-tree architecture. Portable Text is now the single source of truth — no value wrappers, no __inline markers, no void children.

104 files changed, 977 insertions, 963 deletions (nearly net-zero).

Commit stack

# Commit Scope Description
1 9bbeb9fe Slate editor.createTextNode() for schema-aware text node creation
2 d8532121 Slate Smart set_node — relax text/children constraints for void elements
3 7a8fc317 Slate editor.isText/isElement foundation with structural checks
4 9b327ad7 Slate Migrate 42 Slate-layer files to instance methods
5 686986f2 PTE Migrate 15 PTE-layer files (46 call sites) to instance methods
6 e1d2c4c6 Slate Childless voids — optional children, normalization guards, void selection, WebKit compat
7 948884ca PTE Value unification — remove value wrapper, __inline marker, void children
8 1247e02b PTE Typed children + operation-to-patches rewrite
9 04c7db08 PTE applyPatch direct property reads + test data update
10 0bc54054 React Node.has guards for stale selection paths
11 db1fae23 Slate Selection correction for childless void elements
12 211a2bf7 Core Initialize editor.children before building index maps
13 a1f49f5b Slate Childless void paths in toSlateRange
14 5623a4cc Slate Restore createTextNode + Phase 2 instance methods in normalize-node
15 22028502 Slate Restore Phase 2 instance method migrations (10 files)
16 9f5d1a0a PTE Operation + applyPatch fixes for direct property model
17 ad8af02d Tests Update expectations for marks normalization + key ordering

Key changes

Data model

  • Block/inline objects store properties directly on the node (no value wrapper)
  • Inline objects have no __inline: true marker
  • Void elements have no children (no [{_key, _type: "span", text: "", marks: []}])
  • editor.children typed as Array<PortableTextBlock>

Operations & patches

  • operation-to-patches.ts: reads properties directly from operation.newProperties
  • applyPatch.ts: inline/block object handlers apply patches directly to node
  • operation.block.unset.ts: uses Transforms.unsetNodes directly
  • Nested unset on block objects (e.g., _map.foo) now works correctly

Normalization

  • marks: [] added by toSlateBlock during sync (no deferred normalization patch)
  • markDefs, style still normalized via deferred patches
  • Void elements skip normalization (no minimum text descendant enforcement)

Selection

  • Void selection paths truncated at element level (no [N, 0] inside childless voids)
  • Node.has guards prevent crashes from stale selection paths

Testing

  • Types: 39/39 ✅
  • Lint: 21/21 ✅
  • React compiler: ✅
  • Unit: 317/317 ✅
  • Browser (Chromium local): 0 real failures ✅
  • Browser (Firefox, WebKit): CI pending

Vendor `slate@0.120.0`, `slate-dom@0.119.0`, and `slate-react@0.120.0` source
from github.com/ianstormtaylor/slate (tag: slate@0.120.0).

Pure copy of src/ directories — no modifications, no git history.

  packages/editor/src/slate/       — 118 files (slate core)
  packages/editor/src/slate-dom/   — 14 files (DOM integration)
  packages/editor/src/slate-react/ — 40 files (React bindings)
Rewire all bare 'slate', 'slate-dom', 'slate-react' imports to relative
paths. Fix module augmentation (declare module '../slate/index' instead
of 'slate'). Remove slate/slate-dom/slate-react npm dependencies, add
their transitive deps (direction, is-hotkey, lodash, etc.).

Fix 583 TypeScript errors from vendored source hitting PTE's strict
tsconfig (@sanity/tsconfig strictest):

Structural fixes:
- Widen DOMEditor/ReactEditor params to Editor (resolves CustomTypes)
- withDOM/withReact generic: <T extends BaseEditor> → <T extends Editor>
- Remove vendored CustomTypes augmentation (PTE's types/slate.ts is source)
- Convert enum to const object (erasableSyntaxOnly)
- Cast create-editor.ts delegates through any (incremental object build)

Mechanical fixes:
- verbatimModuleSyntax: type-only re-exports (biome --fix)
- erasableSyntaxOnly: angle bracket → as assertions, import aliases
- noUncheckedIndexedAccess: non-null assertions on bounded array access
- Unused variables prefixed with _
- override keyword on React lifecycle methods
- JSX namespace imports for React 19
- React 19 ref types (RefObject<T | null>, useRef(undefined))

Lint fixes:
- Rename String component to SlateString (no shadow global)
- Block statements for single-line ifs
- Explicit types for uninitialized let declarations
- biome-ignore for Slate upstream patterns (hooks in conditionals,
  assignment in expressions, non-null after optional chain)

All 317 unit tests pass. Zero type errors. Zero lint errors.
The vendored Slate source files are now raw TypeScript compiled alongside
PTE, which means the React Compiler babel plugin transforms them. Slate's
components use manual React.memo with custom equality functions that the
React Compiler interferes with, causing incorrect re-render optimization
(skipping renders that should happen).

Exclude src/slate/, src/slate-dom/, and src/slate-react/ from
@vitejs/plugin-react so the React Compiler doesn't process them.
esbuild still handles JSX transformation for these files.
lodash is CJS-only and doesn't resolve under Node's ESM module
resolution. Since the editor package is "type": "module", downstream
consumers running tests in Node (e.g. Vitest) fail with
"Cannot find module 'lodash/debounce'".

Replaces the two lodash imports from vendored Slate with minimal
inline implementations and removes lodash from dependencies.
The set_node operation handler and Transforms.setNodes previously
rejected 'text' and 'children' properties unconditionally. This was
overly restrictive:

- 'text' on Text nodes must go through insert_text/remove_text for
  correct history, but 'text' on Element nodes is a valid custom
  property.
- 'children' on non-void elements is structural and must not be set
  directly, but 'children' on void elements is not structural.

Changes:
- general.ts: Only throw for 'text' on Text nodes, only throw for
  'children' on non-void Elements.
- set-nodes.ts: Same conditional filtering, plus import Text.
- apply-operation-to-portable-text.ts: Skip 'children' when applying
  set_node to object nodes in the PT value layer, preventing Slate
  void-child internals from leaking into portable text output.
…l checks

Phase 1 of the identity migration:
- Add isText/isElement to BaseEditor interface
- Default implementations in createEditor() use structural checks
- Schema plugin overrides with schema-driven checks (_type === span.name)
- Node.* methods use structural hasChildren() helper instead of Text.isText/Element.isElement
- modify.ts and get-dirty-paths.ts converted to structural checks

Node.* methods no longer depend on type identity. Tree traversal is purely
structural: 'does this node have children?' All type identity questions
('is this a span?') are answered by editor.isText/editor.isElement.
…ement across PTE layer

Convert 46 call sites across 15 files in the PTE layer (operations,
internal-utils, editor/) from static Text.isText/Element.isElement to
instance methods editor.isText()/editor.isElement().

Operations files (7): Use operation.editor for instance method access.
Match callbacks use arrow wrappers: (n) => editor.isText(n).

Internal utils:
- operation-to-patches.ts: Thread editor as first param to
  setNodePatch/insertNodePatch. Update caller and tests.
- applyPatch.ts: Use editor methods for node checks. Use structural
  checks for patch values (partial objects without _type).
- apply-operation-to-portable-text.ts: Structural Array.isArray check
  (pure function, no editor in scope).
- values.ts: Structural checks (no editor in scope).

Editor files:
- sync-machine.ts: Use slateEditor.isText().
- create-editable-api.ts: Use editor.isText() in annotation queries.
- range-decorations-machine.ts: Use slateEditor.isElement() in decorate.

All Text/Element value imports removed or converted to type-only.
…void selection paths, WebKit keyboard behaviors
…d children

Eliminate the dual-tree architecture. editor.children IS the PT value.

Values (toSlateBlock/fromSlateBlock):
- Block objects: return {_type, _key, ...rest} directly (no children, value)
- Inline objects: return {_type, _key, ...props} directly (no children,
  value, __inline)
- Spans: ensure marks is always present (marks ?? [])
- fromSlateBlock: near-identity (tree is already PT-shaped)

Schema plugin:
- isInline: purely schema-based (remove __inline check)
- Dual-position types: check editor.children.includes() for position

Update value plugin:
- Remove applyOperationToPortableText call
- Delete editor.value — editor.children IS the PT value
- Apply operation first, then rebuild index maps

Operation handlers:
- insert.child: insert inline objects directly as PT-shaped nodes
- block.set: read props directly, safe/unsafe split for text/children
- child.set: delta-only setNodes, direct props on inline objects
- child.unset: Transforms.unsetNodes for inline objects
- delete: replace VOID_CHILD_KEY with isElement+isInline check

Rendering:
- render.element: schema-based isInline check (remove __inline)
- render.inline-object: destructure element directly (no value wrapper)

Types:
- VoidElement: remove children/value/__inline, add [key: string]: unknown
- Remove editor.value from PortableTextSlateEditor

All editor.value references replaced with editor.children (cast as
Array<PortableTextBlock> where needed for type compatibility).

42 files changed, -114 net lines.
operation-to-patches.ts:
- Block objects: read properties directly from operation.newProperties
  (no value unwrapping). Skip _key, _type, children.
- Inline objects: same direct read pattern.
- insertNodePatch: destructure {children: _c, ...nodeProps} from node.

Type system:
- PortableTextSlateEditor.children typed as Array<PortableTextBlock>
- Remove all 'as Array<PortableTextBlock>' casts (24 files)
- Remove unused PortableTextBlock imports
- Slate internals: children guards (?? []) for optional children

29 files changed, -28 net lines.
applyPatch.ts:
- Inline object set/unset: apply patches directly to node (no value
  unwrapping). Use applyAll on node with propPath, then setNodes/unsetNodes.
- Block object set/unset: same direct pattern. Detect block objects via
  !isTextBlock instead of 'value' in node.
- Remove __inline from reserved props lists.
- Remove all 'value' wrapper references.

operation-to-patches.test.ts:
- Update all test data to new format: properties directly on nodes,
  no value wrapper, no __inline, no void children.
- 317/317 unit tests pass.

2 files changed, -41 net lines.
Childless void elements can leave stale selection paths (e.g., [0,0]
pointing inside a void with no children). Add Node.has guards before
Node.get in three locations:

- editable.tsx mark placeholder handling (render phase)
- editable.tsx mark insertion useEffect (setTimeout)
- android-input-manager.ts deleteContent handling

All three follow the same pattern: check Node.has first, return
undefined if the path doesn't exist, then guard the Text.isText
check with a truthiness test.
The blockIndexMap was empty on initial render because createSlateEditor
called buildIndexMaps with instance.children (which is []) before the
<Slate> component sets editor.children = initialValue.

Fix: set instance.children = initialValue in createSlateEditor before
calling buildIndexMaps. This ensures the initial render has correct
blockIndexMap entries, preventing text blocks from being misclassified
as block objects.

This was the root cause of 126 browser test failures — text blocks
rendered as block objects caused 'Unable to find Block Object' warnings
and cascading 'Maximum call stack size exceeded' errors.
operation.block.unset.ts:
- Rewrite to use Transforms.unsetNodes directly (same pattern as text
  blocks). Remove old applyAll + value wrapper approach.

applyPatch.ts:
- Fix nested unset on block objects (e.g., _map.foo). The old code only
  detected removed top-level keys, missing nested property changes.
  Now also detects changed properties via reference comparison and
  applies them with Transforms.setNodes.

event.block.set.test.tsx:
- Remove expected href patch — only changed properties emit patches now.

event.child.set.test.tsx:
- Remove expected abilities/alive patches from set_key and set_property
  tests — only changed properties emit patches now.

7 browser test failures remain (4 collaborative editing normalization
patch timing, 1 normalization marks, 2 inline object).
collaborative-editing.test.tsx (4 tests):
- Remove expected 'set marks: []' deferred normalization patches.
  toSlateBlock now adds marks: [] during sync, so normalization has
  nothing to fix and no patch is emitted. The deferred normalization
  system still works for other properties (style, markDefs).

self-solving.test.tsx (1 test):
- Same: remove spanPatch (marks: []) from expected patches/mutations.
  Only blockPatch (markDefs: []) is deferred now.

event.insert.inline-object.test.tsx (1 test):
- Fix key ordering: expect ['_type', '_key'] instead of ['_key', '_type'].
  Object.keys order changed with direct property model.

All browser tests pass (0 real failures, only sandbox iframe flakes).
@vercel
Copy link

vercel bot commented Feb 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
portable-text-editor-documentation Ready Ready Preview, Comment Feb 20, 2026 5:33pm
portable-text-example-basic Ready Ready Preview, Comment Feb 20, 2026 5:33pm
portable-text-playground Ready Ready Preview, Comment Feb 20, 2026 5:33pm

Request Review

@changeset-bot
Copy link

changeset-bot bot commented Feb 20, 2026

🦋 Changeset detected

Latest commit: dc1bb71

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@portabletext/editor Minor
@portabletext/plugin-character-pair-decorator Major
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-markdown-shortcuts Major
@portabletext/plugin-one-line Major
@portabletext/plugin-paste-link Major
@portabletext/plugin-sdk-value Major
@portabletext/plugin-typeahead-picker Patch
@portabletext/plugin-typography Patch
@portabletext/toolbar Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

The childless voids commit (e1d2c4c) reverted the smart set_node
guards from PR #2224 back to unconditional text/children rejection.

Restores context-aware guards in both locations:
- general.ts (operation handler): only throw for 'text' on Text nodes,
  only throw for 'children' on non-void Elements
- set-nodes.ts (Transforms.setNodes): same conditional filtering

Uses instance methods (editor.isText, editor.isElement, editor.isVoid)
consistent with the Phase 2 migration.

Also fixes prettier formatting on 8 files flagged by CI.
Base automatically changed from feat-vendor-slate to main February 23, 2026 11:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant