Skip to content

[Enhancement] Optimize tween, animate and RafService for browser performance#721

Merged
titouanmathis merged 19 commits intomainfrom
feat/tween-animate-performance
Apr 12, 2026
Merged

[Enhancement] Optimize tween, animate and RafService for browser performance#721
titouanmathis merged 19 commits intomainfrom
feat/tween-animate-performance

Conversation

@titouanmathis
Copy link
Copy Markdown
Contributor

@titouanmathis titouanmathis commented Apr 2, 2026

🔗 Linked issue

N/A — Performance improvement initiative.

❓ Type of change

  • 📖 Documentation (updates to the documentation, readme or JSdoc annotations)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

Optimize the hot paths in animate, tween, transform, RafService, ScrollService, and Base internals for better runtime performance.

animate.ts — Staged compilation of keyframes

Replace per-frame property iteration and lerp calls with pre-compiled segments. At creation time, keyframe pairs are compiled into parallel arrays of start values, deltas, CSS function names, and units. The render loop becomes a tight indexed loop doing only multiply-add and string concatenation.

  • Pre-compute end - start deltas once instead of recalculating every frame
  • Build transform strings directly instead of allocating an intermediary props object
  • Fast path for numeric translate values (skip closure creation when no CSS units)
  • Replace isDefined() function calls with inline !== undefined checks
  • Incremental sum for mean progress in multi-element animations (O(1) instead of O(n))
  • Replace delegate pattern with direct functions (eliminate string comparison and arguments object)

transform.ts / tween.ts — Inline property checks

Replace isDefined() function calls with !== undefined to avoid function call overhead in the hot path.

RafService.ts — Batch scheduler calls

Wrap all callbacks in a single scheduler.read() call and collect write functions into one scheduler.write() call. Reduces closure creation from 2N to 2 per trigger cycle.

ScrollService.ts — Cache scroll max values

Cache document.scrollingElement.scrollWidth/Height and only update on resize events instead of reading them on every scroll event.

getAllProperties — Replace O(n²) array spread with O(n) push

The prototype chain walker used reduce with spread operator, copying the entire accumulator on each iteration. Replaced with push for linear time.

getInstances() — Avoid unnecessary Set copy

Return the internal storage Set directly (as ReadonlySet) instead of creating a copy on every call.

Animate progress (hot path)

Benchmark Before After Change
progress (4 keyframes) 1.78M 2.44M +37%
progress (5 elements) 738K 1.02M +38%
progress (10 elements) 456K 662K +45%
progress (50 elements) 119K 169K +42%
progress (x, y) 2.26M 2.55M +13%
progress (custom props) 1.80M 2.25M +25%
stagger (numeric) 274K 357K +30%
stagger (function) 285K 382K +34%

Animate creation (expected regression — more upfront work, paid once)

Benchmark Before After Change
creation (opacity) 839K 470K -44%
creation (x, y) 814K 429K -47%

RafService.trigger

Benchmark Before After Change
50 callbacks, read+write 1.04M 3.07M +195%
10 callbacks, read+write 1.84M 3.02M +64%

ScrollService.updateProps

Benchmark Before After Change
updateProps() 164K 458K +179%

Base internals

Benchmark Before After Change
getAllProperties (no filter) 322K 595K +85%
getInstances() (100 instances) 892K 3.83M +329%

Transform

Benchmark Before After Change
translate3d (x only) 1.10M 1.27M +15%
rotate 1.10M 1.31M +19%
empty props 1.25M 1.53M +22%

📝 Checklist

  • I have linked an issue or discussion.
  • I have added tests (if possible).
  • I have updated the documentation accordingly.
  • I have updated the changelog.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 2, 2026

Export Size

@studiometa/js-toolkit

Name Size Diff
ScrollService 1.28 kB +161 B (+14.40%) 🔺
useScroll 1.29 kB +162 B (+14.36%) 🔺
animate 3.27 kB +195 B (+6.35%) 🔺
withScrolledInView 2.93 kB +137 B (+4.90%) 🔺
SERVICES 4.01 kB +186 B (+4.86%) 🔺
RafService 956 B +44 B (+4.82%) 🔺
UTILS 9.57 kB +400 B (+4.36%) 🔺
useRaf 963 B +33 B (+3.55%) 🔺
ALL 20.73 kB +596 B (+2.96%) 🔺
DECORATORS 7.59 kB +178 B (+2.40%) 🔺
Base 8.72 kB +163 B (+1.90%) 🔺
BASE 8.76 kB +162 B (+1.88%) 🔺
withDrag 2.09 kB +36 B (+1.75%) 🔺
scrollTo 2.31 kB +39 B (+1.72%) 🔺
useDrag 1.98 kB +33 B (+1.70%) 🔺
tween 1.72 kB +28 B (+1.66%) 🔺
FRAMEWORK 13.65 kB +159 B (+1.18%) 🔺
DragService 1.94 kB +19 B (+0.99%) 🔺
transform 288 B -37 B (-11.38%) 🔽
Unchanged

@studiometa/js-toolkit

Name Size Diff
AbstractService 528 B -
addClass 226 B -
addStyle 238 B -
boundingRectToCircle 154 B -
cache 194 B -
camelCase 401 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 596 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 376 B -
debounce 92 B -
defineFeatures 322 B -
domScheduler 296 B -
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 -
HELPERS 2.44 kB -
historyPush 499 B -
historyReplace 503 B -
importOnInteraction 891 B -
importOnMediaQuery 236 B -
importWhenIdle 223 B -
importWhenPrefersMotion 275 B -
importWhenVisible 911 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 377 B -
PointerService 1.07 kB -
queryComponent 485 B -
queryComponentAll 494 B -
Queue 226 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 -
sessionStorageProvider 712 B -
SmartQueue 411 B -
smoothTo 473 B -
snakeCase 378 B -
spring 115 B -
startsWith 87 B -
throttle 101 B -
toggleClass 225 B -
transition 916 B -
trapFocus 363 B -
untrapFocus 45 B -
upperCase 54 B -
urlSearchParamsInHashProvider 537 B -
urlSearchParamsProvider 539 B -
useKey 871 B -
useLoad 610 B -
useMutation 829 B -
usePointer 1.09 kB -
useResize 1.02 kB -
useScheduler 290 B -
wait 79 B -
withBreakpointManager 1.42 kB -
withBreakpointObserver 1.61 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 -
withTrailingCharacters 96 B -
withTrailingSlash 120 B -
wrap 77 B -

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 2, 2026

Codecov Report

❌ Patch coverage is 97.75281% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.51%. Comparing base (82e70c2) to head (9a15506).
⚠️ Report is 20 commits behind head on main.

Files with missing lines Patch % Lines
packages/js-toolkit/utils/css/animate.ts 96.49% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #721      +/-   ##
==========================================
- Coverage   98.57%   98.51%   -0.07%     
==========================================
  Files         131      131              
  Lines        2882     2965      +83     
  Branches      713      727      +14     
==========================================
+ Hits         2841     2921      +80     
- Misses         39       42       +3     
  Partials        2        2              
Flag Coverage Δ
unittests 98.51% <97.75%> (-0.07%) ⬇️

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

☔ View full report in Codecov by Sentry.
📢 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
Copy link
Copy Markdown

codspeed-hq bot commented Apr 3, 2026

Merging this PR will degrade performance by 40.77%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 14 improved benchmarks
❌ 7 regressed benchmarks
✅ 94 untouched benchmarks

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
create with easing per keyframe 154.1 µs 234.8 µs -34.4%
create with 4 keyframes 433.6 µs 519.7 µs -16.56%
create with multiple transforms 159 µs 197.9 µs -19.63%
create with custom properties 124.5 µs 174.4 µs -28.64%
trigger (10 callbacks, read + write) 48 µs 40.9 µs +17.4%
create with simple keyframes (opacity) 107.3 µs 181.2 µs -40.77%
updateProps() 77.8 µs 42.8 µs +81.73%
trigger (10 callbacks, read only) 43.8 µs 34.9 µs +25.32%
start/pause cycle 148.5 µs 208.2 µs -28.7%
progress update (10 elements) 2.7 ms 2.4 ms +10.93%
progress update (x, y transform) 371.2 µs 308.9 µs +20.19%
progress update (4 keyframes) 463.4 µs 397.1 µs +16.69%
trigger (50 callbacks, read + write) 106.3 µs 67.1 µs +58.37%
trigger (50 callbacks, read only) 494.6 µs 47.2 µs ×10
create with transform keyframes (x, y) 306.1 µs 168.5 µs +81.74%
progress with numeric stagger 1.3 ms 1.1 ms +18.18%
progress update (5 elements) 939.5 µs 825.9 µs +13.76%
getAllProperties (no filter) 64.3 µs 51.6 µs +24.56%
progress update (5 transforms) 365.1 µs 309.7 µs +17.89%
progress with function stagger 1.3 ms 1.1 ms +17.92%
... ... ... ... ...

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


Comparing feat/tween-animate-performance (9a15506) with main (82e70c2)

Open in CodSpeed

Avoids function call overhead in hot path. Removes isDefined import.
Improves transform benchmarks by ~10-19% on various cases.
Removes function call overhead in hot render path and tween progress.
Notable improvements: custom properties +26%, 4-keyframe progress +9%.
Track running sum instead of recalculating mean() over all elements
on every progress update. O(1) instead of O(n) per update.
Eliminates string comparison and arguments object in hot path.
Uses indexed for-loop instead of entries() iterator.
Skip closure creation and getAnimationStepValue when both from/to
values are plain numbers (most common case, no CSS units).
Replace per-frame property iteration and lerp calls with pre-compiled
segments. Each segment stores parallel arrays of start values, deltas,
CSS function names and units. The render loop becomes a tight indexed
loop doing only multiply-add and string concatenation.

Trade creation speed (~40% slower) for render speed (~30-50% faster).
Animations are created once but rendered hundreds of times per second.
Wrap all callbacks in a single scheduler.read() call and collect write
functions into one scheduler.write() call. This reduces closure creation
from 2N to 2 per trigger cycle. Trigger with 50 callbacks improves from
1.0M to 3.0M ops/sec (+192%).
Avoid reading document.scrollingElement.scrollWidth/Height on every
scroll event. These values only change on resize, so cache them and
update via a resize event listener. ScrollService.updateProps improves
from 164K to 458K ops/sec (+179%).
The previous code used reduce with spread operator which copies the
entire accumulator array on each iteration (O(n²)). Using push mutates
in place (O(n)). Improves unfiltered case from 322K to 595K ops/sec (+85%).
When called without a constructor filter, getInstances() was creating a
new Set copy on every call. Return the storage Set directly as ReadonlySet.
Improves from 892K to 3.83M ops/sec (+329%) with 100 instances.
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 11, 2026

Greptile Summary

This PR delivers significant runtime performance optimizations across animate, tween, RafService, ScrollService, and several Base utilities. The core strategy is staged evaluation: expensive per-frame work (property iteration, delta computation) is moved to construction time, leaving the hot render path as pure multiply-add and string concatenation.

Key behavioral changes:

  • animate keyframe pairs are compiled into CompiledSegment objects once at animation creation; the render loop indexes into pre-built parallel arrays.
  • RafService.trigger wraps all callbacks in a single scheduler.read() and batches writes into one scheduler.write(), reducing closure allocation from 2N to 2 per cycle.
  • ScrollService caches scrollWidth/scrollHeight in __maxX/__maxY (using null as the uninitialized sentinel, which correctly handles negative max values). Max values are refreshed on resize events and by a ResizeObserver on the scrolling element; scroll subscribers are not notified on resize (the trigger = false guard).
  • getAllProperties replaces the O(n²) reduce+spread with an O(n) push loop.
  • getInstances() (no-arg overload) returns a defensive copy typed as ReadonlySet<Base>, confirmed by a new test.
  • isDefined() calls in hot paths replaced with inline !== undefined.

Most concerns raised in prior review rounds have been addressed: the null sentinel avoids false-positive cache invalidation on pages with negative max scroll values; the element.style.transformOrigin DOM read is correctly inside scheduler.read(); the trigger = false flag on resize prevents scroll subscribers from being spuriously notified; and queryComponent casts are updated to ReadonlySet.

The PR is well-covered by new targeted tests (negative scroll max caching, not-triggering on resize, multi-segment dynamic unit resolution, multi-element duration mapping, defensive copy verification).

Outstanding items from prior threads (not addressed yet):

  • The z-axis translate uses offsetWidth as its size reference.
  • The writeQueue array is still allocated inside scheduler.read() on every frame.
  • The CHANGELOG entry still says "returning storage Set directly" even though a defensive copy is still made.

Confidence Score: 4/5

Safe to merge; the one actionable new finding (null-to-number type mismatch in ScrollService) is a compile-time concern rather than a runtime fault, and all prior critical issues have been resolved.

Most concerns from earlier rounds are addressed: the null sentinel handles negative scroll-max values correctly, resize events no longer notify scroll subscribers, getInstances() returns a proper defensive copy, and the transformOrigin DOM read is correctly in the read phase. New tests are thorough and targeted. The remaining open items (z-axis offsetWidth reference, writeQueue per-frame allocation, CHANGELOG wording) were already flagged in prior threads. The only new actionable finding is the number | null → number assignment without a non-null assertion, which is a type-safety gap rather than a runtime bug.

packages/js-toolkit/services/ScrollService.ts — the __maxX/__maxY null-to-number assignment at lines 112–113 needs a non-null assertion (!) to be type-correct in strict mode.

Important Files Changed

Filename Overview
packages/js-toolkit/utils/css/animate.ts Major rewrite: per-frame property iteration replaced with pre-compiled CompiledSegment objects; render loop reduced to multiply-add and string concatenation. Logic is correct; minor issues include trailing whitespace in transform strings and custom-property arithmetic duplicated in the write phase.
packages/js-toolkit/services/ScrollService.ts Caches scroll max values using null-sentinel; resize events update the cache but do not notify subscribers (trigger = false); a ResizeObserver handles body growth without a window-resize. One type-safety concern: number
packages/js-toolkit/services/RafService.ts Batches all RAF callbacks into one scheduler.read() and one scheduler.write(), reducing closure allocation from 2N to 2 per cycle. The writeQueue array is still allocated on every frame (noted in prior thread), but the semantic change is correct and well-tested.
packages/js-toolkit/Base/utils.ts getInstances() now returns ReadonlySet and still creates a defensive copy (verified by new test). queryComponent.ts casts updated to ReadonlySet. Both correct and safe.
packages/js-toolkit/utils/object/getAllProperties.ts O(n²) reduce+spread replaced with O(n) push loop. Semantically identical output, no issues.
packages/js-toolkit/utils/tween.ts Three isDefined() calls replaced with inline !== undefined checks. No logic changes, clean improvement.
packages/js-toolkit/utils/css/transform.ts All isDefined() calls replaced with inline !== undefined. No logic changes, straightforward hot-path micro-optimization.
packages/tests/services/ScrollService.spec.ts New tests cover: ResizeObserver integration, negative max-scroll caching correctness, no-trigger-on-resize guarantee, and multi-scroll cache stability. Good coverage of the new behavior.
packages/tests/utils/css/animate.spec.ts New tests cover: multi-segment custom property stability, idempotent double-progress-set, mixed dynamic unit resolution across segments, and multi-element max-duration mapping. Comprehensive regression coverage for the compiled-segment rewrite.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    subgraph Creation["animate() — creation time (once)"]
        A[keyframes array] --> B[normalizeKeyframes]
        B --> C{for each consecutive pair}
        C --> D[compileSegment]
        D --> E[CompiledSegment\ntransformStarts / transformDeltas\nopacityStart / opacityDelta\ncustomPropertyStarts / Deltas\ndynamicTranslates]
    end

    subgraph HotPath["render path — every frame"]
        F[callback progress] --> G[find active segment]
        G --> H[renderSegment]
        H --> I[easedProgress = segment.easing map 0..1]
        I --> J[scheduler.read]
        J --> K{dynamicTranslates?}
        K -- yes --> L[read element size, resolveUnitValue, mutate segment arrays]
        K -- no --> M
        L --> M[build transformValue string]
        M --> N[compute opacity / transformOrigin DOM reads]
        N --> O[scheduler.write]
        O --> P[element.style.transform / opacity / setProperty]
    end

    subgraph RAF["RafService.trigger — every RAF"]
        Q[scheduler.read one closure] --> R{for each callback}
        R --> S[callback props]
        S --> T{returns render fn?}
        T -- yes --> U[push to writeQueue]
        T -- no --> R
        U --> R
        R -- done --> V{writeQueue.length > 0?}
        V -- yes --> W[scheduler.write one closure flush writeQueue]
    end

    Creation --> HotPath
Loading

Reviews (8): Last reviewed commit: "Keep transform origin reads in animate r..." | Re-trigger Greptile

Comment thread packages/js-toolkit/utils/css/animate.ts
Comment thread packages/js-toolkit/Base/utils.ts Outdated
Comment thread packages/js-toolkit/utils/css/animate.ts
Comment thread packages/js-toolkit/services/RafService.ts
Comment thread packages/js-toolkit/Base/utils.ts
Comment thread packages/js-toolkit/services/ScrollService.ts Outdated
Comment thread packages/js-toolkit/services/ScrollService.ts
Comment thread packages/js-toolkit/utils/css/animate.ts Outdated
@titouanmathis titouanmathis merged commit d137cea into main Apr 12, 2026
8 of 9 checks passed
@titouanmathis titouanmathis deleted the feat/tween-animate-performance branch April 12, 2026 07:47
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