Write immediate spring updates synchronously - #6
Conversation
Unconditional immediate updates went through api.start() and were awaited, costing a frame each even though nothing animated. api.set() writes them synchronously. Removes five awaited round-trips from open and close, and a promise per pointermove during a drag.
|
Correcting the record on this PR's description. The claim that The automated Chrome I measured in runs react-spring caps a frame's progress at 64ms, so a 115ms tween needs two frames. At 1fps that is two seconds rather than 33ms, and the five awaited What still holds:
What does not hold: the "may never complete" paragraph and the before/after table in the description. Those numbers are contaminated and should be ignored. |
Follow-up to #5, which found that both the drag and the open path are script-bound rather than layout-bound.
Change
Every spring update that was unconditionally
immediate: truewent throughapi.start()and was then awaited.start()queues the update and resolves through the animation loop, so each of those cost a frame even though nothing was animating.SpringRef.set()writes the values synchronously and stops the running animation, which is exactly what those call sites wanted.Five awaited round-trips are gone from the open and close sequences, and the per-
pointermovewrite during a drag no longer allocates a promise it never reads.Conditional cases are untouched:
snapSmoothlyandresizeSmoothlystill go throughasyncSet, because theirimmediatedepends onprefers-reduced-motionand on the resize source.Measured
Production Vite build, React 19, 4x CPU throttle, timed from the library's own
onSpringStart/onSpringEndcallbacks so the instrumentation costs nothing.Opening the sheet, first open after load:
openonSpringEnd({type:'OPEN'})openingafter 4 sThe early state transitions also get through faster, 5 of them in 15 ms against 2 in 19 ms, which is the awaited frames disappearing.
Treat the absolute numbers as environment-specific rather than as a benchmark. Getting a trustworthy figure here was hard: polling loops and
MutationObservers both perturbed the result badly enough to invert it, and cold-cache reloads swamped it. The callback timings above are the only instrumentation that did not distort what it measured.The bigger lead this uncovered
Even after this change, roughly a second of the open sequence at 4x throttle sits in a single gap between two substates, and it lands on
activate— the step that turns on the scroll lock, the focus trap and the aria hider before the sheet is visible. That is now the largest single cost on the open path and the obvious next thing to look at. It is out of scope here.Also worth knowing: on
main, the open sequence could fail to complete at all in the harness above, leaving the machine inopeningand never firingonSpringEnd({type:'OPEN'}). Consumers that gate work on that callback would wait forever. This PR made it complete in every run I did, but I have not proven the underlying cause, so treat it as improved rather than fixed.Test plan
npm test: lint, 25 unit tests, library build, docs buildsimpleopens, closes with Escape, reopens and closes with the Dismiss button;scrollabledrags between snap points with the height animating (506 → 623 → 663 → 760 px) and back down