diff --git a/packages/react-art/src/ReactART.js b/packages/react-art/src/ReactART.js index 04478434e8b..e1e9ac8563c 100644 --- a/packages/react-art/src/ReactART.js +++ b/packages/react-art/src/ReactART.js @@ -468,7 +468,7 @@ const ARTRenderer = ReactFiberReconciler({ return emptyObject; }, - scheduleDeferredCallback: ReactScheduler.rIC, + scheduleDeferredCallback: ReactScheduler.scheduleSerialCallback, shouldSetTextContent(type, props) { return ( diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 82032f1d1d2..8be7e4ef103 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -984,8 +984,8 @@ const DOMRenderer = ReactFiberReconciler({ }, }, - scheduleDeferredCallback: ReactScheduler.rIC, - cancelDeferredCallback: ReactScheduler.cIC, + scheduleDeferredCallback: ReactScheduler.scheduleSerialCallback, + cancelDeferredCallback: ReactScheduler.cancelSerialCallback, }); ReactGenericBatching.injection.injectRenderer(DOMRenderer); diff --git a/packages/react-scheduler/src/ReactScheduler.js b/packages/react-scheduler/src/ReactScheduler.js index b15e5f4428e..175e1e47985 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 - * - Support for two priorities; serial and deferred + * X- Initial test coverage + * X- Support for multiple callbacks + * X- 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 */ @@ -63,13 +66,17 @@ if (hasNativePerformanceNow) { } // TODO: There's no way to cancel, because Fiber doesn't atm. -let rIC: ( +let scheduleSerialCallback: ( callback: (deadline: Deadline, options?: {timeout: number}) => void, ) => number; -let cIC: (callbackID: number) => void; +let scheduleDeferredCallback: ( + callback: (deadline: Deadline, options?: {timeout: number}) => void, +) => number; +let cancelSerialCallback: (callbackID: number) => void; +let cancelDeferredCallback: (callback: Function) => void; if (!ExecutionEnvironment.canUseDOM) { - rIC = function( + scheduleSerialCallback = function( frameCallback: (deadline: Deadline, options?: {timeout: number}) => void, ): number { return setTimeout(() => { @@ -81,15 +88,19 @@ if (!ExecutionEnvironment.canUseDOM) { }); }); }; - cIC = function(timeoutID: number) { + cancelSerialCallback = function(timeoutID: number) { clearTimeout(timeoutID); }; } else { // Always polyfill requestIdleCallback and cancelIdleCallback - let scheduledRICCallback = null; + let scheduledSerialCallback = null; let isIdleScheduled = false; let timeoutTime = -1; + let isCurrentlyRunningCallback = false; + // We may need to keep queues of pending callbacks + let pendingSerialCallbacks = []; + let pendingDeferredCallbacks = []; let isAnimationFrameScheduled = false; @@ -108,6 +119,59 @@ if (!ExecutionEnvironment.canUseDOM) { }, }; + /** + * Checks for timed out callbacks, runs them, and then checks again to see if + * any more have timed out. + * Keeps doing this until there are none which have currently timed out. + */ + const callTimedOutCallbacks = function() { + // TODO: this would be more efficient if deferred callbacks are stored in + // min heap. + let foundTimedOutCallback = false; + + // keep checking until we don't find any more timed out callbacks + do { + const currentTime = now(); + foundTimedOutCallback = false; + // run serial callback if it has timed out + if (scheduledSerialCallback !== null) { + if (timeoutTime !== -1 && timeoutTime <= currentTime) { + foundTimedOutCallback = true; + const currentCallback = scheduledSerialCallback; + timeoutTime = -1; + scheduledSerialCallback = null; + frameDeadlineObject.didTimeout = true; + isCurrentlyRunningCallback = true; + currentCallback(frameDeadlineObject); + isCurrentlyRunningCallback = false; + } + } + if (pendingDeferredCallbacks.length > 0) { + // check if any have timed out, and if so + // run them and remove from pendingDeferredCallbacks + for (let i = 0, len = pendingDeferredCallbacks.length; i < len; i++) { + const { + deferredCallback, + deferredCallbackTimeoutTime, + } = pendingDeferredCallbacks[i]; + if ( + deferredCallbackTimeoutTime !== -1 && + deferredCallbackTimeoutTime <= currentTime + ) { + foundTimedOutCallback = true; + pendingDeferredCallbacks.splice(i, 1); // remove this callback + i--; + len--; // compensate for mutating array we are traversing + frameDeadlineObject.didTimeout = true; + isCurrentlyRunningCallback = true; + deferredCallback(frameDeadlineObject); + isCurrentlyRunningCallback = false; + } + } + } + } while (foundTimedOutCallback); + }; + // We use the postMessage trick to defer idle work until after the repaint. const messageKey = '__reactIdleCallback$' + @@ -121,36 +185,45 @@ if (!ExecutionEnvironment.canUseDOM) { 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); + let keepRunningCallbacks = true; + + while (keepRunningCallbacks) { + // call any timed out callbacks, until none left have timed out. + callTimedOutCallbacks(); + + // check if we have any idle time, and if so call some callbacks + const currentTime = now(); + const idleTimeLeft = frameDeadline - currentTime > 0; + if (idleTimeLeft) { + // call the serial callback first if there is one + let nextCallback = scheduledSerialCallback; + timeoutTime = -1; + scheduledSerialCallback = null; + if (nextCallback === null) { + // if no serial callback was scheduled, run a deferred callback + nextCallback = pendingDeferredCallbacks.pop(); } - // Exit without invoking the callback. - return; - } - } else { - // There's still time left in this idle period. - frameDeadlineObject.didTimeout = false; - } + if (nextCallback) { + frameDeadlineObject.didTimeout = false; + isCurrentlyRunningCallback = true; + nextCallback(frameDeadlineObject); + isCurrentlyRunningCallback = false; + } else { + // There are no more scheduled callbacks. + // Our work here is done. + keepRunningCallbacks = false; + } + } else { + // No idle time left in this frame. + // Schedule another animation callback so we retry later. + isAnimationFrameScheduled = true; + requestAnimationFrame(animationTick); - timeoutTime = -1; - const callback = scheduledRICCallback; - scheduledRICCallback = null; - if (callback !== null) { - callback(frameDeadlineObject); + keepRunningCallbacks = false; + } } }; + // Assumes that we have addEventListener in this environment. Might need // something better for old IE. window.addEventListener('message', idleTick, false); @@ -186,32 +259,113 @@ if (!ExecutionEnvironment.canUseDOM) { } }; - rIC = function( + /** + * This method is similar to requestIdleCallback. 'Deferred' callbacks will + * be called after the 'serial' priority callbacks have been cleared, with + * additional priority given to callbacks which are past their timeout. + */ + scheduleDeferredCallback = 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; + const deferredCallbackTimeoutTime = + options && typeof options.timeout === 'number' ? options.timeout : -1; + pendingDeferredCallbacks.push({ + deferredCallback: callback, + deferredCallbackTimeoutTime, + }); + }; + + /** + * 'Serial' callbacks are distinct from regular callbacks because they rely on + * all previous 'serial' callbacks having been evaluated. + * For example: If I click 'submit' and then quickly click 'submit' again. The + * first click should disable the 'submit' button, and we can't process the + * second click until that first click has been processed. + */ + scheduleSerialCallback = function( + callback: (deadline: Deadline) => void, + options?: {timeout: number}, + ): number { + let previousCallback; + let timeoutTimeFromPreviousCallback; + if (scheduledSerialCallback !== null) { + // If we have previous callback, save it and handle it below + timeoutTimeFromPreviousCallback = timeoutTime; + previousCallback = scheduledSerialCallback; + } + // Then set up the next callback, and update timeoutTime + scheduledSerialCallback = callback; if (options != null && typeof options.timeout === 'number') { timeoutTime = now() + options.timeout; + } else { + timeoutTime = -1; } + // If we have previousCallback, call it. This may trigger recursion. + if ( + previousCallback && + typeof timeoutTimeFromPreviousCallback === 'number' + ) { + const prevCallbackTimeout: number = timeoutTimeFromPreviousCallback; + if (isCurrentlyRunningCallback) { + // we are inside a recursive call to scheduleSerialCallback + // add this callback to a pending queue and run after we exit + pendingSerialCallbacks.push({ + pendingCallback: previousCallback, + pendingCallbackTimeout: prevCallbackTimeout, + }); + } else { + frameDeadlineObject.didTimeout = + timeoutTimeFromPreviousCallback !== -1 && + timeoutTimeFromPreviousCallback <= now(); + isCurrentlyRunningCallback = true; + previousCallback(frameDeadlineObject); + isCurrentlyRunningCallback = false; + while (pendingSerialCallbacks.length) { + // the callback recursively called scheduleSerialCallback + // and new callbacks are pending + const { + pendingCallback, + pendingCallbackTimeout, + } = pendingSerialCallbacks.shift(); + // TODO: pull this into helper method + frameDeadlineObject.didTimeout = + pendingCallbackTimeout !== -1 && pendingCallbackTimeout <= now(); + isCurrentlyRunningCallback = true; + pendingCallback(frameDeadlineObject); + isCurrentlyRunningCallback = false; + } + } + } + + // 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 - // might want to still have setTimeout trigger rIC as a backup to ensure - // that we keep performing work. + // might want to still have setTimeout trigger scheduleSerialCallback as a + // backup to ensure that we keep performing work. isAnimationFrameScheduled = true; - requestAnimationFrame(animationTick); + return requestAnimationFrame(animationTick); } return 0; }; - cIC = function() { - scheduledRICCallback = null; + cancelSerialCallback = function() { isIdleScheduled = false; + scheduledSerialCallback = null; timeoutTime = -1; }; + + cancelDeferredCallback = function(callback) { + const index = pendingDeferredCallbacks.indexOf(callback); + pendingDeferredCallbacks.splice(index, 1); + }; } -export {now, rIC, cIC}; +export { + now, + scheduleSerialCallback, + cancelSerialCallback, + scheduleDeferredCallback, + cancelDeferredCallback, +}; diff --git a/packages/react-scheduler/src/__tests__/ReactScheduler-test.js b/packages/react-scheduler/src/__tests__/ReactScheduler-test.js index 1ed60b0fb31..cd96ff11395 100644 --- a/packages/react-scheduler/src/__tests__/ReactScheduler-test.js +++ b/packages/react-scheduler/src/__tests__/ReactScheduler-test.js @@ -41,16 +41,210 @@ describe('ReactScheduler', () => { ReactScheduler = require('react-scheduler'); }); - it('rIC calls the callback within the frame when not blocked', () => { - const {rIC} = ReactScheduler; - const cb = jest.fn(); - rIC(cb); - jest.runAllTimers(); - expect(cb.mock.calls.length).toBe(1); - // should have ... TODO details on what we expect - expect(cb.mock.calls[0][0].didTimeout).toBe(false); - expect(typeof cb.mock.calls[0][0].timeRemaining()).toBe('number'); + describe('scheduleSerialCallback', () => { + it('calls the callback within the frame when not blocked', () => { + const {scheduleSerialCallback} = ReactScheduler; + const cb = jest.fn(); + scheduleSerialCallback(cb); + jest.runAllTimers(); + expect(cb.mock.calls.length).toBe(1); + // 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'); + }); + + describe('with multiple callbacks', () => { + it('flushes previous cb when new one is passed', () => { + const {scheduleSerialCallback} = ReactScheduler; + const callbackLog = []; + const callbackA = jest.fn(() => callbackLog.push('A')); + const callbackB = jest.fn(() => callbackLog.push('B')); + scheduleSerialCallback(callbackA); + // initially waits to call the callback + expect(callbackLog.length).toBe(0); + // when second callback is passed, flushes first one + scheduleSerialCallback(callbackB); + expect(callbackLog.length).toBe(1); + expect(callbackLog[0]).toBe('A'); + // after a delay, calls the latest callback passed + jest.runAllTimers(); + expect(callbackLog.length).toBe(2); + expect(callbackLog[0]).toBe('A'); + expect(callbackLog[1]).toBe('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 scheduleSerialCallback before its own logic', () => { + const {scheduleSerialCallback} = ReactScheduler; + const callbackLog = []; + const callbackA = jest.fn(() => { + callbackLog.push('A'); + scheduleSerialCallback(callbackC); + }); + const callbackB = jest.fn(() => { + callbackLog.push('B'); + }); + const callbackC = jest.fn(() => { + callbackLog.push('C'); + }); + + scheduleSerialCallback(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 + scheduleSerialCallback(callbackB); + expect(callbackLog.length).toBe(2); + expect(callbackLog[0]).toBe('A'); + expect(callbackLog[1]).toBe('B'); + // after a delay, calls the latest callback passed + jest.runAllTimers(); + expect(callbackLog.length).toBe(3); + expect(callbackLog[0]).toBe('A'); + expect(callbackLog[1]).toBe('B'); + expect(callbackLog[2]).toBe('C'); + }); + + it('schedules callbacks in correct order when callbacks have many nested scheduleSerialCallback calls', () => { + const {scheduleSerialCallback} = ReactScheduler; + const callbackLog = []; + const callbackA = jest.fn(() => { + callbackLog.push('A'); + scheduleSerialCallback(callbackC); + scheduleSerialCallback(callbackD); + }); + const callbackB = jest.fn(() => { + callbackLog.push('B'); + scheduleSerialCallback(callbackE); + scheduleSerialCallback(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'); + }); + + scheduleSerialCallback(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 + scheduleSerialCallback(callbackB); + expect(callbackLog.length).toBe(5); + expect(callbackLog[0]).toBe('A'); + expect(callbackLog[1]).toBe('B'); + expect(callbackLog[2]).toBe('C'); + expect(callbackLog[3]).toBe('D'); + expect(callbackLog[4]).toBe('E'); + // after a delay, calls the latest callback passed + jest.runAllTimers(); + expect(callbackLog.length).toBe(6); + expect(callbackLog[5]).toBe('F'); + }); + + it('allows each callback finish running before flushing others', () => { + const {scheduleSerialCallback} = ReactScheduler; + const callbackLog = []; + const callbackA = jest.fn(() => { + // scheduleSerialCallback should wait to flush any more until this callback finishes + scheduleSerialCallback(callbackC); + callbackLog.push('A'); + }); + const callbackB = jest.fn(() => callbackLog.push('B')); + const callbackC = jest.fn(() => callbackLog.push('C')); + + scheduleSerialCallback(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 + scheduleSerialCallback(callbackB); + expect(callbackLog.length).toBe(2); + expect(callbackLog[0]).toBe('A'); + expect(callbackLog[1]).toBe('B'); + // after a delay, calls the latest callback passed + jest.runAllTimers(); + expect(callbackLog.length).toBe(3); + expect(callbackLog[0]).toBe('A'); + expect(callbackLog[1]).toBe('B'); + expect(callbackLog[2]).toBe('C'); + }); + + it('schedules callbacks in correct order when they use scheduleSerialCallback to schedule themselves', () => { + const {scheduleSerialCallback} = ReactScheduler; + const callbackLog = []; + let callbackAIterations = 0; + const callbackA = jest.fn(() => { + if (callbackAIterations < 1) { + scheduleSerialCallback(callbackA); + } + callbackLog.push('A' + callbackAIterations); + callbackAIterations++; + }); + const callbackB = jest.fn(() => callbackLog.push('B')); + + scheduleSerialCallback(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 + scheduleSerialCallback(callbackB); + expect(callbackLog.length).toBe(2); + expect(callbackLog[0]).toBe('A0'); + expect(callbackLog[1]).toBe('B'); + // after a delay, calls the latest callback passed + jest.runAllTimers(); + expect(callbackLog.length).toBe(3); + expect(callbackLog[0]).toBe('A0'); + expect(callbackLog[1]).toBe('B'); + expect(callbackLog[2]).toBe('A1'); + }); + }); + }); + + describe('cancelSerialCallback', () => { + it('cancels the scheduled callback', () => { + const {scheduleSerialCallback, cancelSerialCallback} = ReactScheduler; + const cb = jest.fn(); + scheduleSerialCallback(cb); + expect(cb.mock.calls.length).toBe(0); + cancelSerialCallback(); + jest.runAllTimers(); + expect(cb.mock.calls.length).toBe(0); + }); + + it('when one callback cancels the next one', () => { + const {scheduleSerialCallback, cancelSerialCallback} = ReactScheduler; + const cbA = jest.fn(() => { + cancelSerialCallback(); + }); + const cbB = jest.fn(); + scheduleSerialCallback(cbA); + expect(cbA.mock.calls.length).toBe(0); + scheduleSerialCallback(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 cIC and now + // TODO: test schedule.now });