Skip to content

feat: simpler DOM mapping#2387

Merged
christianhg merged 1 commit intomainfrom
feat-simpler-dom-mapping
Mar 20, 2026
Merged

feat: simpler DOM mapping#2387
christianhg merged 1 commit intomainfrom
feat-simpler-dom-mapping

Conversation

@christianhg
Copy link
Member

@christianhg christianhg commented Mar 17, 2026

Note

🎉 This PR removes the last remaining WeakMaps from PTE and from the vendored Slate code. At the point of vendoring Slate we had 26 WeakMaps scattered around the code base, holding state information and aiding DOM mapping. After this PR we have 0.

Historically, Slate has used WeakMaps to bridge between DOM elements and Slate nodes. Every component registered nodes into these maps during render. This caused a tight coupling between the model and the DOM which also violates React 19's constraints where render must be pure.

This PR replaces all six WeakMaps with a single data-pt-path attribute rendered onto the DOM nodes (e.g., data-pt-path='[_key=="k0"].children[_key=="s0"]'). Finding a DOM node from a path is now just a querySelector and mapping from a DOM node to a node in the model is now just about reading the data attribute, translating the path and traversing the model. No maps, no registration, no cleanup.

This is possible since keyed paths are stable. Unlike indexed paths, which is Slate's native path model, inserting or removing a sibling doesn't invalidate keyed paths. The path format also encodes field names between key segments, which means it extends naturally to containers where children live in schema-defined fields like rows, cells, or content rather than a single children array. This is a key unlock for when PTE needs to support nested structures with a resilient mapping between DOM and model.

Note

A note on performance: querySelector on a full document can be slow, but here it's scoped to a single block's DOM subtree via editorElement.children[blockIndex]. For flat content (text blocks, void objects), there's no querySelector at all - it's a direct child lookup. querySelector only runs for paths deeper than one level, and even then it searches within a single block element, not the full editor.

Note

A note on security: Since data-pt-path attributes contain key values that flow into querySelector, all selectors are escaped with CSS.escape to prevent selector injection from custom key generators.

Note

A note on data integrity: The data-pt-path attribute uses Sanity's bracket notation for keyed segments (e.g., [_key=="k0"].children[_key=="s0"]). This format keeps keys inside brackets, so keys containing dots or other special characters don't break the path serialization. The dot character is only ever a field name separator, never part of a key value.

@changeset-bot
Copy link

changeset-bot bot commented Mar 17, 2026

🦋 Changeset detected

Latest commit: afcc588

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 Patch
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link Patch
@portabletext/plugin-sdk-value Patch
@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

@vercel
Copy link

vercel bot commented Mar 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 Mar 20, 2026 0:28am
portable-text-example-basic Ready Ready Preview, Comment Mar 20, 2026 0:28am
portable-text-playground Ready Ready Preview, Comment Mar 20, 2026 0:28am

Request Review

@github-actions
Copy link
Contributor

github-actions bot commented Mar 17, 2026

📦 Bundle Stats — @portabletext/editor

Compared against main (41bf92f1)

@portabletext/editor

Metric Value vs main (41bf92f)
Internal (raw) 755.0 KB +1.8 KB, +0.2%
Internal (gzip) 142.0 KB +526 B, +0.4%
Bundled (raw) 1.36 MB +1.8 KB, +0.1%
Bundled (gzip) 304.8 KB +390 B, +0.1%
Import time 100ms -0ms, -0.1%

@portabletext/editor/behaviors

Metric Value vs main (41bf92f)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 6ms -0ms, -0.3%

@portabletext/editor/plugins

Metric Value vs main (41bf92f)
Internal (raw) 2.5 KB -
Internal (gzip) 910 B -
Bundled (raw) 2.3 KB -
Bundled (gzip) 839 B -
Import time 12ms -0ms, -0.3%

@portabletext/editor/selectors

Metric Value vs main (41bf92f)
Internal (raw) 60.2 KB -
Internal (gzip) 9.4 KB +2 B, +0.0%
Bundled (raw) 56.7 KB -
Bundled (gzip) 8.6 KB +1 B, +0.0%
Import time 10ms -0ms, -0.2%

@portabletext/editor/utils

Metric Value vs main (41bf92f)
Internal (raw) 24.2 KB -
Internal (gzip) 4.7 KB -
Bundled (raw) 22.2 KB -
Bundled (gzip) 4.4 KB +1 B, +0.0%
Import time 10ms +0ms, +1.3%

🗺️ . · ./behaviors · ./plugins · ./selectors · ./utils · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

@christianhg christianhg force-pushed the feat-simpler-dom-mapping branch from 8ece896 to f3ce145 Compare March 17, 2026 08:23
@christianhg christianhg force-pushed the feat-simpler-dom-mapping branch from f3ce145 to 1dc50d7 Compare March 17, 2026 09:04
@christianhg christianhg force-pushed the feat-simpler-dom-mapping branch from 1dc50d7 to 818940f Compare March 17, 2026 11:06
@christianhg christianhg force-pushed the feat-simpler-dom-mapping branch from 818940f to 222849b Compare March 17, 2026 11:14
@christianhg christianhg force-pushed the feat-simpler-dom-mapping branch from 222849b to 5a8b722 Compare March 17, 2026 11:21
@christianhg christianhg force-pushed the feat-simpler-dom-mapping branch from 5a8b722 to bc7f5bd Compare March 17, 2026 11:44
@christianhg christianhg force-pushed the feat-simpler-dom-mapping branch from 23a2dc0 to f023d35 Compare March 17, 2026 13:52
@christianhg christianhg force-pushed the feat-simpler-dom-mapping branch from faf861c to a08d14e Compare March 17, 2026 16:08
@christianhg christianhg force-pushed the feat-simpler-dom-mapping branch from be7788c to d55f7a0 Compare March 18, 2026 06:32
Historically, Slate has used WeakMaps to bridge between DOM elements and Slate
nodes. Every component registered nodes into these maps during render. This
caused a tight coupling between the model and the DOM which also violates React
19's constraints where render must be pure.

This PR replaces all six WeakMaps with a single `data-pt-path` attribute
rendered onto the DOM nodes (e.g.,
`data-pt-path="[_key=="k0"].children[_key=="s1"]"`). Finding a DOM node from a
path is now just a `querySelector` and mapping from a DOM node to a node in the
model is now just about reading the data attribute, translating the path and
traversing the model. No maps, no registration, no cleanup.

This is possible since keyed paths are stable. Unlike indexed paths, which is
Slate's native path model, inserting or removing a sibling doesn't invalidate
keyed paths. The path format also encodes field names between key segments,
which means it extends naturally to containers where children live in
schema-defined fields like `rows`, `cells`, or `content` rather than a single
`children` array. This is a key unlock for when PTE needs to support nested
structures with a resilient mapping between DOM and model.
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.

2 participants