@samitouri / QOS-React-2 / commits / 9f819a5ea9

[schedule] Refactor Schedule, remove React-isms (#13582)

* 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. * Edits in response to Dan's PR feedback

Andrew Clark committed Sep 14, 2018 at 14:05 UTC 9f819a5ea9fe54b370d6eafb042480dfb2dfb974
7 files changed +532 -381
packages/react-dom/src/__tests__/ReactDOM-test.js
+4 -2
@@ -455,9 +455,11 @@ describe('ReactDOM', () => {
455 try {
456 delete global.requestAnimationFrame;
457 jest.resetModules();
458 - expect(() => require('react-dom')).toWarnDev(
458 + spyOnDevAndProd(console, 'error');
459 + require('react-dom');
460 + expect(console.error.calls.count()).toEqual(1);
461 + expect(console.error.calls.argsFor(0)[0]).toMatch(
462 "This browser doesn't support requestAnimationFrame.",
460 - {withoutStack: true},
463 );
464 } finally {
465 global.requestAnimationFrame = previousRAF;
packages/react-reconciler/src/ReactFiberScheduler.js
+1 -1
@@ -153,7 +153,7 @@ import {
153 import {Dispatcher} from './ReactFiberDispatcher';
154
155 export type Deadline = {
156 - timeRemaining: () => number,
156 + timeRemaining(): number,
157 didTimeout: boolean,
158 };
159
packages/schedule/index.js
-2
@@ -3,8 +3,6 @@
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 - *
7 - * @flow
6 */
7
8 'use strict';
packages/schedule/src/Schedule.js
+346 -371
@@ -4,313 +4,380 @@
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 - * @flow
7 */
8
10 -'use strict';
9 +/* eslint-disable no-var */
10
12 -/**
13 - * A scheduling library to allow scheduling work with more granular priority and
14 - * control than requestAnimationFrame and requestIdleCallback.
15 - * Current TODO items:
16 - * X- Pull out the scheduleWork polyfill built into React
17 - * X- Initial test coverage
18 - * X- Support for multiple callbacks
19 - * - Support for two priorities; serial and deferred
20 - * - Better test coverage
21 - * - Better docblock
22 - * - Polish documentation, API
23 - */
24 -
25 -// This is a built-in polyfill for requestIdleCallback. It works by scheduling
26 -// a requestAnimationFrame, storing the time for the start of the frame, then
27 -// scheduling a postMessage which gets scheduled after paint. Within the
28 -// postMessage handler do as much work as possible until time + frame rate.
29 -// By separating the idle call into a separate event tick we ensure that
30 -// layout, paint and other browser work is counted against the available time.
31 -// The frame rate is dynamically adjusted.
32 -
33 -import type {Deadline} from 'react-reconciler/src/ReactFiberScheduler';
34 -type FrameCallbackType = Deadline => void;
35 -type CallbackConfigType = {|
36 - scheduledCallback: FrameCallbackType,
37 - timeoutTime: number,
38 - next: CallbackConfigType | null, // creating a linked list
39 - prev: CallbackConfigType | null, // creating a linked list
40 -|};
11 +// TODO: Currently there's only a single priority level, Deferred. Will add
12 +// additional priorities.
13 +var DEFERRED_TIMEOUT = 5000;
14
42 -export type CallbackIdType = CallbackConfigType;
15 +// Callbacks are stored as a circular, doubly linked list.
16 +var firstCallbackNode = null;
17
44 -import {canUseDOM} from 'shared/ExecutionEnvironment';
18 +var isPerformingWork = false;
19
46 -// We capture a local reference to any global, in case it gets polyfilled after
47 -// this module is initially evaluated.
48 -// We want to be using a consistent implementation.
49 -const localDate = Date;
50 -
51 -// This initialization code may run even on server environments
52 -// if a component just imports ReactDOM (e.g. for findDOMNode).
53 -// Some environments might not have setTimeout or clearTimeout.
54 -// However, we always expect them to be defined on the client.
55 -// https://github.com/facebook/react/pull/13088
56 -const localSetTimeout =
57 - typeof setTimeout === 'function' ? setTimeout : (undefined: any);
58 -const localClearTimeout =
59 - typeof clearTimeout === 'function' ? clearTimeout : (undefined: any);
60 -
61 -// We don't expect either of these to necessarily be defined,
62 -// but we will error later if they are missing on the client.
63 -const localRequestAnimationFrame =
64 - typeof requestAnimationFrame === 'function'
65 - ? requestAnimationFrame
66 - : (undefined: any);
67 -const localCancelAnimationFrame =
68 - typeof cancelAnimationFrame === 'function'
69 - ? cancelAnimationFrame
70 - : (undefined: any);
20 +var isHostCallbackScheduled = false;
21
72 -const hasNativePerformanceNow =
22 +var hasNativePerformanceNow =
23 typeof performance === 'object' && typeof performance.now === 'function';
24
75 -let now;
25 +var timeRemaining;
26 if (hasNativePerformanceNow) {
77 - const Performance = performance;
78 - now = function() {
79 - return Performance.now();
27 + timeRemaining = function() {
28 + // We assume that if we have a performance timer that the rAF callback
29 + // gets a performance timer value. Not sure if this is always true.
30 + var remaining = getFrameDeadline() - performance.now();
31 + return remaining > 0 ? remaining : 0;
32 };
33 } else {
82 - now = function() {
83 - return localDate.now();
34 + timeRemaining = function() {
35 + // Fallback to Date.now()
36 + var remaining = getFrameDeadline() - Date.now();
37 + return remaining > 0 ? remaining : 0;
38 };
39 }
40
87 -let scheduleWork: (
88 - callback: FrameCallbackType,
89 - options?: {timeout: number},
90 -) => CallbackIdType;
91 -let cancelScheduledWork: (callbackId: CallbackIdType) => void;
92 -
93 -if (!canUseDOM) {
94 - const timeoutIds = new Map();
95 -
96 - scheduleWork = function(
97 - callback: FrameCallbackType,
98 - options?: {timeout: number},
99 - ): CallbackIdType {
100 - // keeping return type consistent
101 - const callbackConfig = {
102 - scheduledCallback: callback,
103 - timeoutTime: 0,
104 - next: null,
105 - prev: null,
106 - };
107 - const timeoutId = localSetTimeout(() => {
108 - callback({
109 - timeRemaining() {
110 - return Infinity;
111 - },
112 - didTimeout: false,
113 - });
114 - });
115 - timeoutIds.set(callback, timeoutId);
116 - return callbackConfig;
117 - };
118 - cancelScheduledWork = function(callbackId: CallbackIdType) {
119 - const callback = callbackId.scheduledCallback;
120 - const timeoutId = timeoutIds.get(callback);
121 - timeoutIds.delete(callbackId);
122 - localClearTimeout(timeoutId);
123 - };
124 -} else {
125 - if (__DEV__) {
126 - if (typeof console !== 'undefined') {
127 - if (typeof localRequestAnimationFrame !== 'function') {
128 - console.error(
129 - "This browser doesn't support requestAnimationFrame. " +
130 - 'Make sure that you load a ' +
131 - 'polyfill in older browsers. https://fb.me/react-polyfills',
132 - );
41 +var deadlineObject = {
42 + timeRemaining,
43 + didTimeout: false,
44 +};
45 +
46 +function ensureHostCallbackIsScheduled() {
47 + if (isPerformingWork) {
48 + // Don't schedule work yet; wait until the next time we yield.
49 + return;
50 + }
51 + // Schedule the host callback using the earliest timeout in the list.
52 + var timesOutAt = firstCallbackNode.timesOutAt;
53 + if (!isHostCallbackScheduled) {
54 + isHostCallbackScheduled = true;
55 + } else {
56 + // Cancel the existing host callback.
57 + cancelCallback();
58 + }
59 + requestCallback(flushWork, timesOutAt);
60 +}
61 +
62 +function flushFirstCallback(node) {
63 + var flushedNode = firstCallbackNode;
64 +
65 + // Remove the node from the list before calling the callback. That way the
66 + // list is in a consistent state even if the callback throws.
67 + var next = firstCallbackNode.next;
68 + if (firstCallbackNode === next) {
69 + // This is the last callback in the list.
70 + firstCallbackNode = null;
71 + next = null;
72 + } else {
73 + var previous = firstCallbackNode.previous;
74 + firstCallbackNode = previous.next = next;
75 + next.previous = previous;
76 + }
77 +
78 + flushedNode.next = flushedNode.previous = null;
79 +
80 + // Now it's safe to call the callback.
81 + var callback = flushedNode.callback;
82 + callback(deadlineObject);
83 +}
84 +
85 +function flushWork(didTimeout) {
86 + isPerformingWork = true;
87 + deadlineObject.didTimeout = didTimeout;
88 + try {
89 + if (didTimeout) {
90 + // Flush all the timed out callbacks without yielding.
91 + while (firstCallbackNode !== null) {
92 + // Read the current time. Flush all the callbacks that expire at or
93 + // earlier than that time. Then read the current time again and repeat.
94 + // This optimizes for as few performance.now calls as possible.
95 + var currentTime = getCurrentTime();
96 + if (firstCallbackNode.timesOutAt <= currentTime) {
97 + do {
98 + flushFirstCallback();
99 + } while (
100 + firstCallbackNode !== null &&
101 + firstCallbackNode.timesOutAt <= currentTime
102 + );
103 + continue;
104 + }
105 + break;
106 }
134 - if (typeof localCancelAnimationFrame !== 'function') {
135 - console.error(
136 - "This browser doesn't support cancelAnimationFrame. " +
137 - 'Make sure that you load a ' +
138 - 'polyfill in older browsers. https://fb.me/react-polyfills',
107 + } else {
108 + // Keep flushing callbacks until we run out of time in the frame.
109 + if (firstCallbackNode !== null) {
110 + do {
111 + flushFirstCallback();
112 + } while (
113 + firstCallbackNode !== null &&
114 + getFrameDeadline() - getCurrentTime() > 0
115 );
116 }
117 }
118 + } finally {
119 + isPerformingWork = false;
120 + if (firstCallbackNode !== null) {
121 + // There's still work remaining. Request another callback.
122 + ensureHostCallbackIsScheduled(firstCallbackNode);
123 + } else {
124 + isHostCallbackScheduled = false;
125 + }
126 }
127 +}
128
144 - let headOfPendingCallbacksLinkedList: CallbackConfigType | null = null;
145 - let tailOfPendingCallbacksLinkedList: CallbackConfigType | null = null;
146 -
147 - // We track what the next soonest timeoutTime is, to be able to quickly tell
148 - // if none of the scheduled callbacks have timed out.
149 - let nextSoonestTimeoutTime = -1;
150 -
151 - let isIdleScheduled = false;
152 - let isAnimationFrameScheduled = false;
153 -
154 - // requestAnimationFrame does not run when the tab is in the background.
155 - // if we're backgrounded we prefer for that work to happen so that the page
156 - // continues to load in the background.
157 - // so we also schedule a 'setTimeout' as a fallback.
158 - const animationFrameTimeout = 100;
159 - let rafID;
160 - let timeoutID;
161 - const scheduleAnimationFrameWithFallbackSupport = function(callback) {
162 - // schedule rAF and also a setTimeout
163 - rafID = localRequestAnimationFrame(function(timestamp) {
164 - // cancel the setTimeout
165 - localClearTimeout(timeoutID);
166 - callback(timestamp);
167 - });
168 - timeoutID = localSetTimeout(function() {
169 - // cancel the requestAnimationFrame
170 - localCancelAnimationFrame(rafID);
171 - callback(now());
172 - }, animationFrameTimeout);
173 - };
129 +function unstable_scheduleWork(callback, options) {
130 + var currentTime = getCurrentTime();
131
175 - let frameDeadline = 0;
176 - // We start out assuming that we run at 30fps but then the heuristic tracking
177 - // will adjust this value to a faster fps if we get more frequent animation
178 - // frames.
179 - let previousFrameTime = 33;
180 - let activeFrameTime = 33;
181 -
182 - const frameDeadlineObject: Deadline = {
183 - didTimeout: false,
184 - timeRemaining() {
185 - const remaining = frameDeadline - now();
186 - return remaining > 0 ? remaining : 0;
187 - },
132 + var timesOutAt;
133 + if (
134 + options !== undefined &&
135 + options !== null &&
136 + options.timeout !== null &&
137 + options.timeout !== undefined
138 + ) {
139 + // Check for an explicit timeout
140 + timesOutAt = currentTime + options.timeout;
141 + } else {
142 + // Compute an absolute timeout using the default constant.
143 + timesOutAt = currentTime + DEFERRED_TIMEOUT;
144 + }
145 +
146 + var newNode = {
147 + callback,
148 + timesOutAt,
149 + next: null,
150 + previous: null,
151 };
152
190 - /**
191 - * Handles the case where a callback errors:
192 - * - don't catch the error, because this changes debugging behavior
193 - * - do start a new postMessage callback, to call any remaining callbacks,
194 - * - but only if there is an error, so there is not extra overhead.
195 - */
196 - const callUnsafely = function(
197 - callbackConfig: CallbackConfigType,
198 - arg: Deadline,
199 - ) {
200 - const callback = callbackConfig.scheduledCallback;
201 - let finishedCalling = false;
202 - try {
203 - callback(arg);
204 - finishedCalling = true;
205 - } finally {
206 - // always remove it from linked list
207 - cancelScheduledWork(callbackConfig);
208 -
209 - if (!finishedCalling) {
210 - // an error must have been thrown
211 - isIdleScheduled = true;
212 - window.postMessage(messageKey, '*');
153 + // Insert the new callback into the list, sorted by its timeout.
154 + if (firstCallbackNode === null) {
155 + // This is the first callback in the list.
156 + firstCallbackNode = newNode.next = newNode.previous = newNode;
157 + ensureHostCallbackIsScheduled(firstCallbackNode);
158 + } else {
159 + var next = null;
160 + var node = firstCallbackNode;
161 + do {
162 + if (node.timesOutAt > timesOutAt) {
163 + // The new callback times out before this one.
164 + next = node;
165 + break;
166 }
167 + node = node.next;
168 + } while (node !== firstCallbackNode);
169 +
170 + if (next === null) {
171 + // No callback with a later timeout was found, which means the new
172 + // callback has the latest timeout in the list.
173 + next = firstCallbackNode;
174 + } else if (next === firstCallbackNode) {
175 + // The new callback has the earliest timeout in the entire list.
176 + firstCallbackNode = newNode;
177 + ensureHostCallbackIsScheduled(firstCallbackNode);
178 }
215 - };
179
217 - /**
218 - * Checks for timed out callbacks, runs them, and then checks again to see if
219 - * any more have timed out.
220 - * Keeps doing this until there are none which have currently timed out.
221 - */
222 - const callTimedOutCallbacks = function() {
223 - if (headOfPendingCallbacksLinkedList === null) {
224 - return;
180 + var previous = next.previous;
181 + previous.next = next.previous = newNode;
182 + newNode.next = next;
183 + newNode.previous = previous;
184 + }
185 +
186 + return newNode;
187 +}
188 +
189 +function unstable_cancelScheduledWork(callbackNode: CallbackNode): void {
190 + var next = callbackNode.next;
191 + if (next === null) {
192 + // Already cancelled.
193 + return;
194 + }
195 +
196 + if (next === callbackNode) {
197 + // This is the only scheduled callback. Clear the list.
198 + firstCallbackNode = null;
199 + } else {
200 + // Remove the callback from its position in the list.
201 + if (callbackNode === firstCallbackNode) {
202 + firstCallbackNode = next;
203 }
204 + var previous = callbackNode.previous;
205 + previous.next = next;
206 + next.previous = previous;
207 + }
208
227 - const currentTime = now();
228 - // TODO: this would be more efficient if deferred callbacks are stored in
229 - // min heap.
230 - // Or in a linked list with links for both timeoutTime order and insertion
231 - // order.
232 - // For now an easy compromise is the current approach:
233 - // Keep a pointer to the soonest timeoutTime, and check that first.
234 - // If it has not expired, we can skip traversing the whole list.
235 - // If it has expired, then we step through all the callbacks.
236 - if (nextSoonestTimeoutTime === -1 || nextSoonestTimeoutTime > currentTime) {
237 - // We know that none of them have timed out yet.
238 - return;
209 + callbackNode.next = callbackNode.previous = null;
210 +}
211 +
212 +// The remaining code is essentially a polyfill for requestIdleCallback. It
213 +// works by scheduling a requestAnimationFrame, storing the time for the start
214 +// of the frame, then scheduling a postMessage which gets scheduled after paint.
215 +// Within the postMessage handler do as much work as possible until time + frame
216 +// rate. By separating the idle call into a separate event tick we ensure that
217 +// layout, paint and other browser work is counted against the available time.
218 +// The frame rate is dynamically adjusted.
219 +
220 +// We capture a local reference to any global, in case it gets polyfilled after
221 +// this module is initially evaluated. We want to be using a
222 +// consistent implementation.
223 +var localDate = Date;
224 +
225 +// This initialization code may run even on server environments if a component
226 +// just imports ReactDOM (e.g. for findDOMNode). Some environments might not
227 +// have setTimeout or clearTimeout. However, we always expect them to be defined
228 +// on the client. https://github.com/facebook/react/pull/13088
229 +var localSetTimeout = typeof setTimeout === 'function' ? setTimeout : undefined;
230 +var localClearTimeout =
231 + typeof clearTimeout === 'function' ? clearTimeout : undefined;
232 +
233 +// We don't expect either of these to necessarily be defined, but we will error
234 +// later if they are missing on the client.
235 +var localRequestAnimationFrame =
236 + typeof requestAnimationFrame === 'function'
237 + ? requestAnimationFrame
238 + : undefined;
239 +var localCancelAnimationFrame =
240 + typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : undefined;
241 +
242 +var getCurrentTime;
243 +
244 +// requestAnimationFrame does not run when the tab is in the background. If
245 +// we're backgrounded we prefer for that work to happen so that the page
246 +// continues to load in the background. So we also schedule a 'setTimeout' as
247 +// a fallback.
248 +// TODO: Need a better heuristic for backgrounded work.
249 +var ANIMATION_FRAME_TIMEOUT = 100;
250 +var rAFID;
251 +var rAFTimeoutID;
252 +var requestAnimationFrameWithTimeout = function(callback) {
253 + // schedule rAF and also a setTimeout
254 + rAFID = localRequestAnimationFrame(function(timestamp) {
255 + // cancel the setTimeout
256 + localClearTimeout(rAFTimeoutID);
257 + callback(timestamp);
258 + });
259 + rAFTimeoutID = localSetTimeout(function() {
260 + // cancel the requestAnimationFrame
261 + localCancelAnimationFrame(rAFID);
262 + callback(getCurrentTime());
263 + }, ANIMATION_FRAME_TIMEOUT);
264 +};
265 +
266 +if (hasNativePerformanceNow) {
267 + var Performance = performance;
268 + getCurrentTime = function() {
269 + return Performance.now();
270 + };
271 +} else {
272 + getCurrentTime = function() {
273 + return localDate.now();
274 + };
275 +}
276 +
277 +var requestCallback;
278 +var cancelCallback;
279 +var getFrameDeadline;
280 +
281 +if (typeof window === 'undefined') {
282 + // If this accidentally gets imported in a non-browser environment, fallback
283 + // to a naive implementation.
284 + var timeoutID = -1;
285 + requestCallback = function(callback, absoluteTimeout) {
286 + timeoutID = setTimeout(callback, 0, true);
287 + };
288 + cancelCallback = function() {
289 + clearTimeout(timeoutID);
290 + };
291 + getFrameDeadline = function() {
292 + return 0;
293 + };
294 +} else if (window._schedMock) {
295 + // Dynamic injection, only for testing purposes.
296 + var impl = window._schedMock;
297 + requestCallback = impl[0];
298 + cancelCallback = impl[1];
299 + getFrameDeadline = impl[2];
300 +} else {
301 + if (typeof console !== 'undefined') {
302 + if (typeof localRequestAnimationFrame !== 'function') {
303 + console.error(
304 + "This browser doesn't support requestAnimationFrame. " +
305 + 'Make sure that you load a ' +
306 + 'polyfill in older browsers. https://fb.me/react-polyfills',
307 + );
308 }
240 - // NOTE: we intentionally wait to update the nextSoonestTimeoutTime until
241 - // after successfully calling any timed out callbacks.
242 - // If a timed out callback throws an error, we could get stuck in a state
243 - // where the nextSoonestTimeoutTime was set wrong.
244 - let updatedNextSoonestTimeoutTime = -1; // we will update nextSoonestTimeoutTime below
245 - const timedOutCallbacks = [];
246 -
247 - // iterate once to find timed out callbacks and find nextSoonestTimeoutTime
248 - let currentCallbackConfig = headOfPendingCallbacksLinkedList;
249 - while (currentCallbackConfig !== null) {
250 - const timeoutTime = currentCallbackConfig.timeoutTime;
251 - if (timeoutTime !== -1 && timeoutTime <= currentTime) {
252 - // it has timed out!
253 - timedOutCallbacks.push(currentCallbackConfig);
254 - } else {
255 - if (
256 - timeoutTime !== -1 &&
257 - (updatedNextSoonestTimeoutTime === -1 ||
258 - timeoutTime < updatedNextSoonestTimeoutTime)
259 - ) {
260 - updatedNextSoonestTimeoutTime = timeoutTime;
261 - }
262 - }
263 - currentCallbackConfig = currentCallbackConfig.next;
309 + if (typeof localCancelAnimationFrame !== 'function') {
310 + console.error(
311 + "This browser doesn't support cancelAnimationFrame. " +
312 + 'Make sure that you load a ' +
313 + 'polyfill in older browsers. https://fb.me/react-polyfills',
314 + );
315 }
316 + }
317
266 - if (timedOutCallbacks.length > 0) {
267 - frameDeadlineObject.didTimeout = true;
268 - for (let i = 0, len = timedOutCallbacks.length; i < len; i++) {
269 - callUnsafely(timedOutCallbacks[i], frameDeadlineObject);
270 - }
271 - }
318 + var scheduledCallback = null;
319 + var isIdleScheduled = false;
320 + var timeoutTime = -1;
321
273 - // NOTE: we intentionally wait to update the nextSoonestTimeoutTime until
274 - // after successfully calling any timed out callbacks.
275 - nextSoonestTimeoutTime = updatedNextSoonestTimeoutTime;
322 + var isAnimationFrameScheduled = false;
323 +
324 + var isPerformingIdleWork = false;
325 +
326 + var frameDeadline = 0;
327 + // We start out assuming that we run at 30fps but then the heuristic tracking
328 + // will adjust this value to a faster fps if we get more frequent animation
329 + // frames.
330 + var previousFrameTime = 33;
331 + var activeFrameTime = 33;
332 +
333 + getFrameDeadline = function() {
334 + return frameDeadline;
335 };
336
337 // We use the postMessage trick to defer idle work until after the repaint.
279 - const messageKey =
338 + var messageKey =
339 '__reactIdleCallback$' +
340 Math.random()
341 .toString(36)
342 .slice(2);
284 - const idleTick = function(event) {
343 + var idleTick = function(event) {
344 if (event.source !== window || event.data !== messageKey) {
345 return;
346 }
288 - isIdleScheduled = false;
347
290 - if (headOfPendingCallbacksLinkedList === null) {
291 - return;
292 - }
348 + isIdleScheduled = false;
349
294 - // First call anything which has timed out, until we have caught up.
295 - callTimedOutCallbacks();
350 + var currentTime = getCurrentTime();
351
297 - let currentTime = now();
298 - // Next, as long as we have idle time, try calling more callbacks.
299 - while (
300 - frameDeadline - currentTime > 0 &&
301 - headOfPendingCallbacksLinkedList !== null
302 - ) {
303 - const latestCallbackConfig = headOfPendingCallbacksLinkedList;
304 - frameDeadlineObject.didTimeout = false;
305 - // callUnsafely will remove it from the head of the linked list
306 - callUnsafely(latestCallbackConfig, frameDeadlineObject);
307 - currentTime = now();
352 + var didTimeout = false;
353 + if (frameDeadline - currentTime <= 0) {
354 + // There's no time left in this idle period. Check if the callback has
355 + // a timeout and whether it's been exceeded.
356 + if (timeoutTime !== -1 && timeoutTime <= currentTime) {
357 + // Exceeded the timeout. Invoke the callback even though there's no
358 + // time left.
359 + didTimeout = true;
360 + } else {
361 + // No timeout.
362 + if (!isAnimationFrameScheduled) {
363 + // Schedule another animation callback so we retry later.
364 + isAnimationFrameScheduled = true;
365 + requestAnimationFrameWithTimeout(animationTick);
366 + }
367 + // Exit without invoking the callback.
368 + return;
369 + }
370 }
309 - if (headOfPendingCallbacksLinkedList !== null) {
310 - if (!isAnimationFrameScheduled) {
311 - // Schedule another animation callback so we retry later.
312 - isAnimationFrameScheduled = true;
313 - scheduleAnimationFrameWithFallbackSupport(animationTick);
371 +
372 + timeoutTime = -1;
373 + var callback = scheduledCallback;
374 + scheduledCallback = null;
375 + if (callback !== null) {
376 + isPerformingIdleWork = true;
377 + try {
378 + callback(didTimeout);
379 + } finally {
380 + isPerformingIdleWork = false;
381 }
382 }
383 };
@@ -318,9 +385,9 @@ if (!canUseDOM) {
385 // something better for old IE.
386 window.addEventListener('message', idleTick, false);
387
321 - const animationTick = function(rafTime) {
388 + var animationTick = function(rafTime) {
389 isAnimationFrameScheduled = false;
323 - let nextFrameTime = rafTime - frameDeadline + activeFrameTime;
390 + var nextFrameTime = rafTime - frameDeadline + activeFrameTime;
391 if (
392 nextFrameTime < activeFrameTime &&
393 previousFrameTime < activeFrameTime
@@ -349,124 +416,32 @@ if (!canUseDOM) {
416 }
417 };
418
352 - scheduleWork = function(
353 - callback: FrameCallbackType,
354 - options?: {timeout: number},
355 - ): CallbackIdType /* CallbackConfigType */ {
356 - let timeoutTime = -1;
357 - if (options != null && typeof options.timeout === 'number') {
358 - timeoutTime = now() + options.timeout;
359 - }
360 - if (
361 - nextSoonestTimeoutTime === -1 ||
362 - (timeoutTime !== -1 && timeoutTime < nextSoonestTimeoutTime)
363 - ) {
364 - nextSoonestTimeoutTime = timeoutTime;
365 - }
366 -
367 - const scheduledCallbackConfig: CallbackConfigType = {
368 - scheduledCallback: callback,
369 - timeoutTime,
370 - prev: null,
371 - next: null,
372 - };
373 - if (headOfPendingCallbacksLinkedList === null) {
374 - // Make this callback the head and tail of our list
375 - headOfPendingCallbacksLinkedList = scheduledCallbackConfig;
376 - tailOfPendingCallbacksLinkedList = scheduledCallbackConfig;
377 - } else {
378 - // Add latest callback as the new tail of the list
379 - scheduledCallbackConfig.prev = tailOfPendingCallbacksLinkedList;
380 - // renaming for clarity
381 - const oldTailOfPendingCallbacksLinkedList = tailOfPendingCallbacksLinkedList;
382 - if (oldTailOfPendingCallbacksLinkedList !== null) {
383 - oldTailOfPendingCallbacksLinkedList.next = scheduledCallbackConfig;
384 - }
385 - tailOfPendingCallbacksLinkedList = scheduledCallbackConfig;
386 - }
387 -
388 - if (!isAnimationFrameScheduled) {
419 + requestCallback = function(callback, absoluteTimeout) {
420 + scheduledCallback = callback;
421 + timeoutTime = absoluteTimeout;
422 + if (isPerformingIdleWork) {
423 + // If we're already performing idle work, an error must have been thrown.
424 + // Don't wait for the next frame. Continue working ASAP, in a new event.
425 + window.postMessage(messageKey, '*');
426 + } else if (!isAnimationFrameScheduled) {
427 // If rAF didn't already schedule one, we need to schedule a frame.
428 // TODO: If this rAF doesn't materialize because the browser throttles, we
391 - // might want to still have setTimeout trigger scheduleWork as a backup to ensure
429 + // might want to still have setTimeout trigger rIC as a backup to ensure
430 // that we keep performing work.
431 isAnimationFrameScheduled = true;
394 - scheduleAnimationFrameWithFallbackSupport(animationTick);
432 + requestAnimationFrameWithTimeout(animationTick);
433 }
396 - return scheduledCallbackConfig;
434 };
435
399 - cancelScheduledWork = function(
400 - callbackConfig: CallbackIdType /* CallbackConfigType */,
401 - ) {
402 - if (
403 - callbackConfig.prev === null &&
404 - headOfPendingCallbacksLinkedList !== callbackConfig
405 - ) {
406 - // this callbackConfig has already been cancelled.
407 - // cancelScheduledWork should be idempotent, a no-op after first call.
408 - return;
409 - }
410 -
411 - /**
412 - * There are four possible cases:
413 - * - Head/nodeToRemove/Tail -> null
414 - * In this case we set Head and Tail to null.
415 - * - Head -> ... middle nodes... -> Tail/nodeToRemove
416 - * In this case we point the middle.next to null and put middle as the new
417 - * Tail.
418 - * - Head/nodeToRemove -> ...middle nodes... -> Tail
419 - * In this case we point the middle.prev at null and move the Head to
420 - * middle.
421 - * - Head -> ... ?some nodes ... -> nodeToRemove -> ... ?some nodes ... -> Tail
422 - * In this case we point the Head.next to the Tail and the Tail.prev to
423 - * the Head.
424 - */
425 - const next = callbackConfig.next;
426 - const prev = callbackConfig.prev;
427 - callbackConfig.next = null;
428 - callbackConfig.prev = null;
429 - if (next !== null) {
430 - // we have a next
431 -
432 - if (prev !== null) {
433 - // we have a prev
434 -
435 - // callbackConfig is somewhere in the middle of a list of 3 or more nodes.
436 - prev.next = next;
437 - next.prev = prev;
438 - return;
439 - } else {
440 - // there is a next but not a previous one;
441 - // callbackConfig is the head of a list of 2 or more other nodes.
442 - next.prev = null;
443 - headOfPendingCallbacksLinkedList = next;
444 - return;
445 - }
446 - } else {
447 - // there is no next callback config; this must the tail of the list
448 -
449 - if (prev !== null) {
450 - // we have a prev
451 -
452 - // callbackConfig is the tail of a list of 2 or more other nodes.
453 - prev.next = null;
454 - tailOfPendingCallbacksLinkedList = prev;
455 - return;
456 - } else {
457 - // there is no previous callback config;
458 - // callbackConfig is the only thing in the linked list,
459 - // so both head and tail point to it.
460 - headOfPendingCallbacksLinkedList = null;
461 - tailOfPendingCallbacksLinkedList = null;
462 - return;
463 - }
464 - }
436 + cancelCallback = function() {
437 + scheduledCallback = null;
438 + isIdleScheduled = false;
439 + timeoutTime = -1;
440 };
441 }
442
443 export {
469 - now as unstable_now,
470 - scheduleWork as unstable_scheduleWork,
471 - cancelScheduledWork as unstable_cancelScheduledWork,
444 + unstable_scheduleWork,
445 + unstable_cancelScheduledWork,
446 + getCurrentTime as unstable_now,
447 };
packages/schedule/src/__tests__/Schedule-test.internal.js new
+174
@@ -0,0 +1,174 @@
1 +/**
2 + * Copyright (c) 2013-present, Facebook, Inc.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +let scheduleWork;
13 +let cancelScheduledWork;
14 +let flushWork;
15 +let advanceTime;
16 +let doWork;
17 +let yieldedValues;
18 +let yieldValue;
19 +let clearYieldedValues;
20 +
21 +describe('Schedule', () => {
22 + beforeEach(() => {
23 + jest.useFakeTimers();
24 + jest.resetModules();
25 +
26 + let _flushWork = null;
27 + let timeoutID = -1;
28 + let endOfFrame = -1;
29 +
30 + let currentTime = 0;
31 +
32 + flushWork = frameSize => {
33 + if (frameSize === null || frameSize === undefined) {
34 + frameSize = Infinity;
35 + }
36 + if (_flushWork === null) {
37 + throw new Error('No work is scheduled.');
38 + }
39 + timeoutID = -1;
40 + endOfFrame = currentTime + frameSize;
41 + try {
42 + _flushWork();
43 + } finally {
44 + endOfFrame = -1;
45 + }
46 + const yields = yieldedValues;
47 + yieldedValues = [];
48 + return yields;
49 + };
50 +
51 + advanceTime = ms => {
52 + currentTime += ms;
53 + jest.advanceTimersByTime(ms);
54 + };
55 +
56 + doWork = (label, timeCost) => {
57 + advanceTime(timeCost);
58 + yieldValue(label);
59 + };
60 +
61 + yieldedValues = [];
62 + yieldValue = value => {
63 + yieldedValues.push(value);
64 + };
65 +
66 + clearYieldedValues = () => {
67 + const yields = yieldedValues;
68 + yieldedValues = [];
69 + return yields;
70 + };
71 +
72 + function requestCallback(fw, absoluteTimeout) {
73 + if (_flushWork !== null) {
74 + throw new Error('Work is already scheduled.');
75 + }
76 + _flushWork = fw;
77 + timeoutID = setTimeout(() => {
78 + _flushWork(true);
79 + }, absoluteTimeout - currentTime);
80 + }
81 + function cancelCallback() {
82 + if (_flushWork === null) {
83 + throw new Error('No work is scheduled.');
84 + }
85 + _flushWork = null;
86 + clearTimeout(timeoutID);
87 + }
88 + function getTimeRemaining() {
89 + return endOfFrame;
90 + }
91 +
92 + // Override host implementation
93 + delete global.performance;
94 + global.Date.now = () => currentTime;
95 + window._schedMock = [requestCallback, cancelCallback, getTimeRemaining];
96 +
97 + const Schedule = require('schedule');
98 + scheduleWork = Schedule.unstable_scheduleWork;
99 + cancelScheduledWork = Schedule.unstable_cancelScheduledWork;
100 + });
101 +
102 + it('flushes work incrementally', () => {
103 + scheduleWork(() => doWork('A', 100));
104 + scheduleWork(() => doWork('B', 200));
105 + scheduleWork(() => doWork('C', 300));
106 + scheduleWork(() => doWork('D', 400));
107 +
108 + expect(flushWork(300)).toEqual(['A', 'B']);
109 + expect(flushWork(300)).toEqual(['C']);
110 + expect(flushWork(400)).toEqual(['D']);
111 + });
112 +
113 + it('cancels work', () => {
114 + scheduleWork(() => doWork('A', 100));
115 + const callbackHandleB = scheduleWork(() => doWork('B', 200));
116 + scheduleWork(() => doWork('C', 300));
117 +
118 + cancelScheduledWork(callbackHandleB);
119 +
120 + expect(flushWork()).toEqual([
121 + 'A',
122 + // B should have been cancelled
123 + 'C',
124 + ]);
125 + });
126 +
127 + it('prioritizes callbacks according to their timeouts', () => {
128 + scheduleWork(() => doWork('A', 10), {timeout: 5000});
129 + scheduleWork(() => doWork('B', 20), {timeout: 5000});
130 + scheduleWork(() => doWork('C', 30), {timeout: 1000});
131 + scheduleWork(() => doWork('D', 40), {timeout: 5000});
132 +
133 + // C should be first because it has the earliest timeout
134 + expect(flushWork()).toEqual(['C', 'A', 'B', 'D']);
135 + });
136 +
137 + it('times out work', () => {
138 + scheduleWork(() => doWork('A', 100), {timeout: 5000});
139 + scheduleWork(() => doWork('B', 200), {timeout: 5000});
140 + scheduleWork(() => doWork('C', 300), {timeout: 1000});
141 + scheduleWork(() => doWork('D', 400), {timeout: 5000});
142 +
143 + // Advance time, but not by enough to flush any work
144 + advanceTime(999);
145 + expect(clearYieldedValues()).toEqual([]);
146 +
147 + // Advance by just a bit more to flush C
148 + advanceTime(1);
149 + expect(clearYieldedValues()).toEqual(['C']);
150 +
151 + // Flush the rest
152 + advanceTime(4000);
153 + expect(clearYieldedValues()).toEqual(['A', 'B', 'D']);
154 + });
155 +
156 + it('has a default timeout of 5 seconds', () => {
157 + scheduleWork(() => doWork('A', 100));
158 + scheduleWork(() => doWork('B', 200));
159 + scheduleWork(() => doWork('C', 300), {timeout: 1000});
160 + scheduleWork(() => doWork('D', 400));
161 +
162 + // Flush C
163 + advanceTime(1000);
164 + expect(clearYieldedValues()).toEqual(['C']);
165 +
166 + // Advance time until right before the rest of the work expires
167 + advanceTime(3699);
168 + expect(clearYieldedValues()).toEqual([]);
169 +
170 + // Now advance by just a bit more
171 + advanceTime(1);
172 + expect(clearYieldedValues()).toEqual(['A', 'B', 'D']);
173 + });
174 +});
packages/schedule/src/__tests__/ScheduleDOM-test.js renamed
+3 -3
@@ -16,7 +16,7 @@ type FrameTimeoutConfigType = {
16 timePastFrameDeadline: ?number,
17 };
18
19 -describe('Schedule', () => {
19 +describe('ScheduleDOM', () => {
20 let rAFCallbacks = [];
21 let postMessageCallback;
22 let postMessageEvents = [];
@@ -309,10 +309,10 @@ describe('Schedule', () => {
309 const callbackC = jest.fn(() => callbackLog.push('C'));
310 const callbackD = jest.fn(() => callbackLog.push('D'));
311
312 - scheduleWork(callbackA); // won't time out
312 + scheduleWork(callbackA, {timeout: 100}); // won't time out
313 scheduleWork(callbackB, {timeout: 100}); // times out later
314 scheduleWork(callbackC, {timeout: 2}); // will time out fast
315 - scheduleWork(callbackD); // won't time out
315 + scheduleWork(callbackD, {timeout: 200}); // won't time out
316
317 advanceOneFrame({timeLeftInFrame: 15}); // runs rAF and postMessage callbacks
318
packages/shared/__tests__/ReactDOMFrameScheduling-test.js
+4 -2
@@ -15,9 +15,11 @@ describe('ReactDOMFrameScheduling', () => {
15 try {
16 global.requestAnimationFrame = undefined;
17 jest.resetModules();
18 - expect(() => require('react-dom')).toWarnDev(
18 + spyOnDevAndProd(console, 'error');
19 + require('react-dom');
20 + expect(console.error.calls.count()).toEqual(1);
21 + expect(console.error.calls.argsFor(0)[0]).toMatch(
22 "This browser doesn't support requestAnimationFrame.",
20 - {withoutStack: true},
23 );
24 } finally {
25 global.requestAnimationFrame = previousRAF;