diff --git a/packages/request-idle-callback-polyfill/README.md b/packages/request-idle-callback-polyfill/README.md new file mode 100644 index 0000000000000..d09bfd445a75b --- /dev/null +++ b/packages/request-idle-callback-polyfill/README.md @@ -0,0 +1,4 @@ +# request-idle-callback-polyfill + +A polyfill for `requestIdleCallback` + diff --git a/packages/request-idle-callback-polyfill/index.js b/packages/request-idle-callback-polyfill/index.js new file mode 100644 index 0000000000000..b26ec6a1b8c1b --- /dev/null +++ b/packages/request-idle-callback-polyfill/index.js @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +'use strict'; + +export * from './src/RequestIdleCallback'; diff --git a/packages/request-idle-callback-polyfill/npm/index.js b/packages/request-idle-callback-polyfill/npm/index.js new file mode 100644 index 0000000000000..69511615332ab --- /dev/null +++ b/packages/request-idle-callback-polyfill/npm/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/request-idle-callback-polyfill.production.min.js'); +} else { + module.exports = require('./cjs/request-idle-callback-polyfill.development.js'); +} diff --git a/packages/request-idle-callback-polyfill/package.json b/packages/request-idle-callback-polyfill/package.json new file mode 100644 index 0000000000000..5adbabb34096f --- /dev/null +++ b/packages/request-idle-callback-polyfill/package.json @@ -0,0 +1,18 @@ +{ + "name": "request-idle-callback-polyfill", + "description": "A polyfill for requestIdleCallback, used by React.", + "version": "0.1.0-alpha.1", + "keywords": [ + "requestIdleCallback" + ], + "homepage": "https://facebook.github.io/react/", + "bugs": "https://github.com/facebook/react/issues", + "license": "MIT", + "files": ["LICENSE", "README.md", "index.js", "cjs/"], + "main": "index.js", + "repository": "facebook/react", + "dependencies": { + "fbjs": "^0.8.16" + } + } + \ No newline at end of file diff --git a/packages/request-idle-callback-polyfill/src/RequestIdleCallback.js b/packages/request-idle-callback-polyfill/src/RequestIdleCallback.js new file mode 100644 index 0000000000000..b8be9ba3f49ac --- /dev/null +++ b/packages/request-idle-callback-polyfill/src/RequestIdleCallback.js @@ -0,0 +1,202 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// This is a built-in polyfill for requestIdleCallback. It works by scheduling +// a requestAnimationFrame, storing the time for the start of the frame, then +// scheduling a postMessage which gets scheduled after paint. Within the +// postMessage handler do as much work as possible until time + frame rate. +// By separating the idle call into a separate event tick we ensure that +// layout, paint and other browser work is counted against the available time. +// The frame rate is dynamically adjusted. + +export type IdleDeadline = { + timeRemaining: () => number, + didTimeout: boolean, +}; + +type IdleRequestOptions = { + timeout: number, +}; + +export type IdleRequestCallback = IdleDeadline => void; + +const hasNativePerformanceNow = + typeof performance === 'object' && typeof performance.now === 'function'; + +let now; +if (hasNativePerformanceNow) { + now = function() { + return performance.now(); + }; +} else { + now = function() { + return Date.now(); + }; +} + +function IdleDeadlineImpl(deadline: number, didTimeout: boolean) { + this._deadline = deadline; + this.didTimeout = didTimeout; +} + +IdleDeadlineImpl.prototype.timeRemaining = function() { + // If the callback timed out there's definitely no time remaining + if (this.didTimeout) { + return 0; + } + // We assume that if we have a performance timer that the rAF callback + // gets a performance timer value. Not sure if this is always true. + const remaining = this._deadline - now(); + return remaining > 0 ? remaining : 0; +}; + +const idleCallbacks: Array = []; +const idleCallbackTimeouts: Array = []; +let idleCallbackIdentifier = 0; +let currentIdleCallbackHandle = 0; +let lastIdlePeriodDeadline = 0; + +let isIdleScheduled = false; + +let isAnimationFrameScheduled = false; +// We start out assuming that we run at 30fps but then the heuristic tracking +// will adjust this value to a faster fps if we get more frequent animation +// frames. +let previousFrameTime = 33; +let activeFrameTime = 33; +// Tracks whether the 'message' event listener has been registered, which is done +// lazily the first time requestIdleCallback is called +let registeredMessageListener = false; + +// We use the postMessage trick to defer idle work until after the repaint. +const messageKey = + '__reactIdleCallback$' + + Math.random() + .toString(36) + .slice(2); + +const idleTick = function(event) { + if (event.source !== window || event.data !== messageKey) { + return; + } + + isIdleScheduled = false; + // While there are still callbacks in the queue... + while (currentIdleCallbackHandle < idleCallbacks.length) { + // Get the callback and the timeout, if it exists + const timeoutTime = idleCallbackTimeouts[currentIdleCallbackHandle]; + const callback = idleCallbacks[currentIdleCallbackHandle]; + // This callback might have been cancelled, continue to check the rest of the queue + if (!callback) { + currentIdleCallbackHandle++; + continue; + } + const currentTime = now(); + let didTimeout = false; + if (lastIdlePeriodDeadline - currentTime <= 0) { + // There's no time left in this idle period. Check if the callback has + // a timeout and whether it's been exceeded. + if (timeoutTime != null && timeoutTime <= currentTime) { + // Exceeded the timeout. Invoke the callback even though there's no + // time left. + didTimeout = true; + } else { + // No timeout. + if (!isAnimationFrameScheduled) { + // Schedule another animation callback so we retry later. + isAnimationFrameScheduled = true; + requestAnimationFrame(animationTick); + } + // Exit without invoking the callback. + return; + } + } else { + // There's still time left in this idle period. + didTimeout = false; + } + currentIdleCallbackHandle++; + callback(new IdleDeadlineImpl(lastIdlePeriodDeadline, didTimeout)); + } +}; + +function animationTick(rafTime: number) { + isAnimationFrameScheduled = false; + let nextFrameTime = rafTime - lastIdlePeriodDeadline + activeFrameTime; + if (nextFrameTime < activeFrameTime && previousFrameTime < activeFrameTime) { + if (nextFrameTime < 8) { + // Defensive coding. We don't support higher frame rates than 120hz. + // If we get lower than that, it is probably a bug. + nextFrameTime = 8; + } + // If one frame goes long, then the next one can be short to catch up. + // If two frames are short in a row, then that's an indication that we + // actually have a higher frame rate than what we're currently optimizing. + // We adjust our heuristic dynamically accordingly. For example, if we're + // running on 120hz display or 90hz VR display. + // Take the max of the two in case one of them was an anomaly due to + // missed frame deadlines. + activeFrameTime = + nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime; + } else { + previousFrameTime = nextFrameTime; + } + lastIdlePeriodDeadline = rafTime + activeFrameTime; + if (!isIdleScheduled) { + isIdleScheduled = true; + window.postMessage(messageKey, '*'); + } +} + +function invokerIdleCallbackTimeout(handle: number) { + const callback = idleCallbacks[handle]; + if (callback !== null) { + cancelIdleCallback(handle); + callback(new IdleDeadlineImpl(now(), true)); + } +} + +export function requestIdleCallback( + callback: IdleRequestCallback, + options?: IdleRequestOptions, +): number { + const handle = idleCallbackIdentifier++; + idleCallbacks[handle] = callback; + + if (options != null && typeof options.timeout === 'number') { + idleCallbackTimeouts[handle] = now() + options.timeout; + window.setTimeout( + () => invokerIdleCallbackTimeout(handle), + options.timeout, + ); + } + + // Lazily register the listener when rIC is first called + if (!registeredMessageListener) { + // Assumes that we have addEventListener in this environment. Might need + // something better for old IE. + window.addEventListener('message', idleTick, false); + registeredMessageListener = true; + } + if (!isAnimationFrameScheduled) { + // If rAF didn't already schedule one, we need to schedule a frame. + // TODO: If this rAF doesn't materialize because the browser throttles, we + // might want to still have setTimeout trigger rIC as a backup to ensure + // that we keep performing work. + isAnimationFrameScheduled = true; + requestAnimationFrame(animationTick); + } + return 0; +} + +export function cancelIdleCallback(handle: number) { + idleCallbacks[handle] = null; + idleCallbackTimeouts[handle] = null; + // @TODO this isn't true if there are still scheduled callbacks in the queue + isIdleScheduled = false; +} diff --git a/packages/request-idle-callback-polyfill/src/__tests__/RequestIdleCallback-test.js b/packages/request-idle-callback-polyfill/src/__tests__/RequestIdleCallback-test.js new file mode 100644 index 0000000000000..79c4e82aa7dee --- /dev/null +++ b/packages/request-idle-callback-polyfill/src/__tests__/RequestIdleCallback-test.js @@ -0,0 +1,145 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let highResolutionTimer; +let requestIdleCallback; +let cancelIdleCallback; +let animationFrameCallbacks; + +// Overwritten global methods +let previousPostMessage; +let previousRAF; + +function performanceNow() { + return highResolutionTimer; +} + +/** + * A synchronous version of jsdom's postMessage. Meant + * to work with mockRunNextFrame. + */ +function postMessage(message, targetOrign) { + const event = new MessageEvent('message', {data: message}); + event.initEvent('message', false, false); + // MessageEvent.source is defined as read-only and null in jsdom. + // Override the getter so the event.source check doesn't cause early + // returns in idleTick. + Object.defineProperty(event, 'source', { + value: window, + }); + window.dispatchEvent(event); +} + +function requestAnimationFrame(callback) { + animationFrameCallbacks.push(callback); +} + +function mockRunNextFrame() { + const callbacksToRun = animationFrameCallbacks.slice(); + const animationFrameStart = highResolutionTimer++; + animationFrameCallbacks.length = 0; + callbacksToRun.forEach(cb => cb(animationFrameStart)); +} + +function mockLongRunningCode() { + highResolutionTimer += 100; +} + +describe('RequestIdleCallback', () => { + beforeAll(() => { + previousRAF = window.requestAnimationFrame; + previousPostMessage = window.postMessage; + window.postMessage = postMessage; + window.performance = {now: performanceNow}; + window.requestAnimationFrame = requestAnimationFrame; + }); + + afterAll(() => { + window.requestAnimationFrame = previousRAF; + window.postMessage = previousPostMessage; + previousPostMessage = null; + previousRAF = null; + delete window.performance; + }); + + beforeEach(() => { + animationFrameCallbacks = []; + highResolutionTimer = 0xf000; + jest.resetModules(); + requestIdleCallback = require('request-idle-callback-polyfill') + .requestIdleCallback; + cancelIdleCallback = require('request-idle-callback-polyfill') + .cancelIdleCallback; + }); + + describe('requestIdleCallback', () => { + it('returns a number', () => { + const callback = jest.fn(); + expect(typeof requestIdleCallback(callback)).toBe('number'); + }); + it('executes callbacks asynchronously', () => { + const callback = jest.fn(); + requestIdleCallback(callback); + expect(callback).not.toBeCalled(); + mockRunNextFrame(); + expect(callback).toBeCalled(); + }); + it('cancels callbacks', () => { + const callback = jest.fn(); + const handle = requestIdleCallback(callback); + cancelIdleCallback(handle); + mockRunNextFrame(); + expect(callback).not.toBeCalled(); + }); + + it('passes a deadline to the callback', () => { + const ops = []; + const callback = jest.fn(deadline => { + ops.push(deadline.didTimeout); + ops.push(deadline.timeRemaining()); + mockLongRunningCode(); + ops.push(deadline.timeRemaining()); + }); + requestIdleCallback(callback); + mockRunNextFrame(); + expect(callback).toBeCalled(); + expect(ops[0]).toBe(false); + expect(ops[1]).toBeGreaterThan(0); + expect(ops[2]).toBe(0); + }); + + it('stops executing callbacks if the deadline expires', () => { + const ops = []; + requestIdleCallback(() => ops.push('first')); + requestIdleCallback(() => { + ops.push('second'); + mockLongRunningCode(); + }); + requestIdleCallback(() => ops.push('third')); + expect(ops).toEqual([]); + mockRunNextFrame(); + expect(ops).toEqual(['first', 'second']); + mockRunNextFrame(); + expect(ops).toEqual(['first', 'second', 'third']); + }); + + it('executes callbacks that timeout', () => { + const ops = []; + const callback = jest.fn(deadline => { + ops.push(deadline.didTimeout); + ops.push(deadline.timeRemaining()); + }); + requestIdleCallback(callback, {timeout: 100}); + jest.runAllTimers(); + expect(ops).toEqual([true, 0]); + }); + }); +}); diff --git a/packages/shared/ReactDOMFrameScheduling.js b/packages/shared/ReactDOMFrameScheduling.js index 86ddc9360f84a..8d2bf33bdd0e2 100644 --- a/packages/shared/ReactDOMFrameScheduling.js +++ b/packages/shared/ReactDOMFrameScheduling.js @@ -15,8 +15,12 @@ // layout, paint and other browser work is counted against the available time. // The frame rate is dynamically adjusted. -import type {Deadline} from 'react-reconciler'; +import type {IdleRequestCallback} from 'request-idle-callback-polyfill'; +import { + requestIdleCallback, + cancelIdleCallback, +} from 'request-idle-callback-polyfill'; import ExecutionEnvironment from 'fbjs/lib/ExecutionEnvironment'; import warning from 'fbjs/lib/warning'; @@ -47,18 +51,14 @@ if (hasNativePerformanceNow) { }; } -// TODO: There's no way to cancel, because Fiber doesn't atm. -let rIC: ( - callback: (deadline: Deadline, options?: {timeout: number}) => void, -) => number; -let cIC: (callbackID: number) => void; +let rIC: requestIdleCallback; +let cIC: cancelIdleCallback; if (!ExecutionEnvironment.canUseDOM) { - rIC = function( - frameCallback: (deadline: Deadline, options?: {timeout: number}) => void, - ): number { + rIC = function(frameCallback: IdleRequestCallback): number { return setTimeout(() => { frameCallback({ + didTimeout: false, timeRemaining() { return Infinity; }, @@ -72,147 +72,8 @@ if (!ExecutionEnvironment.canUseDOM) { typeof requestIdleCallback !== 'function' || typeof cancelIdleCallback !== 'function' ) { - // Polyfill requestIdleCallback and cancelIdleCallback - - let scheduledRICCallback = null; - let isIdleScheduled = false; - let timeoutTime = -1; - - let isAnimationFrameScheduled = false; - - let frameDeadline = 0; - // We start out assuming that we run at 30fps but then the heuristic tracking - // will adjust this value to a faster fps if we get more frequent animation - // frames. - let previousFrameTime = 33; - let activeFrameTime = 33; - - let frameDeadlineObject; - if (hasNativePerformanceNow) { - frameDeadlineObject = { - didTimeout: false, - timeRemaining() { - // We assume that if we have a performance timer that the rAF callback - // gets a performance timer value. Not sure if this is always true. - const remaining = frameDeadline - performance.now(); - return remaining > 0 ? remaining : 0; - }, - }; - } else { - frameDeadlineObject = { - didTimeout: false, - timeRemaining() { - // Fallback to Date.now() - const remaining = frameDeadline - Date.now(); - return remaining > 0 ? remaining : 0; - }, - }; - } - - // We use the postMessage trick to defer idle work until after the repaint. - const messageKey = - '__reactIdleCallback$' + - Math.random() - .toString(36) - .slice(2); - const idleTick = function(event) { - if (event.source !== window || event.data !== messageKey) { - return; - } - - isIdleScheduled = false; - - const currentTime = now(); - if (frameDeadline - currentTime <= 0) { - // There's no time left in this idle period. Check if the callback has - // a timeout and whether it's been exceeded. - if (timeoutTime !== -1 && timeoutTime <= currentTime) { - // Exceeded the timeout. Invoke the callback even though there's no - // time left. - frameDeadlineObject.didTimeout = true; - } else { - // No timeout. - if (!isAnimationFrameScheduled) { - // Schedule another animation callback so we retry later. - isAnimationFrameScheduled = true; - requestAnimationFrame(animationTick); - } - // Exit without invoking the callback. - return; - } - } else { - // There's still time left in this idle period. - frameDeadlineObject.didTimeout = false; - } - - timeoutTime = -1; - const callback = scheduledRICCallback; - scheduledRICCallback = null; - if (callback !== null) { - callback(frameDeadlineObject); - } - }; - // Assumes that we have addEventListener in this environment. Might need - // something better for old IE. - window.addEventListener('message', idleTick, false); - - const animationTick = function(rafTime) { - isAnimationFrameScheduled = false; - let nextFrameTime = rafTime - frameDeadline + activeFrameTime; - if ( - nextFrameTime < activeFrameTime && - previousFrameTime < activeFrameTime - ) { - if (nextFrameTime < 8) { - // Defensive coding. We don't support higher frame rates than 120hz. - // If we get lower than that, it is probably a bug. - nextFrameTime = 8; - } - // If one frame goes long, then the next one can be short to catch up. - // If two frames are short in a row, then that's an indication that we - // actually have a higher frame rate than what we're currently optimizing. - // We adjust our heuristic dynamically accordingly. For example, if we're - // running on 120hz display or 90hz VR display. - // Take the max of the two in case one of them was an anomaly due to - // missed frame deadlines. - activeFrameTime = - nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime; - } else { - previousFrameTime = nextFrameTime; - } - frameDeadline = rafTime + activeFrameTime; - if (!isIdleScheduled) { - isIdleScheduled = true; - window.postMessage(messageKey, '*'); - } - }; - - rIC = function( - callback: (deadline: Deadline) => void, - options?: {timeout: number}, - ): number { - // This assumes that we only schedule one callback at a time because that's - // how Fiber uses it. - scheduledRICCallback = callback; - if (options != null && typeof options.timeout === 'number') { - timeoutTime = now() + options.timeout; - } - if (!isAnimationFrameScheduled) { - // If rAF didn't already schedule one, we need to schedule a frame. - // TODO: If this rAF doesn't materialize because the browser throttles, we - // might want to still have setTimeout trigger rIC as a backup to ensure - // that we keep performing work. - isAnimationFrameScheduled = true; - requestAnimationFrame(animationTick); - } - return 0; - }; - - cIC = function() { - scheduledRICCallback = null; - isIdleScheduled = false; - timeoutTime = -1; - }; + rIC = requestIdleCallback; + cIC = cancelIdleCallback; } else { rIC = window.requestIdleCallback; cIC = window.cancelIdleCallback; diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 0be2375a58b9a..33521f0d8cbef 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -254,6 +254,15 @@ const bundles = [ global: 'SimpleCacheProvider', externals: ['react'], }, + /******* Request Idle Callback Polfyill *******/ + { + label: 'request-idle-callback-polyfill', + bundleTypes: [NODE_DEV, NODE_PROD, UMD_DEV, UMD_PROD], + moduleType: ISOMORPHIC, + entry: 'request-idle-callback-polyfill', + global: 'requestIdleCallback', + externals: [], + }, ]; // Based on deep-freeze by substack (public domain)