From 2c9a10061592c93832e23ebf753a2dfb3fcd947b Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 6 Sep 2018 16:43:34 -0700 Subject: [PATCH] Refactor Schedule, remove React-isms Once the API stabilizes, we will move Schedule this into a separate repo. To promote adoption, especially by projects outside the React ecosystem, we'll remove all React-isms from the source and keep it as simple as possible: - No build step. - No static types. - Everything is in a single file. If we end up needing to support multiple targets, like CommonJS and ESM, we can still avoid a build step by maintaining two copies of the same file, but with different exports. This commit also refactors the implementation to split out the DOM- specific parts (essentially a requestIdleCallback polyfill). Aside from the architectural benefits, this also makes it possible to write host- agnostic tests. If/when we publish a version of Schedule that targets other environments, like React Native, we can run these same tests across all implementations. --- .../react-dom/src/__tests__/ReactDOM-test.js | 6 +- .../src/ReactFiberScheduler.js | 2 +- packages/schedule/index.js | 2 - packages/schedule/src/Schedule.js | 687 +++++++++--------- .../src/__tests__/Schedule-test.internal.js | 174 +++++ .../{Schedule-test.js => ScheduleDOM-test.js} | 6 +- 6 files changed, 513 insertions(+), 364 deletions(-) create mode 100644 packages/schedule/src/__tests__/Schedule-test.internal.js rename packages/schedule/src/__tests__/{Schedule-test.js => ScheduleDOM-test.js} (99%) diff --git a/packages/react-dom/src/__tests__/ReactDOM-test.js b/packages/react-dom/src/__tests__/ReactDOM-test.js index d5212c86e28b9..9d946e09f6ac8 100644 --- a/packages/react-dom/src/__tests__/ReactDOM-test.js +++ b/packages/react-dom/src/__tests__/ReactDOM-test.js @@ -455,9 +455,11 @@ describe('ReactDOM', () => { try { delete global.requestAnimationFrame; jest.resetModules(); - expect(() => require('react-dom')).toWarnDev( + spyOnDevAndProd(console, 'error'); + require('react-dom'); + expect(console.error.calls.count()).toEqual(1); + expect(console.error.calls.argsFor(0)[0]).toMatch( "This browser doesn't support requestAnimationFrame.", - {withoutStack: true}, ); } finally { global.requestAnimationFrame = previousRAF; diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index 4a2b43af4c65e..fd07ec8496dbf 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -153,7 +153,7 @@ import { import {Dispatcher} from './ReactFiberDispatcher'; export type Deadline = { - timeRemaining: () => number, + timeRemaining(): number, didTimeout: boolean, }; diff --git a/packages/schedule/index.js b/packages/schedule/index.js index 83ddace3eb63c..4bb42948eb7aa 100644 --- a/packages/schedule/index.js +++ b/packages/schedule/index.js @@ -3,8 +3,6 @@ * * 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'; diff --git a/packages/schedule/src/Schedule.js b/packages/schedule/src/Schedule.js index aff461781c852..f6a5d5a44971c 100644 --- a/packages/schedule/src/Schedule.js +++ b/packages/schedule/src/Schedule.js @@ -3,174 +3,327 @@ * * 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'; +// TODO: Currently there's only a single priority level, Deferred. Need to add +// additional priorities (serial, offscreen). +const Deferred = 0; +const DEFERRED_TIMEOUT = 5000; -/** - * A scheduling library to allow scheduling work with more granular priority and - * control than requestAnimationFrame and requestIdleCallback. - * Current TODO items: - * X- Pull out the scheduleWork polyfill built into React - * X- Initial test coverage - * X- Support for multiple callbacks - * - Support for two priorities; serial and deferred - * - Better test coverage - * - Better docblock - * - Polish documentation, API - */ +// Callbacks are stored as a circular, doubly linked list. +let firstCallbackNode = null; -// 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. +let priorityContext = Deferred; +let isPerformingWork = false; + +let isHostCallbackScheduled = false; + +let timeRemaining; +if (hasNativePerformanceNow) { + 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 = getTimeRemaining() - performance.now(); + return remaining > 0 ? remaining : 0; + }; +} else { + timeRemaining = () => { + // Fallback to Date.now() + const remaining = getTimeRemaining() - Date.now(); + return remaining > 0 ? remaining : 0; + }; +} + +const deadlineObject = { + timeRemaining, + didTimeout: false, +}; + +function ensureHostCallbackIsScheduled(highestPriorityNode) { + if (isPerformingWork) { + // Don't schedule work yet; wait until the next time we yield. + return; + } + const timesOutAt = highestPriorityNode.timesOutAt; + if (!isHostCallbackScheduled) { + isHostCallbackScheduled = true; + } else { + // Cancel the existing work. + cancelCallback(); + } + // Schedule work using the highest priority callback's timeout. + requestCallback(flushWork, timesOutAt); +} + +function computeAbsoluteTimeoutForPriority(currentTime, priority) { + if (priority === Deferred) { + return currentTime + DEFERRED_TIMEOUT; + } + throw new Error('Not yet implemented.'); +} + +function flushCallback(node) { + // This is already true; only assigning to appease Flow. + firstCallbackNode = node; + + // Remove the node from the list before calling the callback. That way the + // list is in a consistent state even if the callback throws. + const next = firstCallbackNode.next; + if (firstCallbackNode === next) { + // This is the last callback in the list. + firstCallbackNode = null; + } else { + const previous = firstCallbackNode.previous; + firstCallbackNode = previous.next = next; + next.previous = previous; + } + + node.next = node.previous = null; + + // Now it's safe to call the callback. + const callback = node.callback; + callback(deadlineObject); +} + +function flushWork(didTimeout) { + isPerformingWork = true; + deadlineObject.didTimeout = didTimeout; + try { + if (firstCallbackNode !== null) { + if (didTimeout) { + // Flush all the timed out callbacks without yielding. + do { + flushCallback(firstCallbackNode); + } while ( + firstCallbackNode !== null && + firstCallbackNode.timesOutAt <= getCurrentTime() + ); + } else { + // Keep flushing callbacks until we run out of time in the frame. + while ( + firstCallbackNode !== null && + getTimeRemaining() - getCurrentTime() > 0 + ) { + flushCallback(firstCallbackNode); + } + } + } + } finally { + isPerformingWork = false; + if (firstCallbackNode !== null) { + // There's still work remaining. Request another callback. + ensureHostCallbackIsScheduled(firstCallbackNode); + } else { + isHostCallbackScheduled = false; + } + } +} -import type {Deadline} from 'react-reconciler/src/ReactFiberScheduler'; -type FrameCallbackType = Deadline => void; -type CallbackConfigType = {| - scheduledCallback: FrameCallbackType, - timeoutTime: number, - next: CallbackConfigType | null, // creating a linked list - prev: CallbackConfigType | null, // creating a linked list -|}; +function unstable_scheduleWork(callback, options) { + const currentTime = getCurrentTime(); -export type CallbackIdType = CallbackConfigType; + let timesOutAt; + if (options !== undefined && options !== null) { + const timeoutOption = options.timeout; + if (timeoutOption !== null && timeoutOption !== undefined) { + // If an explicit timeout is provided, it takes precedence over the + // priority context. + timesOutAt = currentTime + timeoutOption; + } else { + // Compute an absolute timeout using the current priority context. + timesOutAt = computeAbsoluteTimeoutForPriority( + currentTime, + priorityContext, + ); + } + } else { + timesOutAt = computeAbsoluteTimeoutForPriority( + currentTime, + priorityContext, + ); + } + + const newNode = { + callback, + timesOutAt, + next: null, + previous: null, + }; -import {canUseDOM} from 'shared/ExecutionEnvironment'; + // Insert the new callback into the list, sorted by its timeout. + if (firstCallbackNode === null) { + // This is the first callback in the list. + firstCallbackNode = newNode.next = newNode.previous = newNode; + ensureHostCallbackIsScheduled(firstCallbackNode); + } else { + let next = null; + let node = firstCallbackNode; + do { + if (node.timesOutAt > timesOutAt) { + // This callback is lower priority than the new one. + next = node; + break; + } + node = node.next; + } while (node !== firstCallbackNode); + + if (next === null) { + // No lower priority callback was found, which means the new callback is + // the lowest priority callback in the list. + next = firstCallbackNode; + } else if (next === firstCallbackNode) { + // The new callback is the highest priority callback in the list. + firstCallbackNode = newNode; + ensureHostCallbackIsScheduled(firstCallbackNode); + } + + const previous = next.previous; + previous.next = next.previous = newNode; + newNode.next = next; + newNode.previous = previous; + } + + return newNode; +} + +function unstable_cancelScheduledWork(callbackNode: CallbackNode): void { + const next = callbackNode.next; + if (next === null) { + // Already cancelled. + return; + } + + if (next === callbackNode) { + // This is the only scheduled callback. Clear the list. + firstCallbackNode = null; + } else { + // Remove the callback from its position in the list. + if (callbackNode === firstCallbackNode) { + firstCallbackNode = next; + } + const previous = callbackNode.previous; + previous.next = next; + next.previous = previous; + } + + callbackNode.next = callbackNode.previous = null; +} + +// The remaining code is essentially a 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. // We capture a local reference to any global, in case it gets polyfilled after -// this module is initially evaluated. -// We want to be using a consistent implementation. +// this module is initially evaluated. We want to be using a +// consistent implementation. const localDate = Date; -// This initialization code may run even on server environments -// if a component just imports ReactDOM (e.g. for findDOMNode). -// Some environments might not have setTimeout or clearTimeout. -// However, we always expect them to be defined on the client. -// https://github.com/facebook/react/pull/13088 +// This initialization code may run even on server environments if a component +// just imports ReactDOM (e.g. for findDOMNode). Some environments might not +// have setTimeout or clearTimeout. However, we always expect them to be defined +// on the client. https://github.com/facebook/react/pull/13088 const localSetTimeout = - typeof setTimeout === 'function' ? setTimeout : (undefined: any); + typeof setTimeout === 'function' ? setTimeout : undefined; const localClearTimeout = - typeof clearTimeout === 'function' ? clearTimeout : (undefined: any); + typeof clearTimeout === 'function' ? clearTimeout : undefined; -// We don't expect either of these to necessarily be defined, -// but we will error later if they are missing on the client. +// We don't expect either of these to necessarily be defined, but we will error +// later if they are missing on the client. const localRequestAnimationFrame = typeof requestAnimationFrame === 'function' ? requestAnimationFrame - : (undefined: any); + : undefined; const localCancelAnimationFrame = - typeof cancelAnimationFrame === 'function' - ? cancelAnimationFrame - : (undefined: any); + typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : undefined; const hasNativePerformanceNow = typeof performance === 'object' && typeof performance.now === 'function'; -let now; +let getCurrentTime: () => number; + +// requestAnimationFrame does not run when the tab is in the background. If +// we're backgrounded we prefer for that work to happen so that the page +// continues to load in the background. So we also schedule a 'setTimeout' as +// a fallback. +// TODO: Need a better heuristic for backgrounded work. +const ANIMATION_FRAME_TIMEOUT = 100; +let rAFID; +let rAFTimeoutID; +const requestAnimationFrameWithTimeout = callback => { + // schedule rAF and also a setTimeout + rAFID = localRequestAnimationFrame(timestamp => { + // cancel the setTimeout + localClearTimeout(rAFTimeoutID); + callback(timestamp); + }); + rAFTimeoutID = localSetTimeout(() => { + // cancel the requestAnimationFrame + localCancelAnimationFrame(rAFID); + callback(getCurrentTime()); + }, ANIMATION_FRAME_TIMEOUT); +}; + if (hasNativePerformanceNow) { const Performance = performance; - now = function() { + getCurrentTime = function() { return Performance.now(); }; } else { - now = function() { + getCurrentTime = function() { return localDate.now(); }; } -let scheduleWork: ( - callback: FrameCallbackType, - options?: {timeout: number}, -) => CallbackIdType; -let cancelScheduledWork: (callbackId: CallbackIdType) => void; - -if (!canUseDOM) { - const timeoutIds = new Map(); - - scheduleWork = function( - callback: FrameCallbackType, - options?: {timeout: number}, - ): CallbackIdType { - // keeping return type consistent - const callbackConfig = { - scheduledCallback: callback, - timeoutTime: 0, - next: null, - prev: null, - }; - const timeoutId = localSetTimeout(() => { - callback({ - timeRemaining() { - return Infinity; - }, - didTimeout: false, - }); - }); - timeoutIds.set(callback, timeoutId); - return callbackConfig; +let requestCallback; +let cancelCallback; +let getTimeRemaining; + +if (typeof window === 'undefined') { + // If this accidentally gets imported in a non-browser environment, fallback + // to a naive implementation. + let timeoutID = -1; + requestCallback = (callback, absoluteTimeout) => { + timeoutID = setTimeout(callback, 0, true); }; - cancelScheduledWork = function(callbackId: CallbackIdType) { - const callback = callbackId.scheduledCallback; - const timeoutId = timeoutIds.get(callback); - timeoutIds.delete(callbackId); - localClearTimeout(timeoutId); + cancelCallback = () => { + clearTimeout(timeoutID); }; + getTimeRemaining = () => 0; +} else if (window._sched) { + // Dynamic injection, only for testing purposes. + const impl = window._sched; + requestCallback = impl[0]; + cancelCallback = impl[1]; + getTimeRemaining = impl[2]; } else { - if (__DEV__) { - if (typeof console !== 'undefined') { - if (typeof localRequestAnimationFrame !== 'function') { - console.error( - "This browser doesn't support requestAnimationFrame. " + - 'Make sure that you load a ' + - 'polyfill in older browsers. https://fb.me/react-polyfills', - ); - } - if (typeof localCancelAnimationFrame !== 'function') { - console.error( - "This browser doesn't support cancelAnimationFrame. " + - 'Make sure that you load a ' + - 'polyfill in older browsers. https://fb.me/react-polyfills', - ); - } + if (typeof console !== 'undefined') { + if (typeof localRequestAnimationFrame !== 'function') { + console.error( + "This browser doesn't support requestAnimationFrame. " + + 'Make sure that you load a ' + + 'polyfill in older browsers. https://fb.me/react-polyfills', + ); + } + if (typeof localCancelAnimationFrame !== 'function') { + console.error( + "This browser doesn't support cancelAnimationFrame. " + + 'Make sure that you load a ' + + 'polyfill in older browsers. https://fb.me/react-polyfills', + ); } } - let headOfPendingCallbacksLinkedList: CallbackConfigType | null = null; - let tailOfPendingCallbacksLinkedList: CallbackConfigType | null = null; - - // We track what the next soonest timeoutTime is, to be able to quickly tell - // if none of the scheduled callbacks have timed out. - let nextSoonestTimeoutTime = -1; - + let scheduledCallback = null; let isIdleScheduled = false; + let timeoutTime = -1; + let isAnimationFrameScheduled = false; - // requestAnimationFrame does not run when the tab is in the background. - // if we're backgrounded we prefer for that work to happen so that the page - // continues to load in the background. - // so we also schedule a 'setTimeout' as a fallback. - const animationFrameTimeout = 100; - let rafID; - let timeoutID; - const scheduleAnimationFrameWithFallbackSupport = function(callback) { - // schedule rAF and also a setTimeout - rafID = localRequestAnimationFrame(function(timestamp) { - // cancel the setTimeout - localClearTimeout(timeoutID); - callback(timestamp); - }); - timeoutID = localSetTimeout(function() { - // cancel the requestAnimationFrame - localCancelAnimationFrame(rafID); - callback(now()); - }, animationFrameTimeout); - }; + let isPerformingIdleWork = false; let frameDeadline = 0; // We start out assuming that we run at 30fps but then the heuristic tracking @@ -179,101 +332,7 @@ if (!canUseDOM) { let previousFrameTime = 33; let activeFrameTime = 33; - const frameDeadlineObject: Deadline = { - didTimeout: false, - timeRemaining() { - const remaining = frameDeadline - now(); - return remaining > 0 ? remaining : 0; - }, - }; - - /** - * Handles the case where a callback errors: - * - don't catch the error, because this changes debugging behavior - * - do start a new postMessage callback, to call any remaining callbacks, - * - but only if there is an error, so there is not extra overhead. - */ - const callUnsafely = function( - callbackConfig: CallbackConfigType, - arg: Deadline, - ) { - const callback = callbackConfig.scheduledCallback; - let finishedCalling = false; - try { - callback(arg); - finishedCalling = true; - } finally { - // always remove it from linked list - cancelScheduledWork(callbackConfig); - - if (!finishedCalling) { - // an error must have been thrown - isIdleScheduled = true; - window.postMessage(messageKey, '*'); - } - } - }; - - /** - * 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() { - if (headOfPendingCallbacksLinkedList === null) { - return; - } - - const currentTime = now(); - // TODO: this would be more efficient if deferred callbacks are stored in - // min heap. - // Or in a linked list with links for both timeoutTime order and insertion - // order. - // For now an easy compromise is the current approach: - // Keep a pointer to the soonest timeoutTime, and check that first. - // If it has not expired, we can skip traversing the whole list. - // If it has expired, then we step through all the callbacks. - if (nextSoonestTimeoutTime === -1 || nextSoonestTimeoutTime > currentTime) { - // We know that none of them have timed out yet. - return; - } - // NOTE: we intentionally wait to update the nextSoonestTimeoutTime until - // after successfully calling any timed out callbacks. - // If a timed out callback throws an error, we could get stuck in a state - // where the nextSoonestTimeoutTime was set wrong. - let updatedNextSoonestTimeoutTime = -1; // we will update nextSoonestTimeoutTime below - const timedOutCallbacks = []; - - // iterate once to find timed out callbacks and find nextSoonestTimeoutTime - let currentCallbackConfig = headOfPendingCallbacksLinkedList; - while (currentCallbackConfig !== null) { - const timeoutTime = currentCallbackConfig.timeoutTime; - if (timeoutTime !== -1 && timeoutTime <= currentTime) { - // it has timed out! - timedOutCallbacks.push(currentCallbackConfig); - } else { - if ( - timeoutTime !== -1 && - (updatedNextSoonestTimeoutTime === -1 || - timeoutTime < updatedNextSoonestTimeoutTime) - ) { - updatedNextSoonestTimeoutTime = timeoutTime; - } - } - currentCallbackConfig = currentCallbackConfig.next; - } - - if (timedOutCallbacks.length > 0) { - frameDeadlineObject.didTimeout = true; - for (let i = 0, len = timedOutCallbacks.length; i < len; i++) { - callUnsafely(timedOutCallbacks[i], frameDeadlineObject); - } - } - - // NOTE: we intentionally wait to update the nextSoonestTimeoutTime until - // after successfully calling any timed out callbacks. - nextSoonestTimeoutTime = updatedNextSoonestTimeoutTime; - }; + getTimeRemaining = () => frameDeadline; // We use the postMessage trick to defer idle work until after the repaint. const messageKey = @@ -281,36 +340,44 @@ if (!canUseDOM) { Math.random() .toString(36) .slice(2); - const idleTick = function(event) { + const idleTick = event => { if (event.source !== window || event.data !== messageKey) { return; } - isIdleScheduled = false; - if (headOfPendingCallbacksLinkedList === null) { - return; - } + isIdleScheduled = false; - // First call anything which has timed out, until we have caught up. - callTimedOutCallbacks(); + const currentTime = getCurrentTime(); - let currentTime = now(); - // Next, as long as we have idle time, try calling more callbacks. - while ( - frameDeadline - currentTime > 0 && - headOfPendingCallbacksLinkedList !== null - ) { - const latestCallbackConfig = headOfPendingCallbacksLinkedList; - frameDeadlineObject.didTimeout = false; - // callUnsafely will remove it from the head of the linked list - callUnsafely(latestCallbackConfig, frameDeadlineObject); - currentTime = now(); + 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. + didTimeout = true; + } else { + // No timeout. + if (!isAnimationFrameScheduled) { + // Schedule another animation callback so we retry later. + isAnimationFrameScheduled = true; + requestAnimationFrameWithTimeout(animationTick); + } + // Exit without invoking the callback. + return; + } } - if (headOfPendingCallbacksLinkedList !== null) { - if (!isAnimationFrameScheduled) { - // Schedule another animation callback so we retry later. - isAnimationFrameScheduled = true; - scheduleAnimationFrameWithFallbackSupport(animationTick); + + timeoutTime = -1; + const callback = scheduledCallback; + scheduledCallback = null; + if (callback !== null) { + isPerformingIdleWork = true; + try { + callback(didTimeout); + } finally { + isPerformingIdleWork = false; } } }; @@ -318,7 +385,7 @@ if (!canUseDOM) { // something better for old IE. window.addEventListener('message', idleTick, false); - const animationTick = function(rafTime) { + const animationTick = rafTime => { isAnimationFrameScheduled = false; let nextFrameTime = rafTime - frameDeadline + activeFrameTime; if ( @@ -349,124 +416,32 @@ if (!canUseDOM) { } }; - scheduleWork = function( - callback: FrameCallbackType, - options?: {timeout: number}, - ): CallbackIdType /* CallbackConfigType */ { - let timeoutTime = -1; - if (options != null && typeof options.timeout === 'number') { - timeoutTime = now() + options.timeout; - } - if ( - nextSoonestTimeoutTime === -1 || - (timeoutTime !== -1 && timeoutTime < nextSoonestTimeoutTime) - ) { - nextSoonestTimeoutTime = timeoutTime; - } - - const scheduledCallbackConfig: CallbackConfigType = { - scheduledCallback: callback, - timeoutTime, - prev: null, - next: null, - }; - if (headOfPendingCallbacksLinkedList === null) { - // Make this callback the head and tail of our list - headOfPendingCallbacksLinkedList = scheduledCallbackConfig; - tailOfPendingCallbacksLinkedList = scheduledCallbackConfig; - } else { - // Add latest callback as the new tail of the list - scheduledCallbackConfig.prev = tailOfPendingCallbacksLinkedList; - // renaming for clarity - const oldTailOfPendingCallbacksLinkedList = tailOfPendingCallbacksLinkedList; - if (oldTailOfPendingCallbacksLinkedList !== null) { - oldTailOfPendingCallbacksLinkedList.next = scheduledCallbackConfig; - } - tailOfPendingCallbacksLinkedList = scheduledCallbackConfig; - } - - if (!isAnimationFrameScheduled) { + requestCallback = (callback, absoluteTimeout) => { + scheduledCallback = callback; + timeoutTime = absoluteTimeout; + if (isPerformingIdleWork) { + // If we're already performing idle work, an error must have been thrown. + // Don't wait for the next frame. Continue working ASAP, in a new event. + window.postMessage(messageKey, '*'); + } else 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 scheduleWork as a backup to ensure + // might want to still have setTimeout trigger rIC as a backup to ensure // that we keep performing work. isAnimationFrameScheduled = true; - scheduleAnimationFrameWithFallbackSupport(animationTick); + requestAnimationFrameWithTimeout(animationTick); } - return scheduledCallbackConfig; }; - cancelScheduledWork = function( - callbackConfig: CallbackIdType /* CallbackConfigType */, - ) { - if ( - callbackConfig.prev === null && - headOfPendingCallbacksLinkedList !== callbackConfig - ) { - // this callbackConfig has already been cancelled. - // cancelScheduledWork should be idempotent, a no-op after first call. - return; - } - - /** - * There are four possible cases: - * - Head/nodeToRemove/Tail -> null - * In this case we set Head and Tail to null. - * - Head -> ... middle nodes... -> Tail/nodeToRemove - * In this case we point the middle.next to null and put middle as the new - * Tail. - * - Head/nodeToRemove -> ...middle nodes... -> Tail - * In this case we point the middle.prev at null and move the Head to - * middle. - * - Head -> ... ?some nodes ... -> nodeToRemove -> ... ?some nodes ... -> Tail - * In this case we point the Head.next to the Tail and the Tail.prev to - * the Head. - */ - const next = callbackConfig.next; - const prev = callbackConfig.prev; - callbackConfig.next = null; - callbackConfig.prev = null; - if (next !== null) { - // we have a next - - if (prev !== null) { - // we have a prev - - // callbackConfig is somewhere in the middle of a list of 3 or more nodes. - prev.next = next; - next.prev = prev; - return; - } else { - // there is a next but not a previous one; - // callbackConfig is the head of a list of 2 or more other nodes. - next.prev = null; - headOfPendingCallbacksLinkedList = next; - return; - } - } else { - // there is no next callback config; this must the tail of the list - - if (prev !== null) { - // we have a prev - - // callbackConfig is the tail of a list of 2 or more other nodes. - prev.next = null; - tailOfPendingCallbacksLinkedList = prev; - return; - } else { - // there is no previous callback config; - // callbackConfig is the only thing in the linked list, - // so both head and tail point to it. - headOfPendingCallbacksLinkedList = null; - tailOfPendingCallbacksLinkedList = null; - return; - } - } + cancelCallback = function() { + scheduledCallback = null; + isIdleScheduled = false; + timeoutTime = -1; }; } export { - now as unstable_now, - scheduleWork as unstable_scheduleWork, - cancelScheduledWork as unstable_cancelScheduledWork, + unstable_scheduleWork, + unstable_cancelScheduledWork, + getCurrentTime as unstable_now, }; diff --git a/packages/schedule/src/__tests__/Schedule-test.internal.js b/packages/schedule/src/__tests__/Schedule-test.internal.js new file mode 100644 index 0000000000000..58bcc4216c410 --- /dev/null +++ b/packages/schedule/src/__tests__/Schedule-test.internal.js @@ -0,0 +1,174 @@ +/** + * 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 scheduleWork; +let cancelScheduledWork; +let flushWork; +let advanceTime; +let doWork; +let yieldedValues; +let yieldValue; +let clearYieldedValues; + +describe('Schedule', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.resetModules(); + + let _flushWork = null; + let timeoutID = -1; + let endOfFrame = -1; + + let currentTime = 0; + + flushWork = frameSize => { + if (frameSize === null || frameSize === undefined) { + frameSize = Infinity; + } + if (_flushWork === null) { + throw new Error('No work is scheduled.'); + } + timeoutID = -1; + endOfFrame = currentTime + frameSize; + try { + _flushWork(); + } finally { + endOfFrame = -1; + } + const yields = yieldedValues; + yieldedValues = []; + return yields; + }; + + advanceTime = ms => { + currentTime += ms; + jest.advanceTimersByTime(ms); + }; + + doWork = (label, timeCost) => { + advanceTime(timeCost); + yieldValue(label); + }; + + yieldedValues = []; + yieldValue = value => { + yieldedValues.push(value); + }; + + clearYieldedValues = () => { + const yields = yieldedValues; + yieldedValues = []; + return yields; + }; + + function requestCallback(fw, absoluteTimeout) { + if (_flushWork !== null) { + throw new Error('Work is already scheduled.'); + } + _flushWork = fw; + timeoutID = setTimeout(() => { + _flushWork(true); + }, absoluteTimeout - currentTime); + } + function cancelCallback() { + if (_flushWork === null) { + throw new Error('No work is scheduled.'); + } + _flushWork = null; + clearTimeout(timeoutID); + } + function getTimeRemaining() { + return endOfFrame; + } + + // Override host implementation + delete global.performance; + global.Date.now = () => currentTime; + window._sched = [requestCallback, cancelCallback, getTimeRemaining]; + + const Schedule = require('schedule'); + scheduleWork = Schedule.unstable_scheduleWork; + cancelScheduledWork = Schedule.unstable_cancelScheduledWork; + }); + + it('flushes work incrementally', () => { + scheduleWork(() => doWork('A', 100)); + scheduleWork(() => doWork('B', 200)); + scheduleWork(() => doWork('C', 300)); + scheduleWork(() => doWork('D', 400)); + + expect(flushWork(300)).toEqual(['A', 'B']); + expect(flushWork(300)).toEqual(['C']); + expect(flushWork(400)).toEqual(['D']); + }); + + it('cancels work', () => { + scheduleWork(() => doWork('A', 100)); + const callbackHandleB = scheduleWork(() => doWork('B', 200)); + scheduleWork(() => doWork('C', 300)); + + cancelScheduledWork(callbackHandleB); + + expect(flushWork()).toEqual([ + 'A', + // B should have been cancelled + 'C', + ]); + }); + + it('prioritizes callbacks according to their timeouts', () => { + scheduleWork(() => doWork('A', 10), {timeout: 5000}); + scheduleWork(() => doWork('B', 20), {timeout: 5000}); + scheduleWork(() => doWork('C', 30), {timeout: 1000}); + scheduleWork(() => doWork('D', 40), {timeout: 5000}); + + // C should be first because it has the earliest timeout + expect(flushWork()).toEqual(['C', 'A', 'B', 'D']); + }); + + it('times out work', () => { + scheduleWork(() => doWork('A', 100), {timeout: 5000}); + scheduleWork(() => doWork('B', 200), {timeout: 5000}); + scheduleWork(() => doWork('C', 300), {timeout: 1000}); + scheduleWork(() => doWork('D', 400), {timeout: 5000}); + + // Advance time, but not by enough to flush any work + advanceTime(999); + expect(clearYieldedValues()).toEqual([]); + + // Advance by just a bit more to flush C + advanceTime(1); + expect(clearYieldedValues()).toEqual(['C']); + + // Flush the rest + advanceTime(4000); + expect(clearYieldedValues()).toEqual(['A', 'B', 'D']); + }); + + it('has a default timeout of 5 seconds', () => { + scheduleWork(() => doWork('A', 100)); + scheduleWork(() => doWork('B', 200)); + scheduleWork(() => doWork('C', 300), {timeout: 1000}); + scheduleWork(() => doWork('D', 400)); + + // Flush C + advanceTime(1000); + expect(clearYieldedValues()).toEqual(['C']); + + // Advance time until right before the rest of the work expires + advanceTime(3699); + expect(clearYieldedValues()).toEqual([]); + + // Now advance by just a bit more + advanceTime(1); + expect(clearYieldedValues()).toEqual(['A', 'B', 'D']); + }); +}); diff --git a/packages/schedule/src/__tests__/Schedule-test.js b/packages/schedule/src/__tests__/ScheduleDOM-test.js similarity index 99% rename from packages/schedule/src/__tests__/Schedule-test.js rename to packages/schedule/src/__tests__/ScheduleDOM-test.js index fedf1f27b19f0..131024ef7f35b 100644 --- a/packages/schedule/src/__tests__/Schedule-test.js +++ b/packages/schedule/src/__tests__/ScheduleDOM-test.js @@ -16,7 +16,7 @@ type FrameTimeoutConfigType = { timePastFrameDeadline: ?number, }; -describe('Schedule', () => { +describe('ScheduleDOM', () => { let rAFCallbacks = []; let postMessageCallback; let postMessageEvents = []; @@ -309,10 +309,10 @@ describe('Schedule', () => { const callbackC = jest.fn(() => callbackLog.push('C')); const callbackD = jest.fn(() => callbackLog.push('D')); - scheduleWork(callbackA); // won't time out + scheduleWork(callbackA, {timeout: 100}); // won't time out scheduleWork(callbackB, {timeout: 100}); // times out later scheduleWork(callbackC, {timeout: 2}); // will time out fast - scheduleWork(callbackD); // won't time out + scheduleWork(callbackD, {timeout: 200}); // won't time out advanceOneFrame({timeLeftInFrame: 15}); // runs rAF and postMessage callbacks