Skip to content

Speed up component tree resolution ($parent, $root, $query)#730

Merged
titouanmathis merged 5 commits into
mainfrom
perf/component-tree-resolution
Jul 13, 2026
Merged

Speed up component tree resolution ($parent, $root, $query)#730
titouanmathis merged 5 commits into
mainfrom
perf/component-tree-resolution

Conversation

@titouanmathis

Copy link
Copy Markdown
Contributor

Summary

Component tree resolution scanned the whole global instance set on every access. This replaces those O(N_instances) scans with DOM-local lookups, keyed on each element's __base__ map — the same O(1)-per-ancestor pattern closestComponent/$closest already uses.

  • __parent (backing $parent and $root): resolve by walking the DOM ancestors instead of scanning every instance + copying the storage Set. Now O(tree depth), independent of instance count. Added an O(1) hasInstance() helper to preserve the exact "mounted instance" visibility semantics.
  • queryComponent / queryComponentAll ($query / $queryAll): split by scope. from=document keeps the global-storage path (so detached elements and the no-containment semantics are preserved byte-for-byte), while from=element resolves through a scoped querySelectorAll('*') + explicit self-check + __base__ lookup, gated by hasInstance + terminated-skip + instanceIsMatching. The element path now scales with the from subtree size, not the total instance count.

Benchmarks

Added parent-resolution.bench.ts and query-resolution.bench.ts (50/500/2000 instances, depth 5). The scan cost grows with instance count while the new strategies stay flat:

Path 50 500 2000
__parent (DOM walk vs global scan) ~85× ~705× ~2675×
$query element-scoped (scoped DOM vs global scan) ~14× ~174× ~595×

Behavioral changes (documented in the changelog)

Two edge-case changes on the element-scoped $query/$queryAll path, both inherent to the __base__ (one entry per name per element) approach:

  1. Results are returned in DOM order instead of mount order (ordering was never a documented guarantee). queryComponent may return a different instance when several match within an element root.
  2. When several live instances share the same name on the same element, only one is returned (auto-mount prevents this via getInstanceFromElement; it requires manual double-construction).

The normal one-instance-per-element case is verified identical to the previous behavior.

Tests

Full suite green (888 passed, 2 skipped). Added 5 tests covering the scoped-query invariants: detached elements with from=document, from-self match, attribute-independent resolution, :mounted/:destroyed filters, and skipping terminated __base__ entries. oxlint and tsgo clean.

Note: this is a performance change and is orthogonal to the child→parent data-flow discussion; hasInstance deliberately preserves the current "resolvable only once the ancestor is mounted" semantics — it does not change when a parent becomes resolvable.

🤖 Generated with Claude Code

…nstances

The `__parent` getter (backing `$parent` and `$root`) iterated the whole
global instance set on every access, plus a defensive `Set` copy and an
`$el.contains()` walk per candidate — O(N_instances × depth). Walk the DOM
ancestors and resolve via each element's `__base__` map instead (the same
O(1)-per-ancestor lookup `closestComponent` already uses), guarded by an
O(1) `hasInstance()` membership check to preserve the "mounted instance"
semantics. Resolution is now O(tree depth), independent of instance count.

Co-authored-by: Claude
Compares the previous global-scan strategy against the DOM ancestor walk
across 50/500/2000 instances. The scan cost grows with instance count
(~0.03ms → ~1.05ms) while the walk stays flat (~0.0004ms), regardless of N.

Co-authored-by: Claude
…scan

`queryComponent`/`queryComponentAll` scanned the whole global instance set on
every call. Split resolution by scope: `from=document` keeps the global-storage
path (so detached elements and the no-containment semantics are preserved
byte-for-byte), while `from=element` resolves matches through a scoped
`querySelectorAll('*')` descendant traversal plus an explicit self check, reading
each element's `__base__` map (keyed on config.name only, attribute-independent),
gated by `hasInstance` + terminated-skip + `instanceIsMatching`. The element path
now scales with the `from` subtree size instead of the total instance count.

Two behavioral changes on the element-scoped path, both edge cases: results are
returned in DOM order rather than mount order, and when several live instances
share the same name on the same element only one is returned (the `__base__`
entry). Documented in the changelog.

Co-authored-by: Claude
Compares the global-scan strategy against the scoped DOM traversal for
element-scoped queries across 50/500/2000 instances (~14x/174x/595x faster,
independent of the total mounted-instance count).

Co-authored-by: Claude
@titouanmathis
titouanmathis force-pushed the perf/component-tree-resolution branch from 46ed51f to d88fec2 Compare July 13, 2026 12:58
@github-actions

Copy link
Copy Markdown

Export Size

@studiometa/js-toolkit

Name Size Diff
queryComponent 613 B +128 B (+26.39%) 🔺
queryComponentAll 616 B +122 B (+24.70%) 🔺
HELPERS 2.54 kB +105 B (+4.32%) 🔺
Base 8.77 kB +61 B (+0.70%) 🔺
BASE 8.81 kB +53 B (+0.61%) 🔺
FRAMEWORK 13.71 kB +73 B (+0.54%) 🔺
ALL 20.77 kB +60 B (+0.29%) 🔺
Unchanged

@studiometa/js-toolkit

Name Size Diff
AbstractService 528 B -
addClass 226 B -
addStyle 238 B -
animate 3.27 kB -
boundingRectToCircle 154 B -
cache 194 B -
camelCase 397 B -
clamp 67 B -
clamp01 87 B -
closestComponent 418 B -
collideCircleCircle 99 B -
collideCircleRect 159 B -
collidePointCircle 112 B -
collidePointRect 103 B -
collideRectRect 99 B -
createApp 893 B -
createEaseInOut 116 B -
createEaseOut 71 B -
createElement 592 B -
createLocalStorage 1.19 kB -
createLocalStorageProvider 264 B -
createMemoryStorageProvider 146 B -
createNoopProvider 96 B -
createRange 90 B -
createSessionStorage 1.23 kB -
createSessionStorageProvider 254 B -
createStorage 1.17 kB -
createUrlSearchParamsInHashProvider 413 B -
createUrlSearchParamsInHashStorage 1.21 kB -
createUrlSearchParamsProvider 386 B -
createUrlSearchParamsStorage 1.21 kB -
damp 78 B -
dashCase 372 B -
debounce 92 B -
DECORATORS 7.59 kB -
defineFeatures 322 B -
domScheduler 296 B -
DragService 1.94 kB -
ease 435 B -
easeInCirc 68 B -
easeInCubic 59 B -
easeInExpo 80 B -
easeInOutCirc 141 B -
easeInOutCubic 130 B -
easeInOutExpo 134 B -
easeInOutQuad 128 B -
easeInOutQuart 133 B -
easeInOutQuint 152 B -
easeInOutSine 151 B -
easeInQuad 63 B -
easeInQuart 61 B -
easeInQuint 62 B -
easeInSine 77 B -
easeLinear 49 B -
easeOutCirc 115 B -
easeOutCubic 103 B -
easeOutExpo 112 B -
easeOutQuad 103 B -
easeOutQuart 100 B -
easeOutQuint 103 B -
easeOutSine 121 B -
endsWith 88 B -
getAncestorWhere 91 B -
getAncestorWhereUntil 119 B -
getClosestParent 182 B -
getComponentResolver 138 B -
getDirectChildren 201 B -
getInstanceFromElement 92 B -
getInstances 185 B -
getOffsetSizes 159 B -
hasWindow 62 B -
historyPush 499 B -
historyReplace 503 B -
importOnInteraction 887 B -
importOnMediaQuery 236 B -
importWhenIdle 223 B -
importWhenPrefersMotion 275 B -
importWhenVisible 912 B -
inertiaFinalValue 142 B -
isArray 70 B -
isBoolean 78 B -
isDefined 86 B -
isDev 72 B -
isDirectChild 219 B -
isEmpty 207 B -
isEmptyString 93 B -
isFunction 72 B -
isNull 72 B -
isNumber 84 B -
isObject 105 B -
isString 86 B -
keyCodes 97 B -
KeyService 854 B -
lerp 57 B -
loadElement 169 B -
loadIframe 189 B -
loadImage 188 B -
loadLink 186 B -
loadScript 197 B -
LoadService 593 B -
localStorageProvider 715 B -
logTree 510 B -
lowerCase 60 B -
map 71 B -
matrix 106 B -
mean 91 B -
memo 100 B -
memoize 189 B -
memoryStorageProvider 607 B -
MutationService 799 B -
nextFrame 162 B -
nextMicrotask 111 B -
nextTick 134 B -
noop 39 B -
noopValue 49 B -
objectToURLSearchParams 302 B -
pascalCase 374 B -
PointerService 1.07 kB -
Queue 226 B -
RafService 956 B -
random 64 B -
randomInt 77 B -
randomItem 211 B -
registerComponent 103 B -
removeClass 222 B -
removeStyle 238 B -
ResizeService 1.01 kB -
round 56 B -
saveActiveElement 56 B -
ScrollService 1.28 kB -
scrollTo 2.31 kB -
SERVICES 4.01 kB -
sessionStorageProvider 712 B -
SmartQueue 411 B -
smoothTo 473 B -
snakeCase 374 B -
spring 115 B -
startsWith 87 B -
throttle 101 B -
toggleClass 225 B -
transform 288 B -
transition 916 B -
trapFocus 363 B -
tween 1.72 kB -
untrapFocus 45 B -
upperCase 54 B -
urlSearchParamsInHashProvider 537 B -
urlSearchParamsProvider 539 B -
useDrag 1.98 kB -
useKey 871 B -
useLoad 610 B -
useMutation 829 B -
usePointer 1.09 kB -
useRaf 963 B -
useResize 1.02 kB -
useScheduler 290 B -
useScroll 1.29 kB -
UTILS 9.59 kB -
wait 79 B -
withBreakpointManager 1.42 kB -
withBreakpointObserver 1.61 kB -
withDrag 2.09 kB -
withExtraConfig 135 B -
withFreezedOptions 140 B -
withGroup 252 B -
withIntersectionObserver 260 B -
withLeadingCharacters 88 B -
withLeadingSlash 107 B -
withMountOnMediaQuery 322 B -
withMountWhenInView 286 B -
withMountWhenPrefersMotion 355 B -
withMutation 959 B -
withName 81 B -
withoutLeadingCharacters 86 B -
withoutLeadingCharactersRecursive 124 B -
withoutLeadingSlash 93 B -
withoutTrailingCharacters 98 B -
withoutTrailingCharactersRecursive 129 B -
withoutTrailingSlash 103 B -
withRelativePointer 1.23 kB -
withResponsiveOptions 2.26 kB -
withScrolledInView 2.93 kB -
withTrailingCharacters 96 B -
withTrailingSlash 120 B -
wrap 77 B -

@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.43590% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 97.42%. Comparing base (f524416) to head (d88fec2).

Files with missing lines Patch % Lines
packages/js-toolkit/Base/Base.ts 90.90% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #730      +/-   ##
==========================================
- Coverage   97.78%   97.42%   -0.37%     
==========================================
  Files         162      162              
  Lines        3710     3729      +19     
  Branches     1040     1040              
==========================================
+ Hits         3628     3633       +5     
- Misses         74       86      +12     
- Partials        8       10       +2     
Flag Coverage Δ
eslint-plugin-js-toolkit 94.18% <ø> (ø)
js-toolkit 97.42% <97.43%> (-0.37%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed-hq

codspeed-hq Bot commented Jul 13, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 31.25%

⚡ 10 improved benchmarks
✅ 105 untouched benchmarks
🆕 12 new benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
schedule + flush 50 read tasks 358.6 µs 68.9 µs ×5.2
combined (x, y, scale, rotate) 327.5 µs 288.8 µs +13.41%
all transforms 340.7 µs 300.5 µs +13.37%
rotate 322.2 µs 284.6 µs +13.22%
skewX, skewY 328.7 µs 291.4 µs +12.81%
translate3d (x only) 325.4 µs 289.2 µs +12.55%
rotateX, rotateY, rotateZ 326.4 µs 290.6 µs +12.3%
scale 325.8 µs 290.3 µs +12.24%
skew 322.2 µs 287.5 µs +12.07%
scaleX, scaleY, scaleZ 332.8 µs 298.1 µs +11.65%
🆕 current (global scan) N/A 12.9 ms N/A
🆕 prototype (DOM ancestor walk) N/A 32.6 µs N/A
🆕 current (global scan) N/A 340.1 µs N/A
🆕 prototype (DOM ancestor walk) N/A 32.9 µs N/A
🆕 current (global scan) N/A 5.7 ms N/A
🆕 prototype (DOM ancestor walk) N/A 85.3 µs N/A
🆕 current (global scan) N/A 5.7 ms N/A
🆕 prototype (scoped DOM scan) N/A 52.1 µs N/A
🆕 current (global scan) N/A 176.6 µs N/A
🆕 prototype (scoped DOM scan) N/A 57.1 µs N/A
... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing perf/component-tree-resolution (d88fec2) with main (f524416)

Open in CodSpeed

@titouanmathis
titouanmathis merged commit 8bbdfda into main Jul 13, 2026
10 checks passed
@titouanmathis
titouanmathis deleted the perf/component-tree-resolution branch July 13, 2026 13:16
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