| 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 | }); |