Animation._startLoop can duplicate the rAF loop when the engine sleeps and is woken in the same frame
Summary
Animation.prototype._startLoop schedules the next frame before running update():
Animation.prototype._startLoop = function () {
var self = this;
this._running = true;
requestAnimationFrame(function step() {
self._running && (requestAnimationFrame(step), !self._paused && self.update());
});
};
update() calls stage.update(), which for ZRenderType is _flush(), which is where the
auto-sleep lives:
} else if (this._sleepAfterStill > 0) {
this._stillFrameAccum++;
if (this._stillFrameAccum > this._sleepAfterStill) this.animation.stop(); // _running = false
}
So the engine can decide to sleep from inside the very callback that has already queued its
successor. That successor is still pending, and its only liveness test is self._running.
Anything that calls animation.start() before that pending callback runs — and
ZRenderType.prototype.refresh() calls animation.start() — flips _running back to true
and starts a second loop. The orphan wakes up, sees _running === true, keeps going, and
re-schedules itself.
The two loops then drive the same Animation forever. It accumulates: every subsequent
sleep/wake race adds one more orphan. Only a clean sleep (one where nothing wakes the engine
before the pending callback runs) collapses them all back to zero.
There is no error and no warning. The symptom is that the page silently becomes N× more
expensive.
Measurement
Browser, Chromium headless, one canvas, mixed choreography (one element driven by a GSAP tween
writing el.attr(), one by a zrender Animator). Races forced deterministically by waking the
engine from a wrapper around stop():
| races |
Animation.update per frame |
painter.refresh per second |
| 0 |
1.03 |
59.8 |
| 1 |
2.06 |
120.0 |
| 2 |
3.09 |
179.5 |
| 3 |
4.11 |
239.3 |
The repaint tracks the update count 1:1 — it is not only clip stepping. Per orphan the page pays
one extra Clip.step over every live clip, one extra trigger('frame') and one extra repaint.
The extra trigger('frame') matters beyond CPU: echarts-gl hangs OrbitControl._update on
animation.on('frame') and integrates the delta it receives, so N loops make the camera move
N× faster than intended.
Reproduction
Deterministic, no browser needed — the loop body below is _startLoop extracted verbatim from
the built bundle and driven with an injected rAF queue:
const q = [];
const raf = (cb) => q.push(cb);
const frame = () => { const cur = q.splice(0); for (const cb of cur) cb(); };
const anim = { _running: false, _paused: false, updates: 0 };
const startLoop = function () { // verbatim
const self = this;
this._running = true;
raf(function step() { self._running && (raf(step), !self._paused && self.update()); });
};
const start = () => { if (!anim._running) startLoop.call(anim); };
let race = 1;
anim.update = function () {
this.updates++;
if (race-- > 0) { this._running = false; start(); } // sleep decided inside update, then a wake
};
start();
frame(); // frame 1
anim.updates = 0; frame(); // frame 2
console.log(anim.updates); // → 2 (expected 1)
Set race = 3 and the same script prints 4.
Suggested fix
Give each loop the generation it was born in, and let a stale one retire itself. This keeps
_running, _paused, the re-schedule order and the timing of the first update() identical:
Animation.prototype._startLoop = function () {
var self = this;
var gen = (this.__loopGen = (this.__loopGen | 0) + 1);
this._running = true;
requestAnimationFrame(function step() {
if (!self._running || self.__loopGen !== gen) return; // a stale loop stops here
requestAnimationFrame(step);
if (!self._paused) self.update();
});
};
An equivalent fix would be to store the rAF handle and cancel it in stop(); the generation
counter avoids having to track a handle across pause/resume.
Environment
zrender 6.0.0 as bundled in ECharts 6.0.0 (echarts.min.js). The relevant code is unchanged in
the 5.x line as far as we checked.
Animation._startLoopcan duplicate the rAF loop when the engine sleeps and is woken in the same frameSummary
Animation.prototype._startLoopschedules the next frame before runningupdate():update()callsstage.update(), which forZRenderTypeis_flush(), which is where theauto-sleep lives:
So the engine can decide to sleep from inside the very callback that has already queued its
successor. That successor is still pending, and its only liveness test is
self._running.Anything that calls
animation.start()before that pending callback runs — andZRenderType.prototype.refresh()callsanimation.start()— flips_runningback totrueand starts a second loop. The orphan wakes up, sees
_running === true, keeps going, andre-schedules itself.
The two loops then drive the same
Animationforever. It accumulates: every subsequentsleep/wake race adds one more orphan. Only a clean sleep (one where nothing wakes the engine
before the pending callback runs) collapses them all back to zero.
There is no error and no warning. The symptom is that the page silently becomes N× more
expensive.
Measurement
Browser, Chromium headless, one canvas, mixed choreography (one element driven by a GSAP tween
writing
el.attr(), one by a zrenderAnimator). Races forced deterministically by waking theengine from a wrapper around
stop():Animation.updateper framepainter.refreshper secondThe repaint tracks the update count 1:1 — it is not only clip stepping. Per orphan the page pays
one extra
Clip.stepover every live clip, one extratrigger('frame')and one extra repaint.The extra
trigger('frame')matters beyond CPU:echarts-glhangsOrbitControl._updateonanimation.on('frame')and integrates the delta it receives, so N loops make the camera moveN× faster than intended.
Reproduction
Deterministic, no browser needed — the loop body below is
_startLoopextracted verbatim fromthe built bundle and driven with an injected rAF queue:
Set
race = 3and the same script prints4.Suggested fix
Give each loop the generation it was born in, and let a stale one retire itself. This keeps
_running,_paused, the re-schedule order and the timing of the firstupdate()identical:An equivalent fix would be to store the rAF handle and cancel it in
stop(); the generationcounter avoids having to track a handle across
pause/resume.Environment
zrender 6.0.0 as bundled in ECharts 6.0.0 (
echarts.min.js). The relevant code is unchanged inthe 5.x line as far as we checked.