diff --git a/packages/react-scheduler/src/ReactScheduler.js b/packages/react-scheduler/src/ReactScheduler.js index b15e5f4428e..0486d466f02 100644 --- a/packages/react-scheduler/src/ReactScheduler.js +++ b/packages/react-scheduler/src/ReactScheduler.js @@ -14,10 +14,13 @@ * control than requestAnimationFrame and requestIdleCallback. * Current TODO items: * X- Pull out the rIC polyfill built into React - * - Initial test coverage - * - Support for multiple callbacks + * X- Initial test coverage + * X- Support for multiple callbacks * - Support for two priorities; serial and deferred * - Better test coverage + * - Mock out the react-scheduler module, not the browser APIs, in renderer + * tests + * - Add fixture test of react-scheduler * - Better docblock * - Polish documentation, API */ @@ -31,6 +34,11 @@ // The frame rate is dynamically adjusted. import type {Deadline} from 'react-reconciler'; +type CallbackConfigType = {| + scheduledCallback: Deadline => void, + timeoutTime: number, + callbackId: number, // used for cancelling +|}; import ExecutionEnvironment from 'fbjs/lib/ExecutionEnvironment'; import warning from 'fbjs/lib/warning'; @@ -87,9 +95,19 @@ if (!ExecutionEnvironment.canUseDOM) { } else { // Always polyfill requestIdleCallback and cancelIdleCallback - let scheduledRICCallback = null; + // Number.MAX_SAFE_INTEGER is not supported in IE + const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER || 9007199254740991; + let callbackIdCounter = 1; + let scheduledCallbackConfig: CallbackConfigType | null = null; + const getCallbackId = function(): number { + callbackIdCounter = + callbackIdCounter >= MAX_SAFE_INTEGER ? 1 : callbackIdCounter + 1; + return callbackIdCounter; + }; let isIdleScheduled = false; - let timeoutTime = -1; + let isCurrentlyRunningCallback = false; + // We keep a queue of pending callbacks + let pendingCallbacks: Array = []; let isAnimationFrameScheduled = false; @@ -100,7 +118,15 @@ if (!ExecutionEnvironment.canUseDOM) { let previousFrameTime = 33; let activeFrameTime = 33; - const frameDeadlineObject = { + // When a callback is scheduled, we register it by adding it's id to this + // object. + // If the user calls 'cIC' with the id of that callback, it will be + // unregistered by removing the id from this object. + // Then we skip calling any callback which is not registered. + // This means cancelling is an O(1) time complexity instead of O(n). + const registeredCallbackIds: Map = new Map(); + + const frameDeadlineObject: Deadline = { didTimeout: false, timeRemaining() { const remaining = frameDeadline - now(); @@ -108,6 +134,26 @@ if (!ExecutionEnvironment.canUseDOM) { }, }; + const safelyCallScheduledCallback = function(callback, callbackId) { + if (!registeredCallbackIds.get(callbackId)) { + // ignore cancelled callbacks + return; + } + isCurrentlyRunningCallback = true; + try { + callback(frameDeadlineObject); + registeredCallbackIds.delete(callbackId); + isCurrentlyRunningCallback = false; + } catch (e) { + registeredCallbackIds.delete(callbackId); + isCurrentlyRunningCallback = false; + // Still throw it, but not in this frame. + setTimeout(() => { + throw e; + }); + } + }; + // We use the postMessage trick to defer idle work until after the repaint. const messageKey = '__reactIdleCallback$' + @@ -119,16 +165,22 @@ if (!ExecutionEnvironment.canUseDOM) { return; } + if (scheduledCallbackConfig === null) { + return; + } + isIdleScheduled = false; const currentTime = now(); + const timeoutTime = scheduledCallbackConfig.timeoutTime; + let didTimeout = false; 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; + didTimeout = true; } else { // No timeout. if (!isAnimationFrameScheduled) { @@ -141,14 +193,15 @@ if (!ExecutionEnvironment.canUseDOM) { } } else { // There's still time left in this idle period. - frameDeadlineObject.didTimeout = false; + didTimeout = false; } - timeoutTime = -1; - const callback = scheduledRICCallback; - scheduledRICCallback = null; - if (callback !== null) { - callback(frameDeadlineObject); + const scheduledCallback = scheduledCallbackConfig.scheduledCallback; + const scheduledCallbackId = scheduledCallbackConfig.callbackId; + scheduledCallbackConfig = null; + if (scheduledCallback !== null && typeof scheduledCallbackId === 'number') { + frameDeadlineObject.didTimeout = didTimeout; + safelyCallScheduledCallback(scheduledCallback, scheduledCallbackId); } }; // Assumes that we have addEventListener in this environment. Might need @@ -190,12 +243,72 @@ if (!ExecutionEnvironment.canUseDOM) { 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; + // Handling multiple callbacks: + // For now we implement the behavior expected when the callbacks are + // serial updates, such that each update relies on the previous ones + // having been called before it runs. + // So we call anything in the queue before the latest callback + + const latestCallbackId = getCallbackId(); + if (scheduledCallbackConfig === null) { + // Set up the next callback config + let timeoutTime = -1; + if (options != null && typeof options.timeout === 'number') { + timeoutTime = now() + options.timeout; + } + scheduledCallbackConfig = { + scheduledCallback: callback, + timeoutTime, + callbackId: latestCallbackId, + }; + registeredCallbackIds.set(latestCallbackId, true); + } else { + // If we have a previous callback config, we call that and then schedule + // the latest callback. + const previouslyScheduledCallbackConfig = scheduledCallbackConfig; + + // Then set up the next callback config + let timeoutTime = -1; + if (options != null && typeof options.timeout === 'number') { + timeoutTime = now() + options.timeout; + } + scheduledCallbackConfig = { + scheduledCallback: callback, + timeoutTime, + callbackId: latestCallbackId, + }; + registeredCallbackIds.set(latestCallbackId, true); + + // If we have previousCallback, call it. This may trigger recursion. + const previousCallbackTimeout: number = + previouslyScheduledCallbackConfig.timeoutTime; + const previousCallback = + previouslyScheduledCallbackConfig.scheduledCallback; + if (isCurrentlyRunningCallback) { + // we are inside a recursive call to rIC + // add this callback to a pending queue and run after we exit + pendingCallbacks.push(previouslyScheduledCallbackConfig); + } else { + const prevCallbackId = previouslyScheduledCallbackConfig.callbackId; + frameDeadlineObject.didTimeout = + previousCallbackTimeout !== -1 && previousCallbackTimeout <= now(); + safelyCallScheduledCallback(previousCallback, prevCallbackId); + while (pendingCallbacks.length) { + // the callback recursively called rIC and new callbacks are pending + const callbackConfig = pendingCallbacks.shift(); + const pendingCallback = callbackConfig.scheduledCallback; + const pendingCallbackTimeout = callbackConfig.timeoutTime; + frameDeadlineObject.didTimeout = + pendingCallbackTimeout !== -1 && pendingCallbackTimeout <= now(); + safelyCallScheduledCallback( + pendingCallback, + callbackConfig.callbackId, + ); + } + } } + + // finally, after clearing previous callbacks, schedule the latest one 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 @@ -204,13 +317,11 @@ if (!ExecutionEnvironment.canUseDOM) { isAnimationFrameScheduled = true; requestAnimationFrame(animationTick); } - return 0; + return latestCallbackId; }; - cIC = function() { - scheduledRICCallback = null; - isIdleScheduled = false; - timeoutTime = -1; + cIC = function(callbackId: number) { + registeredCallbackIds.delete(callbackId); }; } diff --git a/packages/react-scheduler/src/__tests__/ReactScheduler-test.js b/packages/react-scheduler/src/__tests__/ReactScheduler-test.js index 1ed60b0fb31..2b304e0537f 100644 --- a/packages/react-scheduler/src/__tests__/ReactScheduler-test.js +++ b/packages/react-scheduler/src/__tests__/ReactScheduler-test.js @@ -47,10 +47,208 @@ describe('ReactScheduler', () => { rIC(cb); jest.runAllTimers(); expect(cb.mock.calls.length).toBe(1); - // should have ... TODO details on what we expect + // should not have timed out and should include a timeRemaining method expect(cb.mock.calls[0][0].didTimeout).toBe(false); expect(typeof cb.mock.calls[0][0].timeRemaining()).toBe('number'); }); - // TODO: test cIC and now + describe('with multiple callbacks', () => { + it('flushes previous cb when new one is passed', () => { + const {rIC} = ReactScheduler; + const callbackLog = []; + const callbackA = jest.fn(() => callbackLog.push('A')); + const callbackB = jest.fn(() => callbackLog.push('B')); + rIC(callbackA); + // initially waits to call the callback + expect(callbackLog).toEqual([]); + // when second callback is passed, flushes first one + rIC(callbackB); + expect(callbackLog).toEqual(['A']); + // after a delay, calls the latest callback passed + jest.runAllTimers(); + expect(callbackLog).toEqual(['A', 'B']); + // callbackA should not have timed out and should include a timeRemaining method + expect(callbackA.mock.calls[0][0].didTimeout).toBe(false); + expect(typeof callbackA.mock.calls[0][0].timeRemaining()).toBe('number'); + // callbackA should not have timed out and should include a timeRemaining method + expect(callbackB.mock.calls[0][0].didTimeout).toBe(false); + expect(typeof callbackB.mock.calls[0][0].timeRemaining()).toBe('number'); + }); + + it('schedules callbacks in correct order when a callback uses rIC before its own logic', () => { + const {rIC} = ReactScheduler; + const callbackLog = []; + const callbackA = jest.fn(() => { + callbackLog.push('A'); + rIC(callbackC); + }); + const callbackB = jest.fn(() => { + callbackLog.push('B'); + }); + const callbackC = jest.fn(() => { + callbackLog.push('C'); + }); + + rIC(callbackA); + // initially waits to call the callback + expect(callbackLog.length).toBe(0); + // when second callback is passed, flushes first one + // callbackA scheduled callbackC, which flushes callbackB + rIC(callbackB); + expect(callbackLog).toEqual(['A', 'B']); + // after a delay, calls the latest callback passed + jest.runAllTimers(); + expect(callbackLog).toEqual(['A', 'B', 'C']); + }); + + it('schedules callbacks in correct order when callbacks have many nested rIC calls', () => { + const {rIC} = ReactScheduler; + const callbackLog = []; + const callbackA = jest.fn(() => { + callbackLog.push('A'); + rIC(callbackC); + rIC(callbackD); + }); + const callbackB = jest.fn(() => { + callbackLog.push('B'); + rIC(callbackE); + rIC(callbackF); + }); + const callbackC = jest.fn(() => { + callbackLog.push('C'); + }); + const callbackD = jest.fn(() => { + callbackLog.push('D'); + }); + const callbackE = jest.fn(() => { + callbackLog.push('E'); + }); + const callbackF = jest.fn(() => { + callbackLog.push('F'); + }); + + rIC(callbackA); + // initially waits to call the callback + expect(callbackLog.length).toBe(0); + // when second callback is passed, flushes first one + // callbackA scheduled callbackC, which flushes callbackB + rIC(callbackB); + expect(callbackLog).toEqual(['A', 'B', 'C', 'D', 'E']); + // after a delay, calls the latest callback passed + jest.runAllTimers(); + expect(callbackLog).toEqual(['A', 'B', 'C', 'D', 'E', 'F']); + }); + + it('allows each callback finish running before flushing others', () => { + const {rIC} = ReactScheduler; + const callbackLog = []; + const callbackA = jest.fn(() => { + // rIC should wait to flush any more until this callback finishes + rIC(callbackC); + callbackLog.push('A'); + }); + const callbackB = jest.fn(() => callbackLog.push('B')); + const callbackC = jest.fn(() => callbackLog.push('C')); + + rIC(callbackA); + // initially waits to call the callback + expect(callbackLog.length).toBe(0); + // when second callback is passed, flushes first one + // callbackA scheduled callbackC, which flushes callbackB + rIC(callbackB); + expect(callbackLog).toEqual(['A', 'B']); + // after a delay, calls the latest callback passed + jest.runAllTimers(); + expect(callbackLog).toEqual(['A', 'B', 'C']); + }); + + it('schedules callbacks in correct order when they use rIC to schedule themselves', () => { + const {rIC} = ReactScheduler; + const callbackLog = []; + let callbackAIterations = 0; + const callbackA = jest.fn(() => { + if (callbackAIterations < 1) { + rIC(callbackA); + } + callbackLog.push('A' + callbackAIterations); + callbackAIterations++; + }); + const callbackB = jest.fn(() => callbackLog.push('B')); + + rIC(callbackA); + // initially waits to call the callback + expect(callbackLog.length).toBe(0); + // when second callback is passed, flushes first one + // callbackA scheduled callbackA again, which flushes callbackB + rIC(callbackB); + expect(callbackLog).toEqual(['A0', 'B']); + // after a delay, calls the latest callback passed + jest.runAllTimers(); + expect(callbackLog).toEqual(['A0', 'B', 'A1']); + }); + + describe('handling errors', () => { + it('flushes scheduled callbacks even if one throws error', () => { + const {rIC} = ReactScheduler; + const callbackLog = []; + const callbackA = jest.fn(() => { + callbackLog.push('A'); + rIC(callbackC); + throw new Error('dummy error A'); + }); + const callbackB = jest.fn(() => { + callbackLog.push('B'); + }); + const callbackC = jest.fn(() => { + callbackLog.push('C'); + }); + + rIC(callbackA); + // initially waits to call the callback + expect(callbackLog.length).toBe(0); + // when second callback is passed, flushes first one + // callbackA scheduled callbackC, which flushes callbackB + // even when callbackA throws an error, we successfully call callbackB + rIC(callbackB); + expect(callbackLog).toEqual(['A', 'B']); + // after a delay, throws the error and calls the latest callback passed + expect(() => jest.runAllTimers()).toThrowError('dummy error A'); + expect(callbackLog).toEqual(['A', 'B', 'C']); + }); + }); + }); + + describe('cIC', () => { + it('cancels the scheduled callback', () => { + const {rIC, cIC} = ReactScheduler; + const cb = jest.fn(); + const callbackId = rIC(cb); + expect(cb.mock.calls.length).toBe(0); + cIC(callbackId); + jest.runAllTimers(); + expect(cb.mock.calls.length).toBe(0); + }); + + // TODO: this test will be easier to implement once we support deferred + /** + it('when one callback cancels the next one', () => { + const {rIC, cIC} = ReactScheduler; + const cbA = jest.fn(() => { + // How to get the callback id? + cIC(); + }); + const cbB = jest.fn(); + rIC(cbA); + expect(cbA.mock.calls.length).toBe(0); + callbackBId = rIC(cbB); + expect(cbA.mock.calls.length).toBe(1); + expect(cbB.mock.calls.length).toBe(0); + jest.runAllTimers(); + // B should not get called because A cancelled B + expect(cbB.mock.calls.length).toBe(0); + }); + */ + }); + + // TODO: test 'now' });