@samitouri / QOS-React / commits / 7c8cc4358e

Add postTask browser scheduler implementation (#19479)

* Reduce code to necessities * Switch to postTask API * Add SchedulerPostTask tests * Updates from review * Fix typo from review * Generate build of unstable_post_task

Ricky committed Jul 29, 2020 at 15:27 UTC 7c8cc4358e79670d1e3be803a8c5267116e09bff
6 files changed +362 -231
packages/scheduler/npm/unstable_post_task.js new
+7
@@ -0,0 +1,7 @@
1 +'use strict';
2 +
3 +if (process.env.NODE_ENV === 'production') {
4 + module.exports = require('./cjs/scheduler-unstable_post_task.production.min.js');
5 +} else {
6 + module.exports = require('./cjs/scheduler-unstable_post_task.development.js');
7 +}
packages/scheduler/package.json
+1
@@ -28,6 +28,7 @@
28 "tracing.js",
29 "tracing-profiling.js",
30 "unstable_mock.js",
31 + "unstable_post_task.js",
32 "cjs/",
33 "umd/"
34 ],
packages/scheduler/src/__tests__/SchedulerPostTask-test.js new
+267
@@ -0,0 +1,267 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
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 + * @jest-environment node
9 + */
10 +
11 +/* eslint-disable no-for-of-loops/no-for-of-loops */
12 +
13 +'use strict';
14 +
15 +let Scheduler;
16 +let runtime;
17 +let performance;
18 +let cancelCallback;
19 +let scheduleCallback;
20 +let NormalPriority;
21 +
22 +// The Scheduler postTask implementation uses a new postTask browser API to
23 +// schedule work on the main thread. Most of our tests treat this as an
24 +// implementation detail; however, the sequence and timing of browser
25 +// APIs are not precisely specified, and can vary across browsers.
26 +//
27 +// To prevent regressions, we need the ability to simulate specific edge cases
28 +// that we may encounter in various browsers.
29 +//
30 +// This test suite mocks all browser methods used in our implementation. It
31 +// assumes as little as possible about the order and timing of events.s
32 +describe('SchedulerPostTask', () => {
33 + beforeEach(() => {
34 + jest.resetModules();
35 +
36 + // Un-mock scheduler
37 + jest.mock('scheduler', () =>
38 + require.requireActual('scheduler/unstable_post_task'),
39 + );
40 + jest.mock('scheduler/src/SchedulerHostConfig', () =>
41 + require.requireActual(
42 + 'scheduler/src/forks/SchedulerHostConfig.post-task.js',
43 + ),
44 + );
45 +
46 + runtime = installMockBrowserRuntime();
47 + performance = window.performance;
48 + Scheduler = require('scheduler');
49 + cancelCallback = Scheduler.unstable_cancelCallback;
50 + scheduleCallback = Scheduler.unstable_scheduleCallback;
51 + NormalPriority = Scheduler.unstable_NormalPriority;
52 + });
53 +
54 + afterEach(() => {
55 + if (!runtime.isLogEmpty()) {
56 + throw Error('Test exited without clearing log.');
57 + }
58 + });
59 +
60 + function installMockBrowserRuntime() {
61 + let hasPendingTask = false;
62 + let timerIDCounter = 0;
63 + let eventLog = [];
64 +
65 + // Mock window functions
66 + const window = {};
67 + global.window = window;
68 +
69 + let currentTime = 0;
70 + window.performance = {
71 + now() {
72 + return currentTime;
73 + },
74 + };
75 +
76 + window.setTimeout = (cb, delay) => {
77 + const id = timerIDCounter++;
78 + log(`Set Timer`);
79 + // TODO
80 + return id;
81 + };
82 + window.clearTimeout = id => {
83 + // TODO
84 + };
85 +
86 + // Mock browser scheduler.
87 + const scheduler = {};
88 + global.scheduler = scheduler;
89 +
90 + let nextTask;
91 + scheduler.postTask = function(callback) {
92 + if (hasPendingTask) {
93 + throw Error('Task already scheduled');
94 + }
95 + log('Post Task');
96 + hasPendingTask = true;
97 + nextTask = callback;
98 + };
99 +
100 + function ensureLogIsEmpty() {
101 + if (eventLog.length !== 0) {
102 + throw Error('Log is not empty. Call assertLog before continuing.');
103 + }
104 + }
105 + function advanceTime(ms) {
106 + currentTime += ms;
107 + }
108 + function fireNextTask() {
109 + ensureLogIsEmpty();
110 + if (!hasPendingTask) {
111 + throw Error('No task was scheduled');
112 + }
113 + hasPendingTask = false;
114 +
115 + log('Task Event');
116 +
117 + // If there's a continuation, it will call postTask again
118 + // which will set nextTask. That means we need to clear
119 + // nextTask before the invocation, otherwise we would
120 + // delete the continuation task.
121 + const task = nextTask;
122 + nextTask = null;
123 + task();
124 + }
125 + function log(val) {
126 + eventLog.push(val);
127 + }
128 + function isLogEmpty() {
129 + return eventLog.length === 0;
130 + }
131 + function assertLog(expected) {
132 + const actual = eventLog;
133 + eventLog = [];
134 + expect(actual).toEqual(expected);
135 + }
136 + return {
137 + advanceTime,
138 + fireNextTask,
139 + log,
140 + isLogEmpty,
141 + assertLog,
142 + };
143 + }
144 +
145 + it('task that finishes before deadline', () => {
146 + scheduleCallback(NormalPriority, () => {
147 + runtime.log('Task');
148 + });
149 + runtime.assertLog(['Post Task']);
150 + runtime.fireNextTask();
151 + runtime.assertLog(['Task Event', 'Task']);
152 + });
153 +
154 + it('task with continuation', () => {
155 + scheduleCallback(NormalPriority, () => {
156 + runtime.log('Task');
157 + while (!Scheduler.unstable_shouldYield()) {
158 + runtime.advanceTime(1);
159 + }
160 + runtime.log(`Yield at ${performance.now()}ms`);
161 + return () => {
162 + runtime.log('Continuation');
163 + };
164 + });
165 + runtime.assertLog(['Post Task']);
166 +
167 + runtime.fireNextTask();
168 + runtime.assertLog(['Task Event', 'Task', 'Yield at 5ms', 'Post Task']);
169 +
170 + runtime.fireNextTask();
171 + runtime.assertLog(['Task Event', 'Continuation']);
172 + });
173 +
174 + it('multiple tasks', () => {
175 + scheduleCallback(NormalPriority, () => {
176 + runtime.log('A');
177 + });
178 + scheduleCallback(NormalPriority, () => {
179 + runtime.log('B');
180 + });
181 + runtime.assertLog(['Post Task']);
182 + runtime.fireNextTask();
183 + runtime.assertLog(['Task Event', 'A', 'B']);
184 + });
185 +
186 + it('multiple tasks with a yield in between', () => {
187 + scheduleCallback(NormalPriority, () => {
188 + runtime.log('A');
189 + runtime.advanceTime(4999);
190 + });
191 + scheduleCallback(NormalPriority, () => {
192 + runtime.log('B');
193 + });
194 + runtime.assertLog(['Post Task']);
195 + runtime.fireNextTask();
196 + runtime.assertLog([
197 + 'Task Event',
198 + 'A',
199 + // Ran out of time. Post a continuation event.
200 + 'Post Task',
201 + ]);
202 + runtime.fireNextTask();
203 + runtime.assertLog(['Task Event', 'B']);
204 + });
205 +
206 + it('cancels tasks', () => {
207 + const task = scheduleCallback(NormalPriority, () => {
208 + runtime.log('Task');
209 + });
210 + runtime.assertLog(['Post Task']);
211 + cancelCallback(task);
212 + runtime.assertLog([]);
213 + });
214 +
215 + it('throws when a task errors then continues in a new event', () => {
216 + scheduleCallback(NormalPriority, () => {
217 + runtime.log('Oops!');
218 + throw Error('Oops!');
219 + });
220 + scheduleCallback(NormalPriority, () => {
221 + runtime.log('Yay');
222 + });
223 + runtime.assertLog(['Post Task']);
224 +
225 + expect(() => runtime.fireNextTask()).toThrow('Oops!');
226 + runtime.assertLog(['Task Event', 'Oops!', 'Post Task']);
227 +
228 + runtime.fireNextTask();
229 + runtime.assertLog(['Task Event', 'Yay']);
230 + });
231 +
232 + it('schedule new task after queue has emptied', () => {
233 + scheduleCallback(NormalPriority, () => {
234 + runtime.log('A');
235 + });
236 +
237 + runtime.assertLog(['Post Task']);
238 + runtime.fireNextTask();
239 + runtime.assertLog(['Task Event', 'A']);
240 +
241 + scheduleCallback(NormalPriority, () => {
242 + runtime.log('B');
243 + });
244 + runtime.assertLog(['Post Task']);
245 + runtime.fireNextTask();
246 + runtime.assertLog(['Task Event', 'B']);
247 + });
248 +
249 + it('schedule new task after a cancellation', () => {
250 + const handle = scheduleCallback(NormalPriority, () => {
251 + runtime.log('A');
252 + });
253 +
254 + runtime.assertLog(['Post Task']);
255 + cancelCallback(handle);
256 +
257 + runtime.fireNextTask();
258 + runtime.assertLog(['Task Event']);
259 +
260 + scheduleCallback(NormalPriority, () => {
261 + runtime.log('B');
262 + });
263 + runtime.assertLog(['Post Task']);
264 + runtime.fireNextTask();
265 + runtime.assertLog(['Task Event', 'B']);
266 + });
267 +});
packages/scheduler/src/forks/SchedulerHostConfig.default.js
+1 -1
@@ -174,7 +174,7 @@ if (
174 // Using console['error'] to evade Babel and ESLint
175 console['error'](
176 'forceFrameRate takes a positive int between 0 and 125, ' +
177 - 'forcing frame rates higher than 125 fps is not unsupported',
177 + 'forcing frame rates higher than 125 fps is not supported',
178 );
179 return;
180 }
packages/scheduler/src/forks/SchedulerHostConfig.post-task.js
+78 -229
@@ -5,246 +5,95 @@
5 * LICENSE file in the root directory of this source tree.
6 */
7
8 -import {enableIsInputPending} from '../SchedulerFeatureFlags';
9 -
10 -export let requestHostCallback;
11 -export let cancelHostCallback;
12 -export let requestHostTimeout;
13 -export let cancelHostTimeout;
14 -export let shouldYieldToHost;
15 -export let requestPaint;
16 -export let getCurrentTime;
17 -export let forceFrameRate;
18 -
19 -if (
20 - // If Scheduler runs in a non-DOM environment, it falls back to a naive
21 - // implementation using setTimeout.
22 - typeof window === 'undefined' ||
23 - // Check if MessageChannel is supported, too.
24 - typeof MessageChannel !== 'function'
25 -) {
26 - // If this accidentally gets imported in a non-browser environment, e.g. JavaScriptCore,
27 - // fallback to a naive implementation.
28 - let _callback = null;
29 - let _timeoutID = null;
30 - const _flushCallback = function() {
31 - if (_callback !== null) {
32 - try {
33 - const currentTime = getCurrentTime();
34 - const hasRemainingTime = true;
35 - _callback(hasRemainingTime, currentTime);
36 - _callback = null;
37 - } catch (e) {
38 - setTimeout(_flushCallback, 0);
39 - throw e;
40 - }
41 - }
42 - };
43 - const initialTime = Date.now();
44 - getCurrentTime = function() {
45 - return Date.now() - initialTime;
46 - };
47 - requestHostCallback = function(cb) {
48 - if (_callback !== null) {
49 - // Protect against re-entrancy.
50 - setTimeout(requestHostCallback, 0, cb);
51 - } else {
52 - _callback = cb;
53 - setTimeout(_flushCallback, 0);
54 - }
55 - };
56 - cancelHostCallback = function() {
57 - _callback = null;
58 - };
59 - requestHostTimeout = function(cb, ms) {
60 - _timeoutID = setTimeout(cb, ms);
61 - };
62 - cancelHostTimeout = function() {
63 - clearTimeout(_timeoutID);
64 - };
65 - shouldYieldToHost = function() {
66 - return false;
67 - };
68 - requestPaint = forceFrameRate = function() {};
69 -} else {
70 - // Capture local references to native APIs, in case a polyfill overrides them.
71 - const performance = window.performance;
72 - const Date = window.Date;
73 - const setTimeout = window.setTimeout;
74 - const clearTimeout = window.clearTimeout;
75 -
76 - if (typeof console !== 'undefined') {
77 - // TODO: Scheduler no longer requires these methods to be polyfilled. But
78 - // maybe we want to continue warning if they don't exist, to preserve the
79 - // option to rely on it in the future?
80 - const requestAnimationFrame = window.requestAnimationFrame;
81 - const cancelAnimationFrame = window.cancelAnimationFrame;
82 - // TODO: Remove fb.me link
83 - if (typeof requestAnimationFrame !== 'function') {
84 - // Using console['error'] to evade Babel and ESLint
85 - console['error'](
86 - "This browser doesn't support requestAnimationFrame. " +
87 - 'Make sure that you load a ' +
88 - 'polyfill in older browsers. https://fb.me/react-polyfills',
89 - );
90 - }
91 - if (typeof cancelAnimationFrame !== 'function') {
92 - // Using console['error'] to evade Babel and ESLint
93 - console['error'](
94 - "This browser doesn't support cancelAnimationFrame. " +
95 - 'Make sure that you load a ' +
96 - 'polyfill in older browsers. https://fb.me/react-polyfills',
97 - );
98 - }
99 - }
8 +// Capture local references to native APIs, in case a polyfill overrides them.
9 +const perf = window.performance;
10 +const setTimeout = window.setTimeout;
11 +const clearTimeout = window.clearTimeout;
12 +
13 +function postTask(callback) {
14 + // Use experimental Chrome Scheduler postTask API.
15 + global.scheduler.postTask(callback);
16 +}
17
101 - if (
102 - typeof performance === 'object' &&
103 - typeof performance.now === 'function'
104 - ) {
105 - getCurrentTime = () => performance.now();
106 - } else {
107 - const initialTime = Date.now();
108 - getCurrentTime = () => Date.now() - initialTime;
109 - }
18 +function getNow() {
19 + return perf.now();
20 +}
21
111 - let isMessageLoopRunning = false;
112 - let scheduledHostCallback = null;
113 - let taskTimeoutID = -1;
22 +let isTaskLoopRunning = false;
23 +let scheduledHostCallback = null;
24 +let taskTimeoutID = -1;
25 +
26 +// Scheduler periodically yields in case there is other work on the main
27 +// thread, like user events. By default, it yields multiple times per frame.
28 +// It does not attempt to align with frame boundaries, since most tasks don't
29 +// need to be frame aligned; for those that do, use requestAnimationFrame.
30 +const yieldInterval = 5;
31 +let deadline = 0;
32 +
33 +// `isInputPending` is not available. Since we have no way of knowing if
34 +// there's pending input, always yield at the end of the frame.
35 +export function shouldYieldToHost() {
36 + return getNow() >= deadline;
37 +}
38
115 - // Scheduler periodically yields in case there is other work on the main
116 - // thread, like user events. By default, it yields multiple times per frame.
117 - // It does not attempt to align with frame boundaries, since most tasks don't
118 - // need to be frame aligned; for those that do, use requestAnimationFrame.
119 - let yieldInterval = 5;
120 - let deadline = 0;
39 +export function requestPaint() {
40 + // Since we yield every frame regardless, `requestPaint` has no effect.
41 +}
42
122 - // TODO: Make this configurable
123 - // TODO: Adjust this based on priority?
124 - const maxYieldInterval = 300;
125 - let needsPaint = false;
43 +export function forceFrameRate(fps) {
44 + // No-op
45 +}
46
127 - if (
128 - enableIsInputPending &&
129 - navigator !== undefined &&
130 - navigator.scheduling !== undefined &&
131 - navigator.scheduling.isInputPending !== undefined
132 - ) {
133 - const scheduling = navigator.scheduling;
134 - shouldYieldToHost = function() {
135 - const currentTime = getCurrentTime();
136 - if (currentTime >= deadline) {
137 - // There's no time left. We may want to yield control of the main
138 - // thread, so the browser can perform high priority tasks. The main ones
139 - // are painting and user input. If there's a pending paint or a pending
140 - // input, then we should yield. But if there's neither, then we can
141 - // yield less often while remaining responsive. We'll eventually yield
142 - // regardless, since there could be a pending paint that wasn't
143 - // accompanied by a call to `requestPaint`, or other main thread tasks
144 - // like network events.
145 - if (needsPaint || scheduling.isInputPending()) {
146 - // There is either a pending paint or a pending input.
147 - return true;
148 - }
149 - // There's no pending input. Only yield if we've reached the max
150 - // yield interval.
151 - return currentTime >= maxYieldInterval;
47 +function performWorkUntilDeadline() {
48 + if (scheduledHostCallback !== null) {
49 + const currentTime = getNow();
50 + // Yield after `yieldInterval` ms, regardless of where we are in the vsync
51 + // cycle. This means there's always time remaining at the beginning of
52 + // the message event.
53 + deadline = currentTime + yieldInterval;
54 + const hasTimeRemaining = true;
55 + try {
56 + const hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
57 + if (!hasMoreWork) {
58 + isTaskLoopRunning = false;
59 + scheduledHostCallback = null;
60 } else {
153 - // There's still time left in the frame.
154 - return false;
61 + // If there's more work, schedule the next message event at the end
62 + // of the preceding one.
63 + postTask(performWorkUntilDeadline);
64 }
156 - };
157 -
158 - requestPaint = function() {
159 - needsPaint = true;
160 - };
65 + } catch (error) {
66 + // If a scheduler task throws, exit the current browser task so the
67 + // error can be observed.
68 + postTask(performWorkUntilDeadline);
69 + throw error;
70 + }
71 } else {
162 - // `isInputPending` is not available. Since we have no way of knowing if
163 - // there's pending input, always yield at the end of the frame.
164 - shouldYieldToHost = function() {
165 - return getCurrentTime() >= deadline;
166 - };
167 -
168 - // Since we yield every frame regardless, `requestPaint` has no effect.
169 - requestPaint = function() {};
72 + isTaskLoopRunning = false;
73 }
74 +}
75
172 - forceFrameRate = function(fps) {
173 - if (fps < 0 || fps > 125) {
174 - // Using console['error'] to evade Babel and ESLint
175 - console['error'](
176 - 'forceFrameRate takes a positive int between 0 and 125, ' +
177 - 'forcing frame rates higher than 125 fps is not unsupported',
178 - );
179 - return;
180 - }
181 - if (fps > 0) {
182 - yieldInterval = Math.floor(1000 / fps);
183 - } else {
184 - // reset the framerate
185 - yieldInterval = 5;
186 - }
187 - };
188 -
189 - const performWorkUntilDeadline = () => {
190 - if (scheduledHostCallback !== null) {
191 - const currentTime = getCurrentTime();
192 - // Yield after `yieldInterval` ms, regardless of where we are in the vsync
193 - // cycle. This means there's always time remaining at the beginning of
194 - // the message event.
195 - deadline = currentTime + yieldInterval;
196 - const hasTimeRemaining = true;
197 - try {
198 - const hasMoreWork = scheduledHostCallback(
199 - hasTimeRemaining,
200 - currentTime,
201 - );
202 - if (!hasMoreWork) {
203 - isMessageLoopRunning = false;
204 - scheduledHostCallback = null;
205 - } else {
206 - // If there's more work, schedule the next message event at the end
207 - // of the preceding one.
208 - port.postMessage(null);
209 - }
210 - } catch (error) {
211 - // If a scheduler task throws, exit the current browser task so the
212 - // error can be observed.
213 - port.postMessage(null);
214 - throw error;
215 - }
216 - } else {
217 - isMessageLoopRunning = false;
218 - }
219 - // Yielding to the browser will give it a chance to paint, so we can
220 - // reset this.
221 - needsPaint = false;
222 - };
223 -
224 - const channel = new MessageChannel();
225 - const port = channel.port2;
226 - channel.port1.onmessage = performWorkUntilDeadline;
227 -
228 - requestHostCallback = function(callback) {
229 - scheduledHostCallback = callback;
230 - if (!isMessageLoopRunning) {
231 - isMessageLoopRunning = true;
232 - port.postMessage(null);
233 - }
234 - };
76 +export function requestHostCallback(callback) {
77 + scheduledHostCallback = callback;
78 + if (!isTaskLoopRunning) {
79 + isTaskLoopRunning = true;
80 + postTask(performWorkUntilDeadline);
81 + }
82 +}
83
236 - cancelHostCallback = function() {
237 - scheduledHostCallback = null;
238 - };
84 +export function cancelHostCallback() {
85 + scheduledHostCallback = null;
86 +}
87
240 - requestHostTimeout = function(callback, ms) {
241 - taskTimeoutID = setTimeout(() => {
242 - callback(getCurrentTime());
243 - }, ms);
244 - };
88 +export function requestHostTimeout(callback, ms) {
89 + taskTimeoutID = setTimeout(() => {
90 + callback(getNow());
91 + }, ms);
92 +}
93
246 - cancelHostTimeout = function() {
247 - clearTimeout(taskTimeoutID);
248 - taskTimeoutID = -1;
249 - };
94 +export function cancelHostTimeout() {
95 + clearTimeout(taskTimeoutID);
96 + taskTimeoutID = -1;
97 }
98 +
99 +export const getCurrentTime = getNow;
scripts/rollup/bundles.js
+8 -1
@@ -634,8 +634,15 @@ const bundles = [
634 externals: [],
635 },
636
637 + /******* React Scheduler Post Task (experimental) *******/
638 {
638 - bundleTypes: [FB_WWW_DEV, FB_WWW_PROD, FB_WWW_PROFILING],
639 + bundleTypes: [
640 + NODE_DEV,
641 + NODE_PROD,
642 + FB_WWW_DEV,
643 + FB_WWW_PROD,
644 + FB_WWW_PROFILING,
645 + ],
646 moduleType: ISOMORPHIC,
647 entry: 'scheduler/unstable_post_task',
648 global: 'SchedulerPostTask',