[Enhancement] Optimize tween, animate and RafService for browser performance#721
[Enhancement] Optimize tween, animate and RafService for browser performance#721titouanmathis merged 19 commits intomainfrom
Conversation
Export Size@studiometa/js-toolkit
Unchanged@studiometa/js-toolkit
|
47afa11 to
50e37d6
Compare
Codecov Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
9826433 to
adcb22e
Compare
Merging this PR will degrade performance by 40.77%
|
| 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)
adcb22e to
221be81
Compare
068a37a to
118871c
Compare
118871c to
ee20d3e
Compare
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.
ee20d3e to
da84702
Compare
Greptile SummaryThis PR delivers significant runtime performance optimizations across Key behavioral changes:
Most concerns raised in prior review rounds have been addressed: the 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):
Confidence Score: 4/5Safe 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
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
Reviews (8): Last reviewed commit: "Keep transform origin reads in animate r..." | Re-trigger Greptile |
🔗 Linked issue
N/A — Performance improvement initiative.
❓ Type of change
📚 Description
Optimize the hot paths in
animate,tween,transform,RafService,ScrollService, and Base internals for better runtime performance.animate.ts— Staged compilation of keyframesReplace per-frame property iteration and
lerpcalls 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.end - startdeltas once instead of recalculating every framepropsobjectisDefined()function calls with inline!== undefinedchecksargumentsobject)transform.ts/tween.ts— Inline property checksReplace
isDefined()function calls with!== undefinedto avoid function call overhead in the hot path.RafService.ts— Batch scheduler callsWrap all callbacks in a single
scheduler.read()call and collect write functions into onescheduler.write()call. Reduces closure creation from 2N to 2 per trigger cycle.ScrollService.ts— Cache scroll max valuesCache
document.scrollingElement.scrollWidth/Heightand only update onresizeevents instead of reading them on every scroll event.getAllProperties— Replace O(n²) array spread with O(n) pushThe prototype chain walker used
reducewith spread operator, copying the entire accumulator on each iteration. Replaced withpushfor linear time.getInstances()— Avoid unnecessary Set copyReturn the internal storage Set directly (as
ReadonlySet) instead of creating a copy on every call.Animate progress (hot path)
Animate creation (expected regression — more upfront work, paid once)
RafService.trigger
ScrollService.updateProps
Base internals
Transform
📝 Checklist