Skip to content

Comments

feat(editor): add threaded block map with O(1) lookups#2195

Draft
christianhg wants to merge 1 commit intomainfrom
feat/threaded-block-map
Draft

feat(editor): add threaded block map with O(1) lookups#2195
christianhg wants to merge 1 commit intomainfrom
feat/threaded-block-map

Conversation

@christianhg
Copy link
Member

@christianhg christianhg commented Feb 17, 2026

Add threaded block map with O(1) lookups and linked-list navigation

What

Introduces a new BlockMap data structure that replaces blockIndexMap as the primary way to look up and navigate blocks. Each entry stores its index, prev/next pointers (threaded in document reading order), and parent/field pointers for future container support.

The old blockIndexMap is kept populated and marked @deprecated for backwards compatibility.

Why

blockIndexMap is a flat Map<string, number> that maps _key to array index. It has two problems:

  1. No navigation. Getting the next or previous block requires walking the value array. Selectors like getNextBlock and getPreviousBlock had to scan by index arithmetic.

  2. Flat only. Keys are bare _key strings, which are only unique within their sibling array. When containers introduce nested blocks, two blocks at different depths could share a _key, making the map ambiguous.

The block map solves both: O(1) lookup by key, O(1) next/prev via linked list, and path-based keys that are unique at any depth.

How it works

Each BlockMapEntry stores:

{
  index: number        // position in parent array
  prev: string | null  // previous block in reading order
  next: string | null  // next block in reading order
  parent: string | null // parent block's map key (null for top-level)
  field: string | null  // field name in parent (null for top-level)
}

Block nodes are not stored on the entry. Access the node via context.value[entry.index]. One source of truth, no data duplication.

Key encoding: Top-level blocks use their bare _key as the map key. Nested blocks (when containers land) will use a length-prefixed path encoding: parentMapKey/7:content/6:block1. Length prefixes prevent ambiguity since _key can contain any character.

Surgical updates: The map is updated per Slate operation instead of being rebuilt from scratch:

Operation Block map work
insert_node (block-level) Create entry, splice into chain, shift indices after
remove_node (block-level) Unlink from chain, delete entry, shift indices after
split_node (block-level) Insert new entry after split point
merge_node (block-level) Remove merged entry, shift indices
set_node (key change) Re-key entry, update neighbor references
move_node Full rebuild (rare operation, complex index math)
Text ops, selection, child-level ops No-op

What changed

New files:

  • block-map.ts: BlockMap, BlockMapEntry, blockMapKey(), buildBlockMap(), updateBlockMap()
  • block-map.test.ts: 36 tests covering build, surgical updates, key encoding, and collision resistance

Migrated to block map (13 files):

  • 8 selectors: getFocusBlock, getAnchorBlock, getNextBlock, getPreviousBlock, getSelectedBlocks, getSelectedChildren, getSelectedTextBlocks, getSelectedValue
  • to-slate-range.ts: block index lookup
  • create-editable-api.ts: focusBlock, getBlock
  • operation.child.unset.ts: block lookup
  • slate-plugin.update-value.ts: calls updateBlockMap after each operation
  • slate-plugin.unique-keys.ts: duplicate key detection

Tests

36 unit tests covering:

  • blockMapKey: top-level, nested, deeply nested, special characters, collision resistance
  • buildBlockMap: empty, single block, multiple blocks, mixed types, rebuild clears state
  • Surgical updates: insert (start/middle/end/empty), remove (start/middle/end/last), split, merge, key change, move, sequential operations
  • Container block in flat walk (documents current behavior for when container walking is added)

All existing tests continue to pass (353 total).

@vercel
Copy link

vercel bot commented Feb 17, 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 18, 2026 10:41am
portable-text-example-basic Ready Ready Preview, Comment Feb 18, 2026 10:41am
portable-text-playground Ready Ready Preview, Comment Feb 18, 2026 10:41am

Request Review

@changeset-bot
Copy link

changeset-bot bot commented Feb 17, 2026

⚠️ No Changeset found

Latest commit: f420e6d

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@christianhg christianhg force-pushed the feat/threaded-block-map branch from c55e32d to a1fbd5d Compare February 17, 2026 12:32
@christianhg christianhg force-pushed the feat/threaded-block-map branch from a1fbd5d to a25ed38 Compare February 17, 2026 18:46
@christianhg christianhg force-pushed the feat/threaded-block-map branch from a25ed38 to f40079d Compare February 18, 2026 09:44
…t navigation

Add BlockMap data structure that maps block _key to BlockMapEntry with
node reference, index, and prev/next pointers for O(1) document-order
navigation. This replaces index-based lookups across the editor.

Data structure:
- BlockMapEntry: { node, index, prev, next, parent, field }
- BlockMap: Map<string, BlockMapEntry>
- buildBlockMap(): full rebuild from value (mount + structural ops)
- updateBlockMap(): incremental per-operation updates

Migration:
- Add blockMap to PortableTextSlateEditor and EditorSnapshot
- Mark blockIndexMap as @deprecated on both types
- Migrate all selectors: getFocusBlock, getAnchorBlock, getNextBlock,
  getPreviousBlock, getSelectedBlocks, getSelectedTextBlocks,
  getSelectedValue, getSelectedChildren
- Migrate comparePoints and isOverlappingSelection
- Migrate toSlateRange to use blockMap.get(key).index
- Migrate all operations to use blockMap for block lookups
- Migrate render.element.tsx, create-editable-api.ts,
  selection-state-context.tsx, range-decorations-machine.ts
- Update create-test-snapshot.ts and to-slate-range.test.ts

Container support (parent/field on BlockMapEntry) is scaffolded but
getContainerField returns null for now — flat documents only.
Container awareness will be added when containerTypes lands on schema.

All 317 tests pass, full monorepo types clean (39/39).
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