fix(perf): seek to the range's boundaries in range-bounded traversal#2914
Merged
Conversation
🦋 Changeset detectedLatest commit: d5902a5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
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 latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📦 Bundle Stats —
|
| 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.
b4ae520 to
d5902a5
Compare
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, runningcomparePathsInTree, a serializing map lookup, per entry until it passedto. Every consumer of a selection-bounded walk paid O(caret position): the decorator ops, annotation ops,unset-matched-in-range, andengine-utils.The fix seeks instead of scanning. A new
boundaryChildIndexresolves 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 ablockIndexMaplookup verified against the child's actual key, with the linear-scan fallback mirroringgetNode/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 (
frombeing an ancestor ofto), reverse iteration, and single-block ranges, and the full suite is green (841 unit, 1698 chromium). Togglingstrongon 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 insidecomparePaths, 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.