Skip to content

fix(perf): seek to the range's boundaries in range-bounded traversal#2914

Merged
christianhg merged 2 commits into
mainfrom
seek-get-nodes-in-range
Jul 6, 2026
Merged

fix(perf): seek to the range's boundaries in range-bounded traversal#2914
christianhg merged 2 commits into
mainfrom
seek-get-nodes-in-range

Conversation

@christianhg

Copy link
Copy Markdown
Member

Toggling a decorator or annotation scaled with the selection's position in the document: bolding one word in the last block of an 8,000-block document cost ~10ms, walking blocks the traversal could never yield. This came out of the traversal-utils audit as the last measured hot-path scan.

The cost sits in getNodesInRange (traversal/get-nodes.ts). The {from, to} mode pruned subtree descent but iterated every child at each level from the first one, running comparePathsInTree, a serializing map lookup, per entry until it passed to. Every consumer of a selection-bounded walk paid O(caret position): the decorator ops, annotation ops, unset-matched-in-range, and engine-utils.

The fix seeks instead of scanning. A new boundaryChildIndex resolves the child each boundary path passes through at the current level: a prefix match against the level's shared path prefix (children differ only in their last segment), then a blockIndexMap lookup verified against the child's actual key, with the linear-scan fallback mirroring getNode/getChildren. The loop then iterates only the window between the two boundaries. The design leans on two things: the existing per-entry checks (canStopTraversal/couldContainInRangeNodes/isInRange) are untouched and remain the semantic source of truth, entries outside the window are provably exactly those the old checks skipped or stopped at; and an unresolvable boundary (numeric prefixes, foreign branches, map misses) leaves that side of the window open, degrading to the previous full scan rather than misbehaving, the same fallback discipline as the rest of the traversal layer.

Net behavior unchanged: all 32 range-traversal tests pass unmodified, including deep container boundaries, ancestor bounds (from being an ancestor of to), reverse iteration, and single-block ranges, and the full suite is green (841 unit, 1698 chromium). Toggling strong on one word in the last block (chromium, warmed, medians): 2.2 to 0.7ms at 1k blocks, 10.2 to 3.2ms at 8k (3.2x). The residual scaling in that gesture is the linear sibling ordering inside comparePaths, which the decorator/annotation ops call directly; consolidating that onto the map-based compare is the companion ticket and stacks naturally on this. A second commit adds a decorator-toggle-at-document-end baseline to the performance test file.

@changeset-bot

changeset-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d5902a5

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

@vercel

vercel Bot commented Jul 6, 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 6, 2026 7:29am
portable-text-example-basic Ready Ready Preview, Comment Jul 6, 2026 7:29am
portable-text-playground Ready Ready Preview, Comment Jul 6, 2026 7:29am

Request Review

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (a71a8308)

@portabletext/editor

Metric Value vs main (a71a830)
Internal (raw) 802.1 KB +1.4 KB, +0.2%
Internal (gzip) 153.4 KB +307 B, +0.2%
Bundled (raw) 1.41 MB +1.4 KB, +0.1%
Bundled (gzip) 317.6 KB +324 B, +0.1%
Import time 97ms +0ms, +0.2%

@portabletext/editor/behaviors

Metric Value vs main (a71a830)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 2ms +0ms, +1.8%

@portabletext/editor/plugins

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

@portabletext/editor/selectors

Metric Value vs main (a71a830)
Internal (raw) 81.5 KB +1.4 KB, +1.8%
Internal (gzip) 15.0 KB +302 B, +2.0%
Bundled (raw) 77.0 KB +1.4 KB, +1.9%
Bundled (gzip) 13.8 KB +303 B, +2.2%
Import time 8ms +0ms, +1.1%

@portabletext/editor/traversal

Metric Value vs main (a71a830)
Internal (raw) 28.3 KB +1.4 KB, +5.2%
Internal (gzip) 5.6 KB +304 B, +5.6%
Bundled (raw) 28.2 KB +1.4 KB, +5.3%
Bundled (gzip) 5.5 KB +299 B, +5.6%
Import time 6ms -0ms, -1.7%

@portabletext/editor/utils

Metric Value vs main (a71a830)
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.2%

🗺️ . · ./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 (a71a8308)

Metric Value vs main (a71a830)
Internal (raw) 53.8 KB -
Internal (gzip) 9.8 KB -
Bundled (raw) 348.9 KB -
Bundled (gzip) 96.3 KB -
Import time 40ms +1ms, +2.5%

🗺️ 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.

`getNodesInRange` iterated every child at each level from the first
one, running `comparePathsInTree` (a serializing map lookup) per entry
until it passed `to`; only subtree descent was pruned. Every
selection-bounded walk therefore paid O(position of the selection in
the document): a one-word decorator toggle at the end of an 8k-block
document spent ~10ms walking blocks it could never yield.

`boundaryChildIndex` now resolves the child each boundary path passes
through at the current level (prefix match against the level's shared
path prefix, then `blockIndexMap` with the verify-then-scan fallback
mirroring `getNode`/`getChildren`), and the loop iterates only the
window between the two boundaries. The existing per-entry checks
(`canStopTraversal`/`couldContainInRangeNodes`/`isInRange`) are
untouched and remain the semantic source of truth; entries outside the
window are exactly those the old checks skipped or stopped at. An
unresolvable boundary (numeric prefixes, foreign branches, map misses
with no matching child) leaves that side of the window open, degrading
to the previous full scan rather than misbehaving.

Net behavior unchanged: all 32 range-traversal tests pass unmodified,
including deep container boundaries, ancestor bounds, and reverse
iteration. Toggling `strong` on one word in the last block (chromium,
warmed): 2.2 to 0.7ms at 1k blocks, 10.2 to 3.2ms at 8k. The residual
scaling comes from the linear sibling ordering inside `comparePaths`,
which the decorator and annotation operations call directly; that
consolidation is tracked separately.
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