Skip to content

Commit 1fe652d

Browse files
committed
Fix race condition in .animate
1 parent 1d43355 commit 1fe652d

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
@@ -2530,9 +2530,13 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
25302530

25312531
for(var i = 0; i < frameList.length; i++) {
25322532
var computedFrame;
2533+
25332534
if(frameList[i].name) {
2535+
// If it's a named frame, compute it:
25342536
computedFrame = Plots.computeFrame(gd, frameList[i].name);
25352537
} else {
2538+
// Otherwise we must have been given a simple object, so treat
2539+
// the input itself as the computed frame.
25362540
computedFrame = frameList[i].frame;
25372541
}
25382542

@@ -2544,32 +2548,50 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
25442548
};
25452549

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

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

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

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

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

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

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

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

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

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

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

2621-
return doFrame();
2640+
doFrame();
26222641
}
26232642

2624-
var counter = 0;
2643+
// This is an animate-local counter that helps match up option input list
2644+
// items with the particular frame.
2645+
var configCounter = 0;
26252646
function setTransitionConfig(frame) {
26262647
if(Array.isArray(transitionOpts)) {
2627-
if(counter >= transitionOpts.length) {
2628-
frame.transitionOpts = transitionOpts[counter];
2648+
if(configCounter >= transitionOpts.length) {
2649+
frame.transitionOpts = transitionOpts[configCounter];
26292650
} else {
26302651
frame.transitionOpts = transitionOpts[0];
26312652
}
26322653
} else {
26332654
frame.transitionOpts = transitionOpts;
26342655
}
2635-
counter++;
2656+
configCounter++;
26362657
return frame;
26372658
}
26382659

@@ -2683,13 +2704,17 @@ Plotly.animate = function(gd, frameOrGroupNameOrFrameList, animationOpts) {
26832704
}
26842705
}
26852706

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

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

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)