main
js 414 lines 11.2 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and 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 ImmediatePriority;
21 let NormalPriority;
22 let UserBlockingPriority;
23 let LowPriority;
24 let IdlePriority;
25 let shouldYield;
26
27 // The Scheduler postTask implementation uses a new postTask browser API to
28 // schedule work on the main thread. This test suite mocks all browser methods
29 // used in our implementation. It assumes as little as possible about the order
30 // and timing of events.
31 describe('SchedulerPostTask', () => {
32 beforeEach(() => {
33 jest.resetModules();
34 jest.mock('scheduler', () =>
35 jest.requireActual('scheduler/unstable_post_task'),
36 );
37
38 runtime = installMockBrowserRuntime();
39 performance = window.performance;
40 Scheduler = require('scheduler');
41 cancelCallback = Scheduler.unstable_cancelCallback;
42 scheduleCallback = Scheduler.unstable_scheduleCallback;
43 ImmediatePriority = Scheduler.unstable_ImmediatePriority;
44 UserBlockingPriority = Scheduler.unstable_UserBlockingPriority;
45 NormalPriority = Scheduler.unstable_NormalPriority;
46 LowPriority = Scheduler.unstable_LowPriority;
47 IdlePriority = Scheduler.unstable_IdlePriority;
48 shouldYield = Scheduler.unstable_shouldYield;
49 });
50
51 afterEach(() => {
52 if (!runtime.isLogEmpty()) {
53 throw Error('Test exited without clearing log.');
54 }
55 });
56
57 function installMockBrowserRuntime() {
58 let taskQueue = new Map();
59 let eventLog = [];
60
61 // Mock window functions
62 const window = {};
63 global.window = window;
64
65 let idCounter = 0;
66 let currentTime = 0;
67 window.performance = {
68 now() {
69 return currentTime;
70 },
71 };
72
73 // Note: setTimeout is used to report errors and nothing else.
74 window.setTimeout = cb => {
75 try {
76 cb();
77 } catch (error) {
78 runtime.log(`Error: ${error.message}`);
79 }
80 };
81
82 // Mock browser scheduler.
83 const scheduler = {};
84 global.scheduler = scheduler;
85
86 scheduler.postTask = function (callback, {signal}) {
87 const {priority} = signal;
88 const id = idCounter++;
89 log(
90 `Post Task ${id} [${priority === undefined ? '<default>' : priority}]`,
91 );
92 const controller = signal._controller;
93 return new Promise((resolve, reject) => {
94 taskQueue.set(controller, {id, callback, resolve, reject});
95 });
96 };
97
98 scheduler.yield = function ({signal}) {
99 const {priority} = signal;
100 const id = idCounter++;
101 log(`Yield ${id} [${priority === undefined ? '<default>' : priority}]`);
102 const controller = signal._controller;
103 let callback;
104
105 return {
106 then(cb) {
107 callback = cb;
108 return new Promise((resolve, reject) => {
109 taskQueue.set(controller, {id, callback, resolve, reject});
110 });
111 },
112 };
113 };
114
115 global.TaskController = class TaskController {
116 constructor({priority}) {
117 this.signal = {_controller: this, priority};
118 }
119 abort() {
120 const task = taskQueue.get(this);
121 if (task !== undefined) {
122 taskQueue.delete(this);
123 const reject = task.reject;
124 reject(new Error('Aborted'));
125 }
126 }
127 };
128
129 function ensureLogIsEmpty() {
130 if (eventLog.length !== 0) {
131 throw Error('Log is not empty. Call assertLog before continuing.');
132 }
133 }
134 function advanceTime(ms) {
135 currentTime += ms;
136 }
137 function flushTasks() {
138 ensureLogIsEmpty();
139
140 // If there's a continuation, it will call postTask again
141 // which will set nextTask. That means we need to clear
142 // nextTask before the invocation, otherwise we would
143 // delete the continuation task.
144 const prevTaskQueue = taskQueue;
145 taskQueue = new Map();
146 for (const [, {id, callback, resolve}] of prevTaskQueue) {
147 log(`Task ${id} Fired`);
148 callback(false);
149 resolve();
150 }
151 }
152 function log(val) {
153 eventLog.push(val);
154 }
155 function isLogEmpty() {
156 return eventLog.length === 0;
157 }
158 function assertLog(expected) {
159 const actual = eventLog;
160 eventLog = [];
161 expect(actual).toEqual(expected);
162 }
163 return {
164 advanceTime,
165 flushTasks,
166 log,
167 isLogEmpty,
168 assertLog,
169 };
170 }
171
172 it('task that finishes before deadline', () => {
173 scheduleCallback(NormalPriority, () => {
174 runtime.log('A');
175 });
176 runtime.assertLog(['Post Task 0 [user-visible]']);
177 runtime.flushTasks();
178 runtime.assertLog(['Task 0 Fired', 'A']);
179 });
180
181 it('task with continuation', () => {
182 scheduleCallback(NormalPriority, () => {
183 runtime.log('A');
184 while (!Scheduler.unstable_shouldYield()) {
185 runtime.advanceTime(1);
186 }
187 runtime.log(`Yield at ${performance.now()}ms`);
188 return () => {
189 runtime.log('Continuation');
190 };
191 });
192 runtime.assertLog(['Post Task 0 [user-visible]']);
193
194 runtime.flushTasks();
195 runtime.assertLog([
196 'Task 0 Fired',
197 'A',
198 'Yield at 5ms',
199 'Yield 1 [user-visible]',
200 ]);
201
202 runtime.flushTasks();
203 runtime.assertLog(['Task 1 Fired', 'Continuation']);
204 });
205
206 it('multiple tasks', () => {
207 scheduleCallback(NormalPriority, () => {
208 runtime.log('A');
209 });
210 scheduleCallback(NormalPriority, () => {
211 runtime.log('B');
212 });
213 runtime.assertLog([
214 'Post Task 0 [user-visible]',
215 'Post Task 1 [user-visible]',
216 ]);
217 runtime.flushTasks();
218 runtime.assertLog(['Task 0 Fired', 'A', 'Task 1 Fired', 'B']);
219 });
220
221 it('cancels tasks', () => {
222 const task = scheduleCallback(NormalPriority, () => {
223 runtime.log('A');
224 });
225 runtime.assertLog(['Post Task 0 [user-visible]']);
226 cancelCallback(task);
227 runtime.flushTasks();
228 runtime.assertLog([]);
229 });
230
231 it('an error in one task does not affect execution of other tasks', () => {
232 scheduleCallback(NormalPriority, () => {
233 throw Error('Oops!');
234 });
235 scheduleCallback(NormalPriority, () => {
236 runtime.log('Yay');
237 });
238 runtime.assertLog([
239 'Post Task 0 [user-visible]',
240 'Post Task 1 [user-visible]',
241 ]);
242 runtime.flushTasks();
243 runtime.assertLog(['Task 0 Fired', 'Error: Oops!', 'Task 1 Fired', 'Yay']);
244 });
245
246 it('schedule new task after queue has emptied', () => {
247 scheduleCallback(NormalPriority, () => {
248 runtime.log('A');
249 });
250
251 runtime.assertLog(['Post Task 0 [user-visible]']);
252 runtime.flushTasks();
253 runtime.assertLog(['Task 0 Fired', 'A']);
254
255 scheduleCallback(NormalPriority, () => {
256 runtime.log('B');
257 });
258 runtime.assertLog(['Post Task 1 [user-visible]']);
259 runtime.flushTasks();
260 runtime.assertLog(['Task 1 Fired', 'B']);
261 });
262
263 it('schedule new task after a cancellation', () => {
264 const handle = scheduleCallback(NormalPriority, () => {
265 runtime.log('A');
266 });
267
268 runtime.assertLog(['Post Task 0 [user-visible]']);
269 cancelCallback(handle);
270
271 runtime.flushTasks();
272 runtime.assertLog([]);
273
274 scheduleCallback(NormalPriority, () => {
275 runtime.log('B');
276 });
277 runtime.assertLog(['Post Task 1 [user-visible]']);
278 runtime.flushTasks();
279 runtime.assertLog(['Task 1 Fired', 'B']);
280 });
281
282 it('schedules tasks at different priorities', () => {
283 scheduleCallback(ImmediatePriority, () => {
284 runtime.log('A');
285 });
286 scheduleCallback(UserBlockingPriority, () => {
287 runtime.log('B');
288 });
289 scheduleCallback(NormalPriority, () => {
290 runtime.log('C');
291 });
292 scheduleCallback(LowPriority, () => {
293 runtime.log('D');
294 });
295 scheduleCallback(IdlePriority, () => {
296 runtime.log('E');
297 });
298 runtime.assertLog([
299 'Post Task 0 [user-blocking]',
300 'Post Task 1 [user-blocking]',
301 'Post Task 2 [user-visible]',
302 'Post Task 3 [user-visible]',
303 'Post Task 4 [background]',
304 ]);
305 runtime.flushTasks();
306 runtime.assertLog([
307 'Task 0 Fired',
308 'A',
309 'Task 1 Fired',
310 'B',
311 'Task 2 Fired',
312 'C',
313 'Task 3 Fired',
314 'D',
315 'Task 4 Fired',
316 'E',
317 ]);
318 });
319
320 it('yielding continues in a new task regardless of how much time is remaining', () => {
321 scheduleCallback(NormalPriority, () => {
322 runtime.log('Original Task');
323 runtime.log('shouldYield: ' + shouldYield());
324 runtime.log('Return a continuation');
325 return () => {
326 runtime.log('Continuation Task');
327 };
328 });
329 runtime.assertLog(['Post Task 0 [user-visible]']);
330
331 runtime.flushTasks();
332 runtime.assertLog([
333 'Task 0 Fired',
334 'Original Task',
335 // Immediately before returning a continuation, `shouldYield` returns
336 // false, which means there must be time remaining in the frame.
337 'shouldYield: false',
338 'Return a continuation',
339
340 // The continuation should be scheduled in a separate macrotask even
341 // though there's time remaining.
342 'Yield 1 [user-visible]',
343 ]);
344
345 // No time has elapsed
346 expect(performance.now()).toBe(0);
347
348 runtime.flushTasks();
349 runtime.assertLog(['Task 1 Fired', 'Continuation Task']);
350 });
351
352 describe('falls back to postTask for scheduling continuations when scheduler.yield is not available', () => {
353 beforeEach(() => {
354 delete global.scheduler.yield;
355 });
356
357 it('task with continuation', () => {
358 scheduleCallback(NormalPriority, () => {
359 runtime.log('A');
360 while (!Scheduler.unstable_shouldYield()) {
361 runtime.advanceTime(1);
362 }
363 runtime.log(`Yield at ${performance.now()}ms`);
364 return () => {
365 runtime.log('Continuation');
366 };
367 });
368 runtime.assertLog(['Post Task 0 [user-visible]']);
369
370 runtime.flushTasks();
371 runtime.assertLog([
372 'Task 0 Fired',
373 'A',
374 'Yield at 5ms',
375 'Post Task 1 [user-visible]',
376 ]);
377
378 runtime.flushTasks();
379 runtime.assertLog(['Task 1 Fired', 'Continuation']);
380 });
381
382 it('yielding continues in a new task regardless of how much time is remaining', () => {
383 scheduleCallback(NormalPriority, () => {
384 runtime.log('Original Task');
385 runtime.log('shouldYield: ' + shouldYield());
386 runtime.log('Return a continuation');
387 return () => {
388 runtime.log('Continuation Task');
389 };
390 });
391 runtime.assertLog(['Post Task 0 [user-visible]']);
392
393 runtime.flushTasks();
394 runtime.assertLog([
395 'Task 0 Fired',
396 'Original Task',
397 // Immediately before returning a continuation, `shouldYield` returns
398 // false, which means there must be time remaining in the frame.
399 'shouldYield: false',
400 'Return a continuation',
401
402 // The continuation should be scheduled in a separate macrotask even
403 // though there's time remaining.
404 'Post Task 1 [user-visible]',
405 ]);
406
407 // No time has elapsed
408 expect(performance.now()).toBe(0);
409
410 runtime.flushTasks();
411 runtime.assertLog(['Task 1 Fired', 'Continuation Task']);
412 });
413 });
414 });