Skip to content

Commit 9d38918

Browse files
committed
Fix race condition in .animate
1 parent 101bfa1 commit 9d38918

File tree

2 files changed

+54
-26
lines changed

2 files changed

+54
-26
lines changed

src/plot_api/plot_api.js

Lines changed: 50 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2531,9 +2531,13 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
25312531

25322532
for(var i = 0; i < frameList.length; i++) {
25332533
var computedFrame;
2534+
25342535
if(frameList[i].name) {
2536+
// If it's a named frame, compute it:
25352537
computedFrame = Plots.computeFrame(gd, frameList[i].name);
25362538
} else {
2539+
// Otherwise we must have been given a simple object, so treat
2540+
// the input itself as the computed frame.
25372541
computedFrame = frameList[i].frame;
25382542
}
25392543

@@ -2545,32 +2549,50 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
25452549
};
25462550

25472551
if(i === frameList.length - 1) {
2552+
// The last frame in this .animate call stores the promise resolve
2553+
// and reject callbacks. This is how we ensure that the animation
2554+
// loop (which may exist as a result of a *different* .animate call)
2555+
// still resolves or rejecdts this .animate call's promise. once it's
2556+
// complete.
25482557
nextFrame.onComplete = resolve;
25492558
nextFrame.onInterrupt = reject;
25502559
}
25512560

25522561
trans._frameQueue.push(nextFrame);
25532562
}
25542563

2564+
// Set it as never having transitioned to a frame. This will cause the animation
2565+
// loop to immediately transition to the next frame (which, for immediate mode,
2566+
// is the first frame in the list since all others would have been discarded
2567+
// below)
25552568
if(animationOpts.mode === 'immediate') {
25562569
trans._lastFrameAt = -Infinity;
25572570
}
25582571

2572+
// Only it's not already running, start a RAF loop. This could be avoided in the
2573+
// case that there's only one frame, but it significantly complicated the logic
2574+
// and only sped things up by about 5% or so for a lorenz attractor simulation.
2575+
// It would be a fine thing to implement, but the benefit of that optimization
2576+
// doesn't seem worth the extra complexity.
25592577
if(!trans._animationRaf) {
25602578
beginAnimationLoop();
25612579
}
25622580
}
25632581

25642582
function stopAnimationLoop() {
2583+
gd.emit('plotly_animated');
2584+
2585+
// Be sure to unset also since it's how we know whether a loop is already running:
25652586
window.cancelAnimationFrame(trans._animationRaf);
25662587
trans._animationRaf = null;
25672588
}
25682589

25692590
function nextFrame() {
2570-
if(trans._currentFrame) {
2571-
if(trans._currentFrame.onComplete) {
2572-
trans._currentFrame.onComplete();
2573-
}
2591+
if(trans._currentFrame && trans._currentFrame.onComplete) {
2592+
// Execute the callback and unset it to ensure it doesn't
2593+
// accidentally get called twice
2594+
trans._currentFrame.onComplete();
2595+
trans._currentFrame.onComplete = null;
25742596
}
25752597

25762598
var newFrame = trans._currentFrame = trans._frameQueue.shift();
@@ -2579,61 +2601,60 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
25792601
trans._lastFrameAt = Date.now();
25802602
trans._timeToNext = newFrame.frameOpts.duration;
25812603

2604+
// This is simply called and it's left to .transition to decide how to manage
2605+
// interrupting current transitions. That means we don't need to worry about
2606+
// how it resolves or what happens after this:
25822607
Plots.transition(gd,
25832608
newFrame.frame.data,
25842609
newFrame.frame.layout,
25852610
newFrame.frame.traces,
25862611
newFrame.frameOpts,
25872612
newFrame.transitionOpts
2588-
).then(function() {
2589-
if(trans._frameQueue.length === 0) {
2590-
gd.emit('plotly_animated');
2591-
if(trans._currentFrame && trans._currentFrame.onComplete) {
2592-
trans._currentFrame.onComplete();
2593-
trans._currentFrame = null;
2594-
}
2595-
}
2596-
});
2597-
}
2598-
2599-
if(trans._frameQueue.length === 0) {
2613+
);
2614+
} else {
2615+
// If there are no more frames, then stop the RAF loop:
26002616
stopAnimationLoop();
26012617
}
26022618
}
26032619

26042620
function beginAnimationLoop() {
26052621
gd.emit('plotly_animating');
26062622

2607-
// If no timer is running, then set last frame = long ago:
2623+
// If no timer is running, then set last frame = long ago so that the next
2624+
// frame is immediately transitioned:
26082625
trans._lastFrameAt = -Infinity;
26092626
trans._timeToNext = 0;
26102627
trans._runningTransitions = 0;
26112628
trans._currentFrame = null;
26122629

26132630
var doFrame = function() {
2614-
// Check if we need to pop a frame:
2631+
// This *must* be requested before nextFrame since nextFrame may decide
2632+
// to cancel it if there's nothing more to animated:
2633+
trans._animationRaf = window.requestAnimationFrame(doFrame);
2634+
2635+
// Check if we're ready for a new frame:
26152636
if(Date.now() - trans._lastFrameAt > trans._timeToNext) {
26162637
nextFrame();
26172638
}
2618-
2619-
trans._animationRaf = window.requestAnimationFrame(doFrame);
26202639
};
26212640

2622-
return doFrame();
2641+
doFrame();
26232642
}
26242643

2625-
var counter = 0;
2644+
// This is an animate-local counter that helps match up option input list
2645+
// items with the particular frame.
2646+
var configCounter = 0;
26262647
function setTransitionConfig(frame) {
26272648
if(Array.isArray(transitionOpts)) {
2628-
if(counter >= transitionOpts.length) {
2629-
frame.transitionOpts = transitionOpts[counter];
2649+
if(configCounter >= transitionOpts.length) {
2650+
frame.transitionOpts = transitionOpts[configCounter];
26302651
} else {
26312652
frame.transitionOpts = transitionOpts[0];
26322653
}
26332654
} else {
26342655
frame.transitionOpts = transitionOpts;
26352656
}
2636-
counter++;
2657+
configCounter++;
26372658
return frame;
26382659
}
26392660

@@ -2684,13 +2705,17 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
26842705
}
26852706
}
26862707

2708+
// If the mode is either next or immediate, then all currently queued frames must
2709+
// be dumped and the corresponding .animate promises rejected.
26872710
if(['next', 'immediate'].indexOf(animationOpts.mode) !== -1) {
26882711
discardExistingFrames();
26892712
}
26902713

26912714
if(frameList.length > 0) {
26922715
queueFrames(frameList);
26932716
} else {
2717+
// This is the case where there were simply no frames. It's a little strange
2718+
// since there's not much to do:
26942719
gd.emit('plotly_animated');
26952720
resolve();
26962721
}

test/jasmine/tests/animate_test.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe('Test animate API', function() {
5252
destroyGraphDiv();
5353
});
5454

55+
runTests(0);
5556
runTests(30);
5657

5758
function runTests(duration) {
@@ -214,7 +215,9 @@ describe('Test animate API', function() {
214215

215216
it('emits plotly_animated before the promise is resolved', function(done) {
216217
var animated = false;
217-
gd.on('plotly_animated', function() { animated = true; });
218+
gd.on('plotly_animated', function() {
219+
animated = true;
220+
});
218221

219222
Plotly.animate(gd, ['frame0'], animOpts).then(function() {
220223
expect(animated).toBe(true);

0 commit comments

Comments
 (0)