Skip to content

Commit

Permalink
feat(animationFrames): Adds an observable of animationFrames (#5021)
Browse files Browse the repository at this point in the history
* feat(animationFrames): Adds an observable of animationFrames

- Also adds tests and test harness for requestAnimationFrame stubbing with sinon
- Updates TypeScript lib to use ES2018 (so we can use `findIndex` on `Array`).

* refactor: switch to a loop-per-subscription approach

Now each subscription will cause a new animation frame loop to be kicked off, instead of trying to share a single animation loop.
  • Loading branch information
benlesh committed Sep 18, 2019
1 parent 56cbd22 commit 6a4cd68
Show file tree
Hide file tree
Showing 6 changed files with 390 additions and 5 deletions.
13 changes: 13 additions & 0 deletions spec-dtslint/observables/dom/animationFrames-spec.ts
@@ -0,0 +1,13 @@
import { animationFrames } from 'rxjs';

it('should just be an observable of numbers', () => {
const o$ = animationFrames(); // $ExpectType Observable<number>
});

it('should allow the passing of a timestampProvider', () => {
const o$ = animationFrames(performance); // $ExpectType Observable<number>
});

it('should not allow the passing of an invalid timestamp provider', () => {
const o$ = animationFrames({ now() { return 'wee' } }); // $ExpectError
});
102 changes: 102 additions & 0 deletions spec/helpers/test-helper.ts
Expand Up @@ -4,6 +4,7 @@ import { of, asyncScheduler, Observable, scheduled, ObservableInput } from 'rxjs
import { root } from 'rxjs/internal/util/root';
import { observable } from 'rxjs/internal/symbol/observable';
import { iterator } from 'rxjs/internal/symbol/iterator';
import * as sinon from 'sinon';

export function lowerCaseO<T>(...args: Array<any>): Observable<T> {
const o = {
Expand Down Expand Up @@ -47,3 +48,104 @@ export const createObservableInputs = <T>(value: T) => of(
) as Observable<ObservableInput<T>>;

global.__root__ = root;

let _raf: any;
let _caf: any;
let _id = 0;

/**
* A type used to test `requestAnimationFrame`
*/
export interface RAFTestTools {
/**
* Synchronously fire the next scheduled animation frame
*/
tick(): void;

/**
* Synchronously fire all scheduled animation frames
*/
flush(): void;

/**
* Un-monkey-patch `requestAnimationFrame` and `cancelAnimationFrame`
*/
restore(): void;
}

/**
* Monkey patches `requestAnimationFrame` and `cancelAnimationFrame`, returning a
* toolset to allow animation frames to be synchronously controlled.
*
* ### Usage
* ```ts
* let raf: RAFTestTools;
*
* beforeEach(() => {
* // patch requestAnimationFrame
* raf = stubRAF();
* });
*
* afterEach(() => {
* // unpatch
* raf.restore();
* });
*
* it('should fire handlers', () => {
* let test = false;
* // use requestAnimationFrame as normal
* requestAnimationFrame(() => test = true);
* // no frame has fired yet (this would be generally true anyhow)
* expect(test).to.equal(false);
* // manually fire the next animation frame
* raf.tick();
* // frame as fired
* expect(test).to.equal(true);
* // raf is now a SinonStub that can be asserted against
* expect(requestAnimationFrame).to.have.been.calledOnce;
* });
* ```
*/
export function stubRAF(): RAFTestTools {
_raf = requestAnimationFrame;
_caf = cancelAnimationFrame;

const handlers: any[] = [];

(requestAnimationFrame as any) = sinon.stub().callsFake((handler: Function) => {
const id = _id++;
handlers.push({ id, handler });
return id;
});

(cancelAnimationFrame as any) = sinon.stub().callsFake((id: number) => {
const index = handlers.findIndex(x => x.id === id);
if (index >= 0) {
handlers.splice(index, 1);
}
});

function tick() {
if (handlers.length > 0) {
handlers.shift().handler();
}
}

function flush() {
while (handlers.length > 0) {
handlers.shift().handler();
}
}

return {
tick,
flush,
restore() {
(requestAnimationFrame as any) = _raf;
(cancelAnimationFrame as any) = _caf;
_raf = _caf = undefined;
handlers.length = 0;
_id = 0;
}
};
}
166 changes: 166 additions & 0 deletions spec/observables/dom/animationFrames-spec.ts
@@ -0,0 +1,166 @@
import { expect } from 'chai';
import { animationFrames, Subject } from 'rxjs';
import * as sinon from 'sinon';
import { take, takeUntil } from 'rxjs/operators';
import { RAFTestTools, stubRAF } from 'spec/helpers/test-helper';

describe('animationFrame', () => {
let raf: RAFTestTools;
let DateStub: sinon.SinonStub;
let now = 1000;

beforeEach(() => {
raf = stubRAF();
DateStub = sinon.stub(Date, 'now').callsFake(() => {
return ++now;
});
});

afterEach(() => {
raf.restore();
DateStub.restore();
});

it('should animate', function () {
const results: any[] = [];
const source$ = animationFrames();

const subs = source$.subscribe({
next: ts => results.push(ts),
error: err => results.push(err),
complete: () => results.push('done'),
});

expect(DateStub).to.have.been.calledOnce;

expect(results).to.deep.equal([]);

raf.tick();
expect(DateStub).to.have.been.calledTwice;
expect(results).to.deep.equal([1]);

raf.tick();
expect(DateStub).to.have.been.calledThrice;
expect(results).to.deep.equal([1, 2]);

raf.tick();
expect(results).to.deep.equal([1, 2, 3]);

// Stop the animation loop
subs.unsubscribe();
});

it('should use any passed timestampProvider', () => {
const results: any[] = [];
let i = 0;
const timestampProvider = {
now: sinon.stub().callsFake(() => {
return [100, 200, 210, 300][i++];
})
};

const source$ = animationFrames(timestampProvider);

const subs = source$.subscribe({
next: ts => results.push(ts),
error: err => results.push(err),
complete: () => results.push('done'),
});

expect(DateStub).not.to.have.been.called;
expect(timestampProvider.now).to.have.been.calledOnce;
expect(results).to.deep.equal([]);

raf.tick();
expect(DateStub).not.to.have.been.called;
expect(timestampProvider.now).to.have.been.calledTwice;
expect(results).to.deep.equal([100]);

raf.tick();
expect(DateStub).not.to.have.been.called;
expect(timestampProvider.now).to.have.been.calledThrice;
expect(results).to.deep.equal([100, 110]);

raf.tick();
expect(results).to.deep.equal([100, 110, 200]);

// Stop the animation loop
subs.unsubscribe();
});

it('should compose with take', () => {
const results: any[] = [];
const source$ = animationFrames();
expect(requestAnimationFrame).not.to.have.been.called;

source$.pipe(
take(2),
).subscribe({
next: ts => results.push(ts),
error: err => results.push(err),
complete: () => results.push('done'),
});

expect(DateStub).to.have.been.calledOnce;
expect(requestAnimationFrame).to.have.been.calledOnce;

expect(results).to.deep.equal([]);

raf.tick();
expect(DateStub).to.have.been.calledTwice;
expect(requestAnimationFrame).to.have.been.calledTwice;
expect(results).to.deep.equal([1]);

raf.tick();
expect(DateStub).to.have.been.calledThrice;
// It shouldn't reschedule, because there are no more subscribers
// for the animation loop.
expect(requestAnimationFrame).to.have.been.calledTwice;
expect(results).to.deep.equal([1, 2, 'done']);

// Since there should be no more subscribers listening on the loop
// the latest animation frame should be cancelled.
expect(cancelAnimationFrame).to.have.been.calledOnce;
});

it('should compose with takeUntil', () => {
const subject = new Subject();
const results: any[] = [];
const source$ = animationFrames();
expect(requestAnimationFrame).not.to.have.been.called;

source$.pipe(
takeUntil(subject),
).subscribe({
next: ts => results.push(ts),
error: err => results.push(err),
complete: () => results.push('done'),
});

expect(DateStub).to.have.been.calledOnce;
expect(requestAnimationFrame).to.have.been.calledOnce;

expect(results).to.deep.equal([]);

raf.tick();
expect(DateStub).to.have.been.calledTwice;
expect(requestAnimationFrame).to.have.been.calledTwice;
expect(results).to.deep.equal([1]);

raf.tick();
expect(DateStub).to.have.been.calledThrice;
expect(requestAnimationFrame).to.have.been.calledThrice;
expect(results).to.deep.equal([1, 2]);
expect(cancelAnimationFrame).not.to.have.been.called;

// Complete the observable via `takeUntil`.
subject.next();
expect(cancelAnimationFrame).to.have.been.calledOnce;
expect(results).to.deep.equal([1, 2, 'done']);

raf.tick();
expect(DateStub).to.have.been.calledThrice;
expect(requestAnimationFrame).to.have.been.calledThrice;
expect(results).to.deep.equal([1, 2, 'done']);
});
});
1 change: 1 addition & 0 deletions src/index.ts
Expand Up @@ -4,6 +4,7 @@ export { ConnectableObservable } from './internal/observable/ConnectableObservab
export { GroupedObservable } from './internal/operators/groupBy';
export { Operator } from './internal/Operator';
export { observable } from './internal/symbol/observable';
export { animationFrames } from './internal/observable/dom/animationFrames';

/* Subjects */
export { Subject } from './internal/Subject';
Expand Down

0 comments on commit 6a4cd68

Please sign in to comment.