Skip to content

Commit e35f589

Browse files
authoredNov 29, 2021
fix: schedulers will no longer error while rescheduling and unsubscribing during flushes
* chore: use sinon sandbox consistently * test: add failing flush tests * fix: don't execute actions scheduled within flush * test: add failing tests * fix: avoid calling flush with empty actions queue Closes #6672 * chore: remove accidental auto-import * test: call all the dones
1 parent c2b3e88 commit e35f589

6 files changed

+180
-27
lines changed
 

‎spec/schedulers/AnimationFrameScheduler-spec.ts

+71-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ describe('Scheduler.animationFrame', () => {
4343
const requestSpy = sinon.spy(animationFrameProvider, 'requestAnimationFrame');
4444
const setSpy = sinon.spy(intervalProvider, 'setInterval');
4545
const clearSpy = sinon.spy(intervalProvider, 'clearInterval');
46-
46+
4747
animate(' ----------x--');
4848
const a = cold(' a ');
4949
const ta = time(' ----| ');
@@ -168,4 +168,74 @@ describe('Scheduler.animationFrame', () => {
168168
}, 0, 0);
169169
scheduledIndices.push(0);
170170
});
171+
172+
it('should execute actions scheduled when flushing in a subsequent flush', (done) => {
173+
const sandbox = sinon.createSandbox();
174+
const stubFlush = (sandbox.stub(animationFrameScheduler, 'flush')).callThrough();
175+
176+
let a: Subscription;
177+
let b: Subscription;
178+
let c: Subscription;
179+
180+
a = animationFrameScheduler.schedule(() => {
181+
expect(stubFlush).to.have.callCount(1);
182+
c = animationFrameScheduler.schedule(() => {
183+
expect(stubFlush).to.have.callCount(2);
184+
sandbox.restore();
185+
done();
186+
});
187+
});
188+
b = animationFrameScheduler.schedule(() => {
189+
expect(stubFlush).to.have.callCount(1);
190+
});
191+
});
192+
193+
it('should execute actions scheduled when flushing in a subsequent flush when some actions are unsubscribed', (done) => {
194+
const sandbox = sinon.createSandbox();
195+
const stubFlush = (sandbox.stub(animationFrameScheduler, 'flush')).callThrough();
196+
197+
let a: Subscription;
198+
let b: Subscription;
199+
let c: Subscription;
200+
201+
a = animationFrameScheduler.schedule(() => {
202+
expect(stubFlush).to.have.callCount(1);
203+
c = animationFrameScheduler.schedule(() => {
204+
expect(stubFlush).to.have.callCount(2);
205+
sandbox.restore();
206+
done();
207+
});
208+
b.unsubscribe();
209+
});
210+
b = animationFrameScheduler.schedule(() => {
211+
done(new Error('Unexpected execution of b'));
212+
});
213+
});
214+
215+
it('should properly cancel an unnecessary flush', (done) => {
216+
const sandbox = sinon.createSandbox();
217+
const cancelAnimationFrameStub = sandbox.stub(animationFrameProvider, 'cancelAnimationFrame').callThrough();
218+
219+
let a: Subscription;
220+
let b: Subscription;
221+
let c: Subscription;
222+
223+
a = animationFrameScheduler.schedule(() => {
224+
expect(animationFrameScheduler.actions).to.have.length(1);
225+
c = animationFrameScheduler.schedule(() => {
226+
done(new Error('Unexpected execution of c'));
227+
});
228+
expect(animationFrameScheduler.actions).to.have.length(2);
229+
// What we're testing here is that the unsubscription of action c effects
230+
// the cancellation of the animation frame in a scenario in which the
231+
// actions queue is not empty - it contains action b.
232+
c.unsubscribe();
233+
expect(animationFrameScheduler.actions).to.have.length(1);
234+
expect(cancelAnimationFrameStub).to.have.callCount(1);
235+
});
236+
b = animationFrameScheduler.schedule(() => {
237+
sandbox.restore();
238+
done();
239+
});
240+
});
171241
});

‎spec/schedulers/AsapScheduler-spec.ts

+77-10
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@ describe('Scheduler.asap', () => {
3939

4040
it('should cancel asap actions when delay > 0', () => {
4141
testScheduler.run(({ cold, expectObservable, flush, time }) => {
42-
const setImmediateSpy = sinon.spy(immediateProvider, 'setImmediate');
43-
const setSpy = sinon.spy(intervalProvider, 'setInterval');
44-
const clearSpy = sinon.spy(intervalProvider, 'clearInterval');
42+
const sandbox = sinon.createSandbox();
43+
const setImmediateSpy = sandbox.spy(immediateProvider, 'setImmediate');
44+
const setSpy = sandbox.spy(intervalProvider, 'setInterval');
45+
const clearSpy = sandbox.spy(intervalProvider, 'clearInterval');
4546

4647
const a = cold(' a ');
4748
const ta = time(' ----| ');
@@ -57,17 +58,15 @@ describe('Scheduler.asap', () => {
5758
expect(setImmediateSpy).to.have.not.been.called;
5859
expect(setSpy).to.have.been.calledOnce;
5960
expect(clearSpy).to.have.been.calledOnce;
60-
setImmediateSpy.restore();
61-
setSpy.restore();
62-
clearSpy.restore();
61+
sandbox.restore();
6362
});
6463
});
6564

6665
it('should reuse the interval for recursively scheduled actions with the same delay', () => {
6766
const sandbox = sinon.createSandbox();
6867
const fakeTimer = sandbox.useFakeTimers();
6968
// callThrough is missing from the declarations installed by the typings tool in stable
70-
const stubSetInterval = (<any> sinon.stub(global, 'setInterval')).callThrough();
69+
const stubSetInterval = (<any> sandbox.stub(global, 'setInterval')).callThrough();
7170
const period = 50;
7271
const state = { index: 0, period };
7372
type State = typeof state;
@@ -86,15 +85,14 @@ describe('Scheduler.asap', () => {
8685
fakeTimer.tick(period);
8786
expect(state).to.have.property('index', 2);
8887
expect(stubSetInterval).to.have.property('callCount', 1);
89-
stubSetInterval.restore();
9088
sandbox.restore();
9189
});
9290

9391
it('should not reuse the interval for recursively scheduled actions with a different delay', () => {
9492
const sandbox = sinon.createSandbox();
9593
const fakeTimer = sandbox.useFakeTimers();
9694
// callThrough is missing from the declarations installed by the typings tool in stable
97-
const stubSetInterval = (<any> sinon.stub(global, 'setInterval')).callThrough();
95+
const stubSetInterval = (<any> sandbox.stub(global, 'setInterval')).callThrough();
9896
const period = 50;
9997
const state = { index: 0, period };
10098
type State = typeof state;
@@ -114,7 +112,6 @@ describe('Scheduler.asap', () => {
114112
fakeTimer.tick(period);
115113
expect(state).to.have.property('index', 2);
116114
expect(stubSetInterval).to.have.property('callCount', 3);
117-
stubSetInterval.restore();
118115
sandbox.restore();
119116
});
120117

@@ -221,4 +218,74 @@ describe('Scheduler.asap', () => {
221218
}, 0, 0);
222219
scheduledIndices.push(0);
223220
});
221+
222+
it('should execute actions scheduled when flushing in a subsequent flush', (done) => {
223+
const sandbox = sinon.createSandbox();
224+
const stubFlush = (sandbox.stub(asapScheduler, 'flush')).callThrough();
225+
226+
let a: Subscription;
227+
let b: Subscription;
228+
let c: Subscription;
229+
230+
a = asapScheduler.schedule(() => {
231+
expect(stubFlush).to.have.callCount(1);
232+
c = asapScheduler.schedule(() => {
233+
expect(stubFlush).to.have.callCount(2);
234+
sandbox.restore();
235+
done();
236+
});
237+
});
238+
b = asapScheduler.schedule(() => {
239+
expect(stubFlush).to.have.callCount(1);
240+
});
241+
});
242+
243+
it('should execute actions scheduled when flushing in a subsequent flush when some actions are unsubscribed', (done) => {
244+
const sandbox = sinon.createSandbox();
245+
const stubFlush = (sandbox.stub(asapScheduler, 'flush')).callThrough();
246+
247+
let a: Subscription;
248+
let b: Subscription;
249+
let c: Subscription;
250+
251+
a = asapScheduler.schedule(() => {
252+
expect(stubFlush).to.have.callCount(1);
253+
c = asapScheduler.schedule(() => {
254+
expect(stubFlush).to.have.callCount(2);
255+
sandbox.restore();
256+
done();
257+
});
258+
b.unsubscribe();
259+
});
260+
b = asapScheduler.schedule(() => {
261+
done(new Error('Unexpected execution of b'));
262+
});
263+
});
264+
265+
it('should properly cancel an unnecessary flush', (done) => {
266+
const sandbox = sinon.createSandbox();
267+
const clearImmediateStub = sandbox.stub(immediateProvider, 'clearImmediate').callThrough();
268+
269+
let a: Subscription;
270+
let b: Subscription;
271+
let c: Subscription;
272+
273+
a = asapScheduler.schedule(() => {
274+
expect(asapScheduler.actions).to.have.length(1);
275+
c = asapScheduler.schedule(() => {
276+
done(new Error('Unexpected execution of c'));
277+
});
278+
expect(asapScheduler.actions).to.have.length(2);
279+
// What we're testing here is that the unsubscription of action c effects
280+
// the cancellation of the microtask in a scenario in which the actions
281+
// queue is not empty - it contains action b.
282+
c.unsubscribe();
283+
expect(asapScheduler.actions).to.have.length(1);
284+
expect(clearImmediateStub).to.have.callCount(1);
285+
});
286+
b = asapScheduler.schedule(() => {
287+
sandbox.restore();
288+
done();
289+
});
290+
});
224291
});

‎src/internal/scheduler/AnimationFrameAction.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ export class AnimationFrameAction<T> extends AsyncAction<T> {
2727
if ((delay != null && delay > 0) || (delay == null && this.delay > 0)) {
2828
return super.recycleAsyncId(scheduler, id, delay);
2929
}
30-
// If the scheduler queue is empty, cancel the requested animation frame and
31-
// set the scheduled flag to undefined so the next AnimationFrameAction will
32-
// request its own.
33-
if (scheduler.actions.length === 0) {
30+
// If the scheduler queue has no remaining actions with the same async id,
31+
// cancel the requested animation frame and set the scheduled flag to
32+
// undefined so the next AnimationFrameAction will request its own.
33+
if (!scheduler.actions.some((action) => action.id === id)) {
3434
animationFrameProvider.cancelAnimationFrame(id);
3535
scheduler._scheduled = undefined;
3636
}

‎src/internal/scheduler/AnimationFrameScheduler.ts

+12-4
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,32 @@ import { AsyncScheduler } from './AsyncScheduler';
44
export class AnimationFrameScheduler extends AsyncScheduler {
55
public flush(action?: AsyncAction<any>): void {
66
this._active = true;
7+
// The async id that effects a call to flush is stored in _scheduled.
8+
// Before executing an action, it's necessary to check the action's async
9+
// id to determine whether it's supposed to be executed in the current
10+
// flush.
11+
// Previous implementations of this method used a count to determine this,
12+
// but that was unsound, as actions that are unsubscribed - i.e. cancelled -
13+
// are removed from the actions array and that can shift actions that are
14+
// scheduled to be executed in a subsequent flush into positions at which
15+
// they are executed within the current flush.
16+
const flushId = this._scheduled;
717
this._scheduled = undefined;
818

919
const { actions } = this;
1020
let error: any;
11-
let index = -1;
1221
action = action || actions.shift()!;
13-
const count = actions.length;
1422

1523
do {
1624
if ((error = action.execute(action.state, action.delay))) {
1725
break;
1826
}
19-
} while (++index < count && (action = actions.shift()));
27+
} while ((action = actions[0]) && action.id === flushId && actions.shift());
2028

2129
this._active = false;
2230

2331
if (error) {
24-
while (++index < count && (action = actions.shift())) {
32+
while ((action = actions[0]) && action.id === flushId && actions.shift()) {
2533
action.unsubscribe();
2634
}
2735
throw error;

‎src/internal/scheduler/AsapAction.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ export class AsapAction<T> extends AsyncAction<T> {
2727
if ((delay != null && delay > 0) || (delay == null && this.delay > 0)) {
2828
return super.recycleAsyncId(scheduler, id, delay);
2929
}
30-
// If the scheduler queue is empty, cancel the requested microtask and
31-
// set the scheduled flag to undefined so the next AsapAction will schedule
32-
// its own.
33-
if (scheduler.actions.length === 0) {
30+
// If the scheduler queue has no remaining actions with the same async id,
31+
// cancel the requested microtask and set the scheduled flag to undefined
32+
// so the next AsapAction will request its own.
33+
if (!scheduler.actions.some((action) => action.id === id)) {
3434
immediateProvider.clearImmediate(id);
3535
scheduler._scheduled = undefined;
3636
}

‎src/internal/scheduler/AsapScheduler.ts

+12-4
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,32 @@ import { AsyncScheduler } from './AsyncScheduler';
44
export class AsapScheduler extends AsyncScheduler {
55
public flush(action?: AsyncAction<any>): void {
66
this._active = true;
7+
// The async id that effects a call to flush is stored in _scheduled.
8+
// Before executing an action, it's necessary to check the action's async
9+
// id to determine whether it's supposed to be executed in the current
10+
// flush.
11+
// Previous implementations of this method used a count to determine this,
12+
// but that was unsound, as actions that are unsubscribed - i.e. cancelled -
13+
// are removed from the actions array and that can shift actions that are
14+
// scheduled to be executed in a subsequent flush into positions at which
15+
// they are executed within the current flush.
16+
const flushId = this._scheduled;
717
this._scheduled = undefined;
818

919
const { actions } = this;
1020
let error: any;
11-
let index = -1;
1221
action = action || actions.shift()!;
13-
const count = actions.length;
1422

1523
do {
1624
if ((error = action.execute(action.state, action.delay))) {
1725
break;
1826
}
19-
} while (++index < count && (action = actions.shift()));
27+
} while ((action = actions[0]) && action.id === flushId && actions.shift());
2028

2129
this._active = false;
2230

2331
if (error) {
24-
while (++index < count && (action = actions.shift())) {
32+
while ((action = actions[0]) && action.id === flushId && actions.shift()) {
2533
action.unsubscribe();
2634
}
2735
throw error;

0 commit comments

Comments
 (0)
Please sign in to comment.