From 19a7fc3a124c881514b3edcaa4489c8667024e6e Mon Sep 17 00:00:00 2001 From: Ben Lesh Date: Mon, 16 Sep 2019 08:41:17 -0500 Subject: [PATCH] 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`). --- .../observables/dom/animationFrame-spec.ts | 13 ++ spec/helpers/test-helper.ts | 103 +++++++++ spec/observables/dom/animationFrame-spec.ts | 166 +++++++++++++++ src/index.ts | 1 + src/internal/observable/dom/animationFrame.ts | 200 ++++++++++++++++++ tsconfig.base.json | 6 +- 6 files changed, 484 insertions(+), 5 deletions(-) create mode 100644 spec-dtslint/observables/dom/animationFrame-spec.ts create mode 100644 spec/observables/dom/animationFrame-spec.ts create mode 100644 src/internal/observable/dom/animationFrame.ts diff --git a/spec-dtslint/observables/dom/animationFrame-spec.ts b/spec-dtslint/observables/dom/animationFrame-spec.ts new file mode 100644 index 00000000000..249615ee0f8 --- /dev/null +++ b/spec-dtslint/observables/dom/animationFrame-spec.ts @@ -0,0 +1,13 @@ +import { animationFrames } from 'rxjs'; + +it('should just be an observable of numbers', () => { + const o$ = animationFrames(); // $ExpectType Observable +}); + +it('should allow the passing of a timestampProvider', () => { + const o$ = animationFrames(performance); // $ExpectType Observable +}); + +it('should not allow the passing of an invalid timestamp provider', () => { + const o$ = animationFrames({ now() { return 'wee' } }); // $ExpectError +}); \ No newline at end of file diff --git a/spec/helpers/test-helper.ts b/spec/helpers/test-helper.ts index 3096a153bab..25d24619a90 100644 --- a/spec/helpers/test-helper.ts +++ b/spec/helpers/test-helper.ts @@ -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(...args: Array): Observable { const o = { @@ -47,3 +48,105 @@ export const createObservableInputs = (value: T) => of( ) as Observable>; 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; + } + }; +} \ No newline at end of file diff --git a/spec/observables/dom/animationFrame-spec.ts b/spec/observables/dom/animationFrame-spec.ts new file mode 100644 index 00000000000..8b690679c5b --- /dev/null +++ b/spec/observables/dom/animationFrame-spec.ts @@ -0,0 +1,166 @@ +import { expect } from 'chai'; +import { animationFrame, 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$ = animationFrame(); + + 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$ = animationFrame(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$ = animationFrame(); + 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$ = animationFrame(); + 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']); + }); +}); diff --git a/src/index.ts b/src/index.ts index 624a92be5c0..792869a08b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 as animationFrame } from './internal/observable/dom/animationFrame'; /* Subjects */ export { Subject } from './internal/Subject'; diff --git a/src/internal/observable/dom/animationFrame.ts b/src/internal/observable/dom/animationFrame.ts new file mode 100644 index 00000000000..791469fa7fe --- /dev/null +++ b/src/internal/observable/dom/animationFrame.ts @@ -0,0 +1,200 @@ +import { Observable } from '../../Observable'; +import { Subscriber } from '../../Subscriber'; + +// TODO: move to types.ts +export interface TimestampProvider { + now(): number; +} + +/** + * A list of subscribers to notify, the timestamp providers used to get values, and the start times + * from their subscriptions. + * + * The structure is as follows: + * + * `i`: `Subscriber` - The subscriber to notify. + * `i + 1`: `timestampProvider` - the object used to get each new timestamp + * `i + 2`: `number` - the start timestamp + */ +const subscriberState: (Subscriber | TimestampProvider | number)[] = []; + +/** + * A list of subscribers to remove at the end of the run loop. + * + * This exists because during the run loop, when `next` is called on each subscriber, + * a side effect could occur that would cause unsubscription. In order to prevent us + * from looping over a currently mutating array of subscribers, we buffer the subscribers + * we want to remove until after the run loop is done, then remove them. + */ +const subscribersToRemoveAfterRun: Subscriber[] = []; + +/** + * An observable of animation frames + * + * Emits the the amount of time elapsed since subscription on each animation frame. Defaults to elapsed + * milliseconds. Does not end on its own. + * + * Will schedule a shared animation frame loop and notify all subscribers from that one loop. As + * an implementation detail, this means that it does not necessary call `requestAnimationFrame` at + * the time of subscription, rather it shares a single `requestAnimationFrame` loop between many + * subscriptions. + * + * This is useful for setting up animations with RxJS. + * + * ### Example + * + * Tweening a div to move it on the screen + * + * ```ts + * import { animationFrames } from 'rxjs'; + * import { map, takeWhile, endWith } from 'rxjs/operators'; + * + * function tween(start: number, end: number, duration: number) { + * const diff = end - start; + * return animationFrames().pipe( + * // Figure out what percentage of time has passed + * map(elapsed => elapsed / duration), + * // Take the vector while less than 100% + * takeWhile(v => v < 1), + * // Finish with 100% + * endWith(1), + * // Calculate the distance traveled between start and end + * map(v => v * diff + start) + * ); + * } + * + * // Setup a div for us to move around + * const div = document.createElement('div'); + * document.body.appendChild(div); + * div.style.position = 'absolute'; + * div.style.width = '40px'; + * div.style.height = '40px'; + * div.style.backgroundColor = 'lime'; + * div.style.transform = 'translate3d(10px, 0, 0)'; + * + * tween(10, 200, 4000).subscribe(x => { + * div.style.transform = `translate3d(${x}px, 0, 0)`; + * }); + * ``` + * + * ### Example + * + * Providing a custom timestamp provider + * + * ```ts + * import { animationFrames, TimestampProvider } from 'rxjs'; + * + * // A custom timestamp provider + * let now = 0; + * const customTSProvider: TimestampProvider = { + * now() { return now++; } + * }; + * + * const source$ = animationFrames(customTSProvider); + * + * // Log increasing numbers 0...1...2... on every animation frame. + * source$.subscribe(x => console.log(x)); + * ``` + * + * @param timestampProvider An object with a `now` method that provides a numeric timestamp + */ +export function animationFrames(timestampProvider: TimestampProvider = Date) { + return new Observable(subscriber => { + subscriberState.push(subscriber, timestampProvider, timestampProvider.now()); + startAnimationLoop(); + return () => { + if (isCurrentlyNotifying) { + // The `animate` loop is currently firing. We need to wait before we + // remove the subscriber from the list. + subscribersToRemoveAfterRun.push(subscriber); + } else { + removeSubscriber(subscriber); + } + }; + }); +} + +/** + * Removes a subscriber, and its accompanying data, from the list that gets notified when an animation frame has fired. + * + * NOTE: This implementation is relying on the fact that `subscriber` will always be a different instance as it is passed + * into the observable's initialization function (passed to the Observable ctor). + * + * @param subscriber the subscriber to remove from the list of subscribers to notify + */ +function removeSubscriber(subscriber: Subscriber) { + const index = subscriberState.indexOf(subscriber); + if (index >= 0) { + // Remove the subscriber and its timestampProvider + subscriberState.splice(index, 3); + if (subscriberState.length === 0) { + stopAnimationLoop(); + } + } +} + +/** + * The currently scheduled animation frame id. + */ +let scheduledAnimationId = 0; + +/** + * If `true`, the `animate` loop is currently notifying the subscribers. + * + * We have this so we can see if unsubscription should defer the removal of subscribers from + * the inner list. This is okay, because subscribers that are already unsubscribed will not notify, + * and it saves us from needing to copy the array of subscribers prior to the run loop. + */ +let isCurrentlyNotifying = false; + +/** + * Starts the animation frame `animate` loop, if necessary. + * Idempotent. If called and it's already scheduled to start, it will not reschedule or cancel. + */ +function startAnimationLoop() { + if (scheduledAnimationId === 0) { + scheduledAnimationId = requestAnimationFrame(animate); + } +} + +/** + * Executes notification of all subscribers, then reschedules itself. + * Do not call directly. This is the "run loop". + */ +function animate() { + // Flag to to make sure unsubscription knows it cannot remove subscribers at this time + // If an unsubscribe occurs (due to a `next` call side effect), this flag will tell it + // to defer the removal of the subscription, this saves us from having to copy the array + // of subscribers. + isCurrentlyNotifying = true; + for (let i = 0; i < subscriberState.length; i += 3) { + const subscriber = subscriberState[i] as Subscriber; + const timestampProvider = subscriberState[i + 1] as TimestampProvider; + const startTime = subscriberState[i + 2] as number; + subscriber.next(timestampProvider.now() - startTime); + } + isCurrentlyNotifying = false; + + // Clean up any subscribers that were removed by side effects during notification above. + while (subscribersToRemoveAfterRun.length > 0) { + removeSubscriber(subscribersToRemoveAfterRun.shift()); + } + + if (subscriberState.length > 0) { + // Schedule this to fire again. + scheduledAnimationId = requestAnimationFrame(animate); + } +} + +/** + * Stops the animation frame `animate` loop. + */ +function stopAnimationLoop() { + // We only want to stop the animation frame if we actually have one scheduled. + // DEV TIP: There should be nothing in `subscriberState` at this point! + if (scheduledAnimationId) { + cancelAnimationFrame(scheduledAnimationId); + // Ensure we reset the animation frame so we can start it again in `start`. + scheduledAnimationId = 0; + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 14e762195e4..f5da547783f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -14,11 +14,7 @@ "outDir": "./.out", "lib": [ "es5", - "es2015.iterable", - "es2015.collection", - "es2015.promise", - "es2015.symbol", - "es2015.symbol.wellknown", + "es2018", "dom" ] },