Skip to content

fix: skip leafless subtrees when normalizing DOM points#2899

Draft
christianhg wants to merge 1 commit into
mainfrom
fix-normalize-dom-point-leafless-subtrees
Draft

fix: skip leafless subtrees when normalizing DOM points#2899
christianhg wants to merge 1 commit into
mainfrom
fix-normalize-dom-point-leafless-subtrees

Conversation

@christianhg

Copy link
Copy Markdown
Member

Triple-clicking a table in the playground shows a whole-table selection on screen while the editor's model keeps a stale collapsed caret, so Delete and typing act on something other than what's visibly selected. Clicking near the table's edges similarly parks carets that never map into the model (EDEX-1118 reported the same family from the deployed playground in June: typing after such a click injects content into the last cell, then below the table).

The diagnosis: getEditableChildAndIndex treats any element with children as content. A table's <colgroup> has children (the <col>s) but no editable content, so normalizeDOMPoint's descent walks into it and dead-ends: the resulting point on a <col> resolves to nothing in toSelectionPoint, suppressThrow: true swallows the failure, and the whole selectionchange sync silently skips, leaving the model stale. Any leafless subtree inside a container render triggers it; <colgroup> is just the canonical case every table renders.

The fix extends the child-seeking skip condition: elements whose subtree holds no editable content (no text, no rendered block/inline object) are skipped with backtracking, exactly as contenteditable="false" elements already are. The predicate is deliberately conservative: zero-width leaves keep matching through their text content and void objects through their rendered attributes, so void-adjacent positions behave exactly as before.

Two tests pin it on a container table rendered with a plain <colgroup> (no attribute tricks), both driven through the real selectionchange sync: an element-level range spanning the table must map to first-cell start to last-cell end, and a collapsed element-level point before the colgroup must map into the first cell. Both fail without the fix (one maps wrong, one maps to null), verified red. Green on chromium, firefox, and webkit; the full chromium editor browser suite passes 1690/1690.

Follows #2898 in the same subsystem: that one stopped the validator from destroying equivalent DOM selections, this one makes the mapping produce the right answer in the first place. The remaining piece of the cluster is EDEX-1512 (canonicalizing collapsed carets parked on structural elements).

@vercel

vercel Bot commented Jul 2, 2026

Copy link
Copy Markdown

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 Jul 4, 2026 7:49am
portable-text-example-basic Ready Ready Preview, Comment Jul 4, 2026 7:49am
portable-text-playground Ready Ready Preview, Comment Jul 4, 2026 7:49am

Request Review

@changeset-bot

changeset-bot Bot commented Jul 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bd7c064

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

This PR includes changesets to release 14 packages
Name Type
@portabletext/editor Patch
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-dnd Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-list-index Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link Patch
@portabletext/plugin-sdk-value Patch
@portabletext/plugin-table 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

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (59d03e2e)

@portabletext/editor

Metric Value vs main (59d03e2)
Internal (raw) 799.3 KB +208 B, +0.0%
Internal (gzip) 152.7 KB +34 B, +0.0%
Bundled (raw) 1.41 MB +210 B, +0.0%
Bundled (gzip) 316.9 KB +44 B, +0.0%
Import time 99ms -1ms, -1.4%

@portabletext/editor/behaviors

Metric Value vs main (59d03e2)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 2ms -0ms, -2.3%

@portabletext/editor/plugins

Metric Value vs main (59d03e2)
Internal (raw) 2.7 KB -
Internal (gzip) 894 B -
Bundled (raw) 2.5 KB -
Bundled (gzip) 827 B -
Import time 7ms +0ms, +0.5%

@portabletext/editor/selectors

Metric Value vs main (59d03e2)
Internal (raw) 80.1 KB -
Internal (gzip) 14.7 KB -
Bundled (raw) 75.5 KB -
Bundled (gzip) 13.5 KB -
Import time 8ms +0ms, +1.5%

@portabletext/editor/traversal

Metric Value vs main (59d03e2)
Internal (raw) 26.9 KB -
Internal (gzip) 5.3 KB -
Bundled (raw) 26.7 KB -
Bundled (gzip) 5.2 KB -
Import time 6ms -0ms, -0.5%

@portabletext/editor/utils

Metric Value vs main (59d03e2)
Internal (raw) 29.7 KB -
Internal (gzip) 6.2 KB -
Bundled (raw) 27.1 KB -
Bundled (gzip) 5.8 KB -
Import time 6ms -0ms, -0.4%

🗺️ . · ./behaviors · ./plugins · ./selectors · ./traversal · ./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.

📦 Bundle Stats — @portabletext/markdown

Compared against main (59d03e2e)

Metric Value vs main (59d03e2)
Internal (raw) 53.8 KB -
Internal (gzip) 9.8 KB -
Bundled (raw) 348.9 KB -
Bundled (gzip) 96.3 KB -
Import time 38ms -1ms, -1.3%

🗺️ View treemap · 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.

`getEditableChildAndIndex` treated any element with children as content,
so a leafless subtree (a table's <colgroup> being the canonical case)
became a dead-end: `normalizeDOMPoint` descended into it, the resulting
point on a <col> resolved to nothing in `toSelectionPoint`,
`suppressThrow` swallowed the failure, and the entire selection sync
silently skipped, leaving the model stale while the screen showed e.g. a
whole-table selection after a triple-click.

The child-seeking loop now also skips elements whose subtree holds no
editable content (no text and no rendered block/inline object),
backtracking to a sibling exactly as it already does for
`contenteditable="false"` elements. Zero-width leaves keep matching via
their text content, and void objects keep matching via their rendered
attributes, so void-adjacent positions are unaffected. Pinned by two
tests on a container table rendered with a plain <colgroup>: an
element-level range spanning the table maps to first-cell start ->
last-cell end, and a collapsed element-level point before the colgroup
maps into the first cell (both fail without the fix: one maps wrong, one
maps to null).
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