Skip to content

Animation._startLoop can duplicate the rAF loop when the engine sleeps and is woken in the same frame #1170

Description

@gioneves

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions