├── .babelrc ├── .coveralls.yml ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .nvmrc ├── .travis.yml ├── README.md ├── package-lock.json ├── package.json ├── src └── AnimationFrameComponent.jsx └── test ├── AnimationFrameComponentTests.jsx ├── init.js └── mocha.opts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "env"] 3 | } 4 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.transpiled.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "globals": { 3 | "expect": true, 4 | "describe": true, 5 | "it": true, 6 | "beforeEach": true, 7 | "afterEach": true, 8 | "createDom": true, 9 | "destroyDom": true, 10 | "mockRaf": true 11 | }, 12 | 13 | "env": { 14 | "es6": true, 15 | "commonjs": true, 16 | "browser": true 17 | }, 18 | 19 | "extends": "eslint:recommended", 20 | 21 | "parserOptions": { 22 | "ecmaFeatures": { 23 | "jsx": true 24 | } 25 | }, 26 | 27 | "plugins": ["react"], 28 | 29 | "rules": { 30 | 31 | "indent": [ 32 | "error", 33 | 4 34 | ], 35 | 36 | "linebreak-style": [ 37 | "error", 38 | "unix" 39 | ], 40 | 41 | "quotes": [ 42 | "error", 43 | "single" 44 | ], 45 | 46 | "semi": [ 47 | "error", 48 | "always" 49 | ], 50 | 51 | "no-var": "error", 52 | "react/jsx-uses-vars": 2 53 | } 54 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | *.transpiled.js 3 | node_modules 4 | coverage 5 | .nyc_output -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | .babelrc 4 | .eslintignore 5 | .eslintrc.js 6 | .nvmrc 7 | README.md -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v8.6.0 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 8 3 | 4 | install: "npm i" 5 | script: "npm run test-with-coverage" 6 | 7 | deploy: 8 | provider: npm 9 | email: "james@jamesswright.co.uk" 10 | api_key: 11 | secure: "IgDd7RMfLEysVzuJmPeHuiyg3V3nK9eb7rfdXdYbgSgkfFuUPck0VjUMB1vv9/7dnDolvEdJ3xGCuEbW2wX/5hq16x0k+WTfICrXy8uy2E8dhZsfYMeOqbvjNe/mahuJ4IfcM8gDptf8/oKGbUi2eeji2v1d726Hbd+R5cLu8vEVaJFxqH7io5HgZdwP2+n7r4CSpWjulz+XmamjKSH5FQ61GOBZNos/72Ef4T63KTbV9dYpIXpdACPryhv7U0BClxfIKewcFuaU707xbn5UanRpsD7nP5GMnhL+Jxu2UxVDiZ6oEvQbYLxen9gaKMbuvYd+xybztVIm4RRuxnZGBmJQ88o2CrXRSKdA368JFOwkZssmlXkTx5iutfP5qnT6MQ6WzX3z+o7sYLDEFwhV9aHwssfPhR/CKIMaRo2ZsSh+QsPUsedW8TNtpvxdhtecZv5JxwR8wQ9Ri/k5cTavvfwlhr0B+9e3H3E+tZ/V0KxloLLyL933z28RzAFywjwxuyhK/3BQuPdl2r5XD2EcqXHcuV4r/K0rPn033bqZ0vesqcI8NuYrfW//fr7WjT6Ugsd+G97TJwylOQduXJcbLTcChFwq7fyyxWUrV43ONCH5O5XRGcWiuwvLISwsOGuXwaMb+6jGcwm7KB3kyZaS2g3q4/5tK+TKGRVzjonMThA=" 12 | 13 | on: 14 | tags: true 15 | 16 | after_success: npm run coverage -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Animation Frame 2 | 3 | [![Build Status](https://travis-ci.org/jamesseanwright/react-animation-frame.svg?branch=master)](https://travis-ci.org/jamesseanwright/react-animation-frame) [![Coverage Status](https://coveralls.io/repos/github/jamesseanwright/react-animation-frame/badge.svg?branch=master)](https://coveralls.io/github/jamesseanwright/react-animation-frame?branch=master) 4 | [![npm version](https://badge.fury.io/js/react-animation-frame.svg)](https://www.npmjs.com/package/react-animation-frame) 5 | 6 | 7 | A React higher-order component for invoking component repeating logic using [`requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame). It works in both the [browser](https://github.com/jamesseanwright/react-animation-frame-example) and [React Native](https://github.com/jamesseanwright/react-animation-frame-native-example). 8 | 9 | The module follows the CommonJS format, so it is compatible with Browserify and Webpack. It also supports ES5 and beyond. 10 | 11 | ## Motivation 12 | 13 | Take a look at the [example project](https://github.com/jamesseanwright/react-animation-frame-example); this contains a `Timer` component that should animate a progress bar until the configured end time is surpassed: 14 | 15 | ```js 16 | 'use strict'; 17 | 18 | const React = require('react'); 19 | const ReactAnimationFrame = require('react-animation-frame'); 20 | 21 | class Timer extends React.Component { 22 | onAnimationFrame(time) { 23 | const progress = Math.round(time / this.props.durationMs * 100); 24 | this.bar.style.width = `${progress}%`; 25 | 26 | if (progress === 100) { 27 | this.props.endAnimation(); 28 | } 29 | } 30 | 31 | render() { 32 | return ( 33 |
34 |

{this.props.message}

35 |
this.bar = node}>
36 |
37 | ); 38 | } 39 | } 40 | 41 | module.exports = ReactAnimationFrame(Timer); 42 | ``` 43 | 44 | The `onAnimationFrame` method will be called on each repaint, via `requestAnimationFrame`, by the higher-order `ReactAnimationFrame` component. Once the progress of our animation reaches 100%, we can kill the underlying loop using the `endAnimation` method passed to the `props` of the wrapped component. 45 | 46 | The loop can also be throttled by passing a second parameter to `ReactAnimationFrame`, which represents the number of milliseconds that should elapse between invocations of `onAnimationFrame`: 47 | 48 | ```js 49 | module.exports = ReactAnimationFrame(Timer, 100); 50 | ``` 51 | 52 | ## Installation 53 | 54 | `npm i --save react-animation-frame` 55 | 56 | 57 | ## API 58 | 59 | ### `ReactAnimationFrame(Component[, throttleMs])` 60 | 61 | Wraps `Component` and starts a `requestAnimationFrame` loop. `throttleMs` if specified, will throttle invocations of `onAnimationFrame` by any number of milliseconds. 62 | 63 | 64 | ### Inside a wrapped component 65 | 66 | #### `onAnimationFrame(timestamp, lastTimestamp)` 67 | 68 | Called on each iteration of the underlying `requestAnimationFrame` loop, or if the elapsed throttle time has been surpassed. `timestamp` is the same `DOMHighResTimeStamp` with which `requestAnimationFrame`'s callback is invoked. 69 | 70 | The previous timestamp is also passed, which can be useful for calculating deltas. For n calls, `lastTimestamp` will be the value of `timestamp` for call n - 1. 71 | 72 | 73 | #### `this.props.endAnimation()` 74 | 75 | Cancels the current animation frame and ends the loop. 76 | 77 | #### `this.props.startAnimation()` 78 | 79 | Function to restart the animation after it was ended by `endAnimation`. 80 | 81 | 82 | ### Local development 83 | 84 | Run `npm i` to install the dependencies. 85 | 86 | * `npm run build` - transpile the library 87 | * `npm test` - lints the code and runs the tests 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-animation-frame", 3 | "version": "1.0.2", 4 | "description": "A React higher-order component for managing recurring animation frames", 5 | "repository": "https://github.com/jamesseanwright/react-animation-frame", 6 | "main": "dist/AnimationFrameComponent.js", 7 | "scripts": { 8 | "build": "babel --out-dir dist src", 9 | "coverage": "nyc report --reporter=text-lcov | coveralls", 10 | "prepare": "npm run build", 11 | "pretest": "eslint --ext js --ext jsx src test", 12 | "test": "mocha", 13 | "test-with-coverage": "npm run pretest && nyc --extension .jsx mocha" 14 | }, 15 | "author": "James Wright ", 16 | "license": "ISC", 17 | "keywords": [ 18 | "react", 19 | "requestAnimationFrame", 20 | "higher-order component", 21 | "hoc" 22 | ], 23 | "devDependencies": { 24 | "babel-cli": "6.23.0", 25 | "babel-preset-env": "1.6.1", 26 | "babel-preset-react": "6.23.0", 27 | "chai": "3.5.0", 28 | "coveralls": "2.12.0", 29 | "enzyme": "3.1.0", 30 | "enzyme-adapter-react-16": "1.6.0", 31 | "eslint": "3.17.1", 32 | "eslint-plugin-react": "7.4.0", 33 | "jsdom": "9.11.0", 34 | "mocha": "3.2.0", 35 | "mock-raf": "1.0.0", 36 | "nyc": "11.2.1", 37 | "react": "16.6.1", 38 | "react-dom": "16.6.1", 39 | "react-test-renderer": "16.0.0", 40 | "sinon": "1.17.7" 41 | }, 42 | "peerDependencies": { 43 | "react": "~16 || ~15" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/AnimationFrameComponent.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const React = require('react'); 4 | 5 | module.exports = function AnimationFrameComponent(InnerComponent, throttleMs) { 6 | return class AnimatedComponent extends React.Component { 7 | constructor() { 8 | super(); 9 | 10 | this.loop = this.loop.bind(this); 11 | this.endAnimation = this.endAnimation.bind(this); 12 | this.startAnimation = this.startAnimation.bind(this); 13 | 14 | this.isActive = true; 15 | this.rafId = 0; 16 | this.lastInvocationMs = 0; 17 | } 18 | 19 | loop(time) { 20 | const { lastInvocationMs, isActive } = this; 21 | const isAnimatable = !!(this.innerComponent && this.innerComponent.onAnimationFrame); 22 | 23 | // Latter const is defensive check for React Native unmount (issues/#3) 24 | if (!isActive || !isAnimatable) return; 25 | 26 | const hasTimeElapsed = !throttleMs || time - lastInvocationMs >= throttleMs; 27 | 28 | if (hasTimeElapsed) { 29 | this.lastInvocationMs = time; 30 | this.innerComponent.onAnimationFrame(time, lastInvocationMs); 31 | } 32 | 33 | this.rafId = requestAnimationFrame(this.loop); 34 | } 35 | 36 | endAnimation() { 37 | cancelAnimationFrame(this.rafId); 38 | 39 | this.isActive = false; 40 | } 41 | 42 | startAnimation() { 43 | if (!this.isActive) { 44 | this.isActive = true; 45 | this.rafId = requestAnimationFrame(this.loop); 46 | } 47 | } 48 | 49 | componentDidMount() { 50 | if (!this.innerComponent.onAnimationFrame) { 51 | throw new Error('The component passed to AnimationFrameComponent does not implement onAnimationFrame'); 52 | } 53 | 54 | this.rafId = requestAnimationFrame(this.loop); 55 | } 56 | 57 | componentWillUnmount() { 58 | this.endAnimation(); 59 | } 60 | 61 | render() { 62 | return ( 63 | this.innerComponent = node} 64 | endAnimation={this.endAnimation} 65 | startAnimation={this.startAnimation} 66 | {...this.props} /> 67 | ); 68 | } 69 | }; 70 | }; 71 | -------------------------------------------------------------------------------- /test/AnimationFrameComponentTests.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const enzyme = require('enzyme'); 4 | const React = require('react'); 5 | const { expect } = require('chai'); 6 | const sinon = require('sinon'); 7 | 8 | const AnimationFrameComponent = require('../src/AnimationFrameComponent'); 9 | 10 | class InnerComponent extends React.Component { 11 | onAnimationFrame() {} 12 | 13 | render() { 14 | return

Foo

; 15 | } 16 | } 17 | 18 | class NonAnimatable extends React.Component { 19 | render() { 20 | return

Can't be animated!

; 21 | } 22 | } 23 | 24 | describe('the RequestAnimationFrame HOC', function () { 25 | let mockComponent; 26 | 27 | beforeEach(function () { 28 | mockComponent = sinon.mock(InnerComponent.prototype); 29 | createDom(); 30 | }); 31 | 32 | afterEach(function () { 33 | mockComponent.restore(); 34 | destroyDom(); 35 | }); 36 | 37 | it('should throw an error if the inner component doesn`t implement onAnimationFrame', function () { 38 | const WrappedComponent = AnimationFrameComponent(NonAnimatable); 39 | 40 | expect(() => enzyme.mount()).to.throw( 41 | 'The component passed to AnimationFrameComponent does not implement onAnimationFrame' 42 | ); 43 | }); 44 | 45 | it('should pass all properties to the wrapped component', function () { 46 | const WrappedComponent = AnimationFrameComponent(InnerComponent); 47 | 48 | const renderedComponent = enzyme.mount( 49 | 50 | ); 51 | 52 | const innerComponent = renderedComponent.find(InnerComponent); 53 | 54 | expect(renderedComponent.prop('foo')).to.deep.equal(innerComponent.prop('foo')); 55 | expect(renderedComponent.prop('baz')).to.deep.equal(innerComponent.prop('baz')); 56 | }); 57 | 58 | it('should call onAnimationFrame on each frame', function () { 59 | mockComponent.expects('onAnimationFrame') 60 | .thrice() 61 | .withArgs(sinon.match.number); 62 | 63 | const WrappedComponent = AnimationFrameComponent(InnerComponent); 64 | 65 | enzyme.mount(); 66 | mockRaf.step({ count: 3 }); 67 | 68 | mockComponent.verify(); 69 | }); 70 | 71 | it('should pass the current and previous times to onAnimationFrame', function () { 72 | mockComponent.expects('onAnimationFrame') 73 | .withArgs(16.666666666666668, 0) 74 | .onFirstCall(); 75 | 76 | mockComponent.expects('onAnimationFrame') 77 | .withArgs(33.333333333333336, 16.666666666666668) 78 | .onSecondCall(); 79 | 80 | const WrappedComponent = AnimationFrameComponent(InnerComponent); 81 | 82 | enzyme.mount(); 83 | mockRaf.step({ count: 2 }); 84 | 85 | mockComponent.verify(); 86 | }); 87 | 88 | it('should stop looping when the endAnimation method is invoked', function () { 89 | mockComponent.expects('onAnimationFrame') 90 | .once() 91 | .withArgs(sinon.match.number); 92 | 93 | const WrappedComponent = AnimationFrameComponent(InnerComponent); 94 | const renderedComponent = enzyme.mount(); 95 | const innerComponent = renderedComponent.find(InnerComponent); 96 | 97 | mockRaf.step({ count: 1 }); 98 | 99 | innerComponent.prop('endAnimation')(); 100 | 101 | mockRaf.step({ count: 3 }); 102 | 103 | mockComponent.verify(); 104 | }); 105 | 106 | it('should restart looping when the startAnimation method is invoked', function () { 107 | mockComponent.expects('onAnimationFrame') 108 | .twice() 109 | .withArgs(sinon.match.number); 110 | 111 | const WrappedComponent = AnimationFrameComponent(InnerComponent); 112 | const renderedComponent = enzyme.mount(); 113 | const innerComponent = renderedComponent.find(InnerComponent); 114 | 115 | mockRaf.step({ count: 1 }); 116 | 117 | innerComponent.prop('endAnimation')(); 118 | 119 | mockRaf.step({ count: 3 }); 120 | 121 | innerComponent.prop('startAnimation')(); 122 | 123 | mockRaf.step({ count: 1 }); 124 | 125 | mockComponent.verify(); 126 | }); 127 | 128 | it('should throttle the invocation of the callback if specified', function (done) { 129 | this.timeout(4000); 130 | 131 | const rafIntervalMs = 16; // fixing rAF interval for predictable testing 132 | const throttleMs = 1000; 133 | const invocationCount = 3; 134 | const stepCount = Math.ceil(throttleMs / rafIntervalMs) * invocationCount; 135 | 136 | mockComponent.expects('onAnimationFrame') 137 | .exactly(invocationCount) 138 | .withArgs(sinon.match.number); 139 | 140 | const WrappedComponent = AnimationFrameComponent(InnerComponent, throttleMs); 141 | 142 | enzyme.mount(); 143 | 144 | mockRaf.step({ count: stepCount, time: rafIntervalMs }); 145 | 146 | /* While this is generally a bad practice, 147 | * fake timers can't be used as the component 148 | * is tracking elapsed time between each rAF 149 | * loop invocation. */ 150 | setTimeout(() => { 151 | mockComponent.verify(); 152 | done(); 153 | }, throttleMs * invocationCount + 5); 154 | }); 155 | 156 | describe('React Native unmount bug resolution', function () { 157 | it('should not call onAnimationFrame when the child unmounts', function () { 158 | mockComponent.expects('onAnimationFrame') 159 | .never(); 160 | 161 | const WrappedComponent = AnimationFrameComponent(InnerComponent); 162 | const renderedComponent = enzyme.mount(); 163 | 164 | renderedComponent.instance().innerComponent = null; 165 | mockRaf.step({ count: 2 }); 166 | 167 | mockComponent.verify(); 168 | }); 169 | 170 | it('should not call onAnimationFrame it becomes unavailable', function () { 171 | mockComponent.expects('onAnimationFrame') 172 | .never(); 173 | 174 | const WrappedComponent = AnimationFrameComponent(InnerComponent); 175 | const renderedComponent = enzyme.mount(); 176 | 177 | renderedComponent.instance().innerComponent.onAnimationFrame = null; 178 | mockRaf.step({ count: 2 }); 179 | 180 | mockComponent.verify(); 181 | }); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /test/init.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('babel-register'); 4 | const { jsdom } = require('jsdom'); 5 | const createMockRaf = require('mock-raf'); 6 | const enzyme = require('enzyme'); 7 | const Adapter = require('enzyme-adapter-react-16'); 8 | const adapter = new Adapter(); 9 | 10 | enzyme.configure({ adapter }); 11 | 12 | global.createDom = function createDom() { 13 | const mockRaf = createMockRaf(); 14 | 15 | const document = jsdom(` 16 | 17 | 18 | 19 | 20 | `); 21 | 22 | global.document = document; 23 | global.window = document.defaultView; 24 | global.mockRaf = mockRaf; 25 | global.requestAnimationFrame = mockRaf.raf; 26 | global.cancelAnimationFrame = () => {}; 27 | }; 28 | 29 | global.destroyDom = function destroyDom() { 30 | global.window.close(); 31 | delete global.window; 32 | delete global.document; 33 | }; 34 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | test/*.jsx 2 | --require test/init --------------------------------------------------------------------------------