diff --git a/.eslintrc.js b/.eslintrc.js index 179141ca82e1e..6998bca453f18 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -166,6 +166,12 @@ module.exports = { __webpack_require__: true, }, }, + { + files: ['packages/scheduler/**/*.js'], + globals: { + TaskController: true, + }, + }, ], globals: { diff --git a/packages/scheduler/src/SchedulerPostTask.js b/packages/scheduler/src/SchedulerPostTask.js new file mode 100644 index 0000000000000..b42f2114be2c3 --- /dev/null +++ b/packages/scheduler/src/SchedulerPostTask.js @@ -0,0 +1,249 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {PriorityLevel} from './SchedulerPriorities'; + +declare class TaskController { + constructor(priority?: string): TaskController; + signal: mixed; + abort(): void; +} + +type PostTaskPriorityLevel = 'user-blocking' | 'user-visible' | 'background'; + +type CallbackNode = {| + _controller: TaskController, +|}; + +import { + ImmediatePriority, + UserBlockingPriority, + NormalPriority, + LowPriority, + IdlePriority, +} from './SchedulerPriorities'; + +export { + ImmediatePriority as unstable_ImmediatePriority, + UserBlockingPriority as unstable_UserBlockingPriority, + NormalPriority as unstable_NormalPriority, + IdlePriority as unstable_IdlePriority, + LowPriority as unstable_LowPriority, +}; + +// Capture local references to native APIs, in case a polyfill overrides them. +const perf = window.performance; + +// Use experimental Chrome Scheduler postTask API. +const scheduler = global.scheduler; + +const getCurrentTime = perf.now.bind(perf); + +export const unstable_now = getCurrentTime; + +// Scheduler periodically yields in case there is other work on the main +// thread, like user events. By default, it yields multiple times per frame. +// It does not attempt to align with frame boundaries, since most tasks don't +// need to be frame aligned; for those that do, use requestAnimationFrame. +const yieldInterval = 5; +let deadline = 0; + +let currentPriorityLevel_DEPRECATED = NormalPriority; + +// `isInputPending` is not available. Since we have no way of knowing if +// there's pending input, always yield at the end of the frame. +export function unstable_shouldYield() { + return getCurrentTime() >= deadline; +} + +export function unstable_requestPaint() { + // Since we yield every frame regardless, `requestPaint` has no effect. +} + +type SchedulerCallback = ( + didTimeout_DEPRECATED: boolean, +) => + | T + // May return a continuation + | SchedulerCallback; + +export function unstable_scheduleCallback( + priorityLevel: PriorityLevel, + callback: SchedulerCallback, + options?: {delay?: number}, +): CallbackNode { + let postTaskPriority; + switch (priorityLevel) { + case ImmediatePriority: + case UserBlockingPriority: + postTaskPriority = 'user-blocking'; + break; + case LowPriority: + case NormalPriority: + postTaskPriority = 'user-visible'; + break; + case IdlePriority: + postTaskPriority = 'background'; + break; + default: + postTaskPriority = 'user-visible'; + break; + } + + const controller = new TaskController(); + const postTaskOptions = { + priority: postTaskPriority, + delay: typeof options === 'object' && options !== null ? options.delay : 0, + signal: controller.signal, + }; + + const node = { + _controller: controller, + }; + + scheduler + .postTask( + runTask.bind(null, priorityLevel, postTaskPriority, node, callback), + postTaskOptions, + ) + .catch(handlePostTaskError); + + return node; +} + +function runTask( + priorityLevel: PriorityLevel, + postTaskPriority: PostTaskPriorityLevel, + node: CallbackNode, + callback: SchedulerCallback, +) { + deadline = getCurrentTime() + yieldInterval; + try { + currentPriorityLevel_DEPRECATED = priorityLevel; + const didTimeout_DEPRECATED = false; + const result = callback(didTimeout_DEPRECATED); + if (typeof result === 'function') { + // Assume this is a continuation + const continuation: SchedulerCallback = (result: any); + const continuationController = new TaskController(); + const continuationOptions = { + priority: postTaskPriority, + signal: continuationController.signal, + }; + // Update the original callback node's controller, since even though we're + // posting a new task, conceptually it's the same one. + node._controller = continuationController; + scheduler + .postTask( + runTask.bind( + null, + priorityLevel, + postTaskPriority, + node, + continuation, + ), + continuationOptions, + ) + .catch(handlePostTaskError); + } + } finally { + currentPriorityLevel_DEPRECATED = NormalPriority; + } +} + +function handlePostTaskError(error) { + // This error is either a user error thrown by a callback, or an AbortError + // as a result of a cancellation. + // + // User errors trigger a global `error` event even if we don't rethrow them. + // In fact, if we do rethrow them, they'll get reported to the console twice. + // I'm not entirely sure the current `postTask` spec makes sense here. If I + // catch a `postTask` call, it shouldn't trigger a global error. + // + // Abort errors are an implementation detail. We don't expose the + // TaskController to the user, nor do we expose the promise that is returned + // from `postTask`. So we shouldn't rethrow those, either, since there's no + // way to handle them. (If we did return the promise to the user, then it + // should be up to them to handle the AbortError.) + // + // In either case, we can suppress the error, barring changes to the spec + // or the Scheduler API. +} + +export function unstable_cancelCallback(node: CallbackNode) { + const controller = node._controller; + controller.abort(); +} + +export function unstable_runWithPriority( + priorityLevel: PriorityLevel, + callback: () => T, +): T { + const previousPriorityLevel = currentPriorityLevel_DEPRECATED; + currentPriorityLevel_DEPRECATED = priorityLevel; + try { + return callback(); + } finally { + currentPriorityLevel_DEPRECATED = previousPriorityLevel; + } +} + +export function unstable_getCurrentPriorityLevel() { + return currentPriorityLevel_DEPRECATED; +} + +export function unstable_next(callback: () => T): T { + let priorityLevel; + switch (currentPriorityLevel_DEPRECATED) { + case ImmediatePriority: + case UserBlockingPriority: + case NormalPriority: + // Shift down to normal priority + priorityLevel = NormalPriority; + break; + default: + // Anything lower than normal priority should remain at the current level. + priorityLevel = currentPriorityLevel_DEPRECATED; + break; + } + + const previousPriorityLevel = currentPriorityLevel_DEPRECATED; + currentPriorityLevel_DEPRECATED = priorityLevel; + try { + return callback(); + } finally { + currentPriorityLevel_DEPRECATED = previousPriorityLevel; + } +} + +export function unstable_wrapCallback(callback: () => T): () => T { + const parentPriorityLevel = currentPriorityLevel_DEPRECATED; + return () => { + const previousPriorityLevel = currentPriorityLevel_DEPRECATED; + currentPriorityLevel_DEPRECATED = parentPriorityLevel; + try { + return callback(); + } finally { + currentPriorityLevel_DEPRECATED = previousPriorityLevel; + } + }; +} + +export function unstable_forceFrameRate() {} + +export function unstable_pauseExecution() {} + +export function unstable_continueExecution() {} + +export function unstable_getFirstCallbackNode() { + return null; +} + +// Currently no profiling build +export const unstable_Profiling = null; diff --git a/packages/scheduler/src/__tests__/SchedulerPostTask-test.js b/packages/scheduler/src/__tests__/SchedulerPostTask-test.js index 32b91cd3ae0fc..8ad6228bb8a91 100644 --- a/packages/scheduler/src/__tests__/SchedulerPostTask-test.js +++ b/packages/scheduler/src/__tests__/SchedulerPostTask-test.js @@ -17,18 +17,16 @@ let runtime; let performance; let cancelCallback; let scheduleCallback; +let ImmediatePriority; let NormalPriority; +let UserBlockingPriority; +let LowPriority; +let IdlePriority; // The Scheduler postTask implementation uses a new postTask browser API to -// schedule work on the main thread. Most of our tests treat this as an -// implementation detail; however, the sequence and timing of browser -// APIs are not precisely specified, and can vary across browsers. -// -// To prevent regressions, we need the ability to simulate specific edge cases -// that we may encounter in various browsers. -// -// This test suite mocks all browser methods used in our implementation. It -// assumes as little as possible about the order and timing of events.s +// schedule work on the main thread. This test suite mocks all browser methods +// used in our implementation. It assumes as little as possible about the order +// and timing of events. describe('SchedulerPostTask', () => { beforeEach(() => { jest.resetModules(); @@ -37,18 +35,17 @@ describe('SchedulerPostTask', () => { jest.mock('scheduler', () => require.requireActual('scheduler/unstable_post_task'), ); - jest.mock('scheduler/src/SchedulerHostConfig', () => - require.requireActual( - 'scheduler/src/forks/SchedulerHostConfig.post-task.js', - ), - ); runtime = installMockBrowserRuntime(); performance = window.performance; Scheduler = require('scheduler'); cancelCallback = Scheduler.unstable_cancelCallback; scheduleCallback = Scheduler.unstable_scheduleCallback; + ImmediatePriority = Scheduler.unstable_ImmediatePriority; + UserBlockingPriority = Scheduler.unstable_UserBlockingPriority; NormalPriority = Scheduler.unstable_NormalPriority; + LowPriority = Scheduler.unstable_LowPriority; + IdlePriority = Scheduler.unstable_IdlePriority; }); afterEach(() => { @@ -58,14 +55,14 @@ describe('SchedulerPostTask', () => { }); function installMockBrowserRuntime() { - let hasPendingTask = false; - let timerIDCounter = 0; + let taskQueue = new Map(); let eventLog = []; // Mock window functions const window = {}; global.window = window; + let idCounter = 0; let currentTime = 0; window.performance = { now() { @@ -73,28 +70,33 @@ describe('SchedulerPostTask', () => { }, }; - window.setTimeout = (cb, delay) => { - const id = timerIDCounter++; - log(`Set Timer`); - // TODO - return id; - }; - window.clearTimeout = id => { - // TODO - }; - // Mock browser scheduler. const scheduler = {}; global.scheduler = scheduler; - let nextTask; - scheduler.postTask = function(callback) { - if (hasPendingTask) { - throw Error('Task already scheduled'); + scheduler.postTask = function(callback, {priority, signal}) { + const id = idCounter++; + log( + `Post Task ${id} [${priority === undefined ? '' : priority}]`, + ); + const controller = signal._controller; + return new Promise((resolve, reject) => { + taskQueue.set(controller, {id, callback, resolve, reject}); + }); + }; + + global.TaskController = class TaskController { + constructor() { + this.signal = {_controller: this}; + } + abort() { + const task = taskQueue.get(this); + if (task !== undefined) { + taskQueue.delete(this); + const reject = task.reject; + reject(new Error('Aborted')); + } } - log('Post Task'); - hasPendingTask = true; - nextTask = callback; }; function ensureLogIsEmpty() { @@ -105,22 +107,26 @@ describe('SchedulerPostTask', () => { function advanceTime(ms) { currentTime += ms; } - function fireNextTask() { + function flushTasks() { ensureLogIsEmpty(); - if (!hasPendingTask) { - throw Error('No task was scheduled'); - } - hasPendingTask = false; - - log('Task Event'); // If there's a continuation, it will call postTask again // which will set nextTask. That means we need to clear // nextTask before the invocation, otherwise we would // delete the continuation task. - const task = nextTask; - nextTask = null; - task(); + const prevTaskQueue = taskQueue; + taskQueue = new Map(); + for (const [, {id, callback, resolve, reject}] of prevTaskQueue) { + try { + log(`Task ${id} Fired`); + callback(false); + resolve(); + } catch (error) { + log(`Task ${id} errored [${error.message}]`); + reject(error); + continue; + } + } } function log(val) { eventLog.push(val); @@ -135,7 +141,7 @@ describe('SchedulerPostTask', () => { } return { advanceTime, - fireNextTask, + flushTasks, log, isLogEmpty, assertLog, @@ -144,16 +150,16 @@ describe('SchedulerPostTask', () => { it('task that finishes before deadline', () => { scheduleCallback(NormalPriority, () => { - runtime.log('Task'); + runtime.log('A'); }); - runtime.assertLog(['Post Task']); - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'Task']); + runtime.assertLog(['Post Task 0 [user-visible]']); + runtime.flushTasks(); + runtime.assertLog(['Task 0 Fired', 'A']); }); it('task with continuation', () => { scheduleCallback(NormalPriority, () => { - runtime.log('Task'); + runtime.log('A'); while (!Scheduler.unstable_shouldYield()) { runtime.advanceTime(1); } @@ -162,13 +168,18 @@ describe('SchedulerPostTask', () => { runtime.log('Continuation'); }; }); - runtime.assertLog(['Post Task']); + runtime.assertLog(['Post Task 0 [user-visible]']); - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'Task', 'Yield at 5ms', 'Post Task']); + runtime.flushTasks(); + runtime.assertLog([ + 'Task 0 Fired', + 'A', + 'Yield at 5ms', + 'Post Task 1 [user-visible]', + ]); - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'Continuation']); + runtime.flushTasks(); + runtime.assertLog(['Task 1 Fired', 'Continuation']); }); it('multiple tasks', () => { @@ -178,55 +189,42 @@ describe('SchedulerPostTask', () => { scheduleCallback(NormalPriority, () => { runtime.log('B'); }); - runtime.assertLog(['Post Task']); - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'A', 'B']); - }); - - it('multiple tasks with a yield in between', () => { - scheduleCallback(NormalPriority, () => { - runtime.log('A'); - runtime.advanceTime(4999); - }); - scheduleCallback(NormalPriority, () => { - runtime.log('B'); - }); - runtime.assertLog(['Post Task']); - runtime.fireNextTask(); runtime.assertLog([ - 'Task Event', - 'A', - // Ran out of time. Post a continuation event. - 'Post Task', + 'Post Task 0 [user-visible]', + 'Post Task 1 [user-visible]', ]); - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'B']); + runtime.flushTasks(); + runtime.assertLog(['Task 0 Fired', 'A', 'Task 1 Fired', 'B']); }); it('cancels tasks', () => { const task = scheduleCallback(NormalPriority, () => { - runtime.log('Task'); + runtime.log('A'); }); - runtime.assertLog(['Post Task']); + runtime.assertLog(['Post Task 0 [user-visible]']); cancelCallback(task); + runtime.flushTasks(); runtime.assertLog([]); }); - it('throws when a task errors then continues in a new event', () => { + it('an error in one task does not affect execution of other tasks', () => { scheduleCallback(NormalPriority, () => { - runtime.log('Oops!'); throw Error('Oops!'); }); scheduleCallback(NormalPriority, () => { runtime.log('Yay'); }); - runtime.assertLog(['Post Task']); - - expect(() => runtime.fireNextTask()).toThrow('Oops!'); - runtime.assertLog(['Task Event', 'Oops!', 'Post Task']); - - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'Yay']); + runtime.assertLog([ + 'Post Task 0 [user-visible]', + 'Post Task 1 [user-visible]', + ]); + runtime.flushTasks(); + runtime.assertLog([ + 'Task 0 Fired', + 'Task 0 errored [Oops!]', + 'Task 1 Fired', + 'Yay', + ]); }); it('schedule new task after queue has emptied', () => { @@ -234,16 +232,16 @@ describe('SchedulerPostTask', () => { runtime.log('A'); }); - runtime.assertLog(['Post Task']); - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'A']); + runtime.assertLog(['Post Task 0 [user-visible]']); + runtime.flushTasks(); + runtime.assertLog(['Task 0 Fired', 'A']); scheduleCallback(NormalPriority, () => { runtime.log('B'); }); - runtime.assertLog(['Post Task']); - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'B']); + runtime.assertLog(['Post Task 1 [user-visible]']); + runtime.flushTasks(); + runtime.assertLog(['Task 1 Fired', 'B']); }); it('schedule new task after a cancellation', () => { @@ -251,17 +249,55 @@ describe('SchedulerPostTask', () => { runtime.log('A'); }); - runtime.assertLog(['Post Task']); + runtime.assertLog(['Post Task 0 [user-visible]']); cancelCallback(handle); - runtime.fireNextTask(); - runtime.assertLog(['Task Event']); + runtime.flushTasks(); + runtime.assertLog([]); scheduleCallback(NormalPriority, () => { runtime.log('B'); }); - runtime.assertLog(['Post Task']); - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'B']); + runtime.assertLog(['Post Task 1 [user-visible]']); + runtime.flushTasks(); + runtime.assertLog(['Task 1 Fired', 'B']); + }); + + it('schedules tasks at different priorities', () => { + scheduleCallback(ImmediatePriority, () => { + runtime.log('A'); + }); + scheduleCallback(UserBlockingPriority, () => { + runtime.log('B'); + }); + scheduleCallback(NormalPriority, () => { + runtime.log('C'); + }); + scheduleCallback(LowPriority, () => { + runtime.log('D'); + }); + scheduleCallback(IdlePriority, () => { + runtime.log('E'); + }); + runtime.assertLog([ + 'Post Task 0 [user-blocking]', + 'Post Task 1 [user-blocking]', + 'Post Task 2 [user-visible]', + 'Post Task 3 [user-visible]', + 'Post Task 4 [background]', + ]); + runtime.flushTasks(); + runtime.assertLog([ + 'Task 0 Fired', + 'A', + 'Task 1 Fired', + 'B', + 'Task 2 Fired', + 'C', + 'Task 3 Fired', + 'D', + 'Task 4 Fired', + 'E', + ]); }); }); diff --git a/packages/scheduler/src/forks/SchedulerHostConfig.post-task.js b/packages/scheduler/src/forks/SchedulerHostConfig.post-task.js deleted file mode 100644 index 1bd6bbea03ecf..0000000000000 --- a/packages/scheduler/src/forks/SchedulerHostConfig.post-task.js +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -// Capture local references to native APIs, in case a polyfill overrides them. -const perf = window.performance; -const setTimeout = window.setTimeout; -const clearTimeout = window.clearTimeout; - -function postTask(callback) { - // Use experimental Chrome Scheduler postTask API. - global.scheduler.postTask(callback); -} - -function getNow() { - return perf.now(); -} - -let isTaskLoopRunning = false; -let scheduledHostCallback = null; -let taskTimeoutID = -1; - -// Scheduler periodically yields in case there is other work on the main -// thread, like user events. By default, it yields multiple times per frame. -// It does not attempt to align with frame boundaries, since most tasks don't -// need to be frame aligned; for those that do, use requestAnimationFrame. -const yieldInterval = 5; -let deadline = 0; - -// `isInputPending` is not available. Since we have no way of knowing if -// there's pending input, always yield at the end of the frame. -export function shouldYieldToHost() { - return getNow() >= deadline; -} - -export function requestPaint() { - // Since we yield every frame regardless, `requestPaint` has no effect. -} - -export function forceFrameRate(fps) { - // No-op -} - -function performWorkUntilDeadline() { - if (scheduledHostCallback !== null) { - const currentTime = getNow(); - // Yield after `yieldInterval` ms, regardless of where we are in the vsync - // cycle. This means there's always time remaining at the beginning of - // the message event. - deadline = currentTime + yieldInterval; - const hasTimeRemaining = true; - try { - const hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime); - if (!hasMoreWork) { - isTaskLoopRunning = false; - scheduledHostCallback = null; - } else { - // If there's more work, schedule the next message event at the end - // of the preceding one. - postTask(performWorkUntilDeadline); - } - } catch (error) { - // If a scheduler task throws, exit the current browser task so the - // error can be observed. - postTask(performWorkUntilDeadline); - throw error; - } - } else { - isTaskLoopRunning = false; - } -} - -export function requestHostCallback(callback) { - scheduledHostCallback = callback; - if (!isTaskLoopRunning) { - isTaskLoopRunning = true; - postTask(performWorkUntilDeadline); - } -} - -export function cancelHostCallback() { - scheduledHostCallback = null; -} - -export function requestHostTimeout(callback, ms) { - taskTimeoutID = setTimeout(() => { - callback(getNow()); - }, ms); -} - -export function cancelHostTimeout() { - clearTimeout(taskTimeoutID); - taskTimeoutID = -1; -} - -export const getCurrentTime = getNow; diff --git a/packages/scheduler/unstable_post_task.js b/packages/scheduler/unstable_post_task.js index aa14495a61abf..666eff8a85898 100644 --- a/packages/scheduler/unstable_post_task.js +++ b/packages/scheduler/unstable_post_task.js @@ -7,4 +7,4 @@ 'use strict'; -export * from './src/Scheduler'; +export * from './src/SchedulerPostTask'; diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index ecbbcd981e57f..e34daab32c6d3 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -209,8 +209,6 @@ const forks = Object.freeze({ entry === 'react-test-renderer' ) { return 'scheduler/src/forks/SchedulerHostConfig.mock'; - } else if (entry === 'scheduler/unstable_post_task') { - return 'scheduler/src/forks/SchedulerHostConfig.post-task'; } return 'scheduler/src/forks/SchedulerHostConfig.default'; }, diff --git a/scripts/rollup/validate/eslintrc.cjs.js b/scripts/rollup/validate/eslintrc.cjs.js index 0e182bfefcdd7..4eae89839ae16 100644 --- a/scripts/rollup/validate/eslintrc.cjs.js +++ b/scripts/rollup/validate/eslintrc.cjs.js @@ -30,6 +30,8 @@ module.exports = { Int32Array: true, ArrayBuffer: true, + TaskController: true, + // Flight Uint8Array: true, Promise: true, diff --git a/scripts/rollup/validate/eslintrc.cjs2015.js b/scripts/rollup/validate/eslintrc.cjs2015.js index 5886ba8d91b41..81383a59643e9 100644 --- a/scripts/rollup/validate/eslintrc.cjs2015.js +++ b/scripts/rollup/validate/eslintrc.cjs2015.js @@ -30,6 +30,8 @@ module.exports = { Int32Array: true, ArrayBuffer: true, + TaskController: true, + // Flight Uint8Array: true, Promise: true, diff --git a/scripts/rollup/validate/eslintrc.fb.js b/scripts/rollup/validate/eslintrc.fb.js index b05619d10bd0a..9c9f6b780a935 100644 --- a/scripts/rollup/validate/eslintrc.fb.js +++ b/scripts/rollup/validate/eslintrc.fb.js @@ -31,6 +31,8 @@ module.exports = { Int32Array: true, ArrayBuffer: true, + TaskController: true, + // Flight Uint8Array: true, Promise: true, diff --git a/scripts/rollup/validate/eslintrc.rn.js b/scripts/rollup/validate/eslintrc.rn.js index 49b993ed90d78..4fb8181ae7096 100644 --- a/scripts/rollup/validate/eslintrc.rn.js +++ b/scripts/rollup/validate/eslintrc.rn.js @@ -29,6 +29,8 @@ module.exports = { SharedArrayBuffer: true, Int32Array: true, ArrayBuffer: true, + + TaskController: true, }, parserOptions: { ecmaVersion: 5, diff --git a/scripts/rollup/validate/eslintrc.umd.js b/scripts/rollup/validate/eslintrc.umd.js index 8dfe4ec5630cf..e3786ffd3b6c2 100644 --- a/scripts/rollup/validate/eslintrc.umd.js +++ b/scripts/rollup/validate/eslintrc.umd.js @@ -34,6 +34,8 @@ module.exports = { Int32Array: true, ArrayBuffer: true, + TaskController: true, + // Flight Uint8Array: true, Promise: true,