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',