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..0b29a8d2055bb --- /dev/null +++ b/packages/scheduler/src/SchedulerPostTask.js @@ -0,0 +1,245 @@ +/** + * 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 CallbackStatus = 'ok' | 'errored' | 'canceled'; + +type CallbackNode = { + _controller: TaskController, + _status: CallbackStatus, +}; + +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, + _status: 'ok', + }; + + scheduler + .postTask( + runTask.bind(null, priorityLevel, postTaskPriority, node, callback), + postTaskOptions, + ) + .catch(handlePostTaskError.bind(null, node)); + + return node; +} + +function runTask( + priorityLevel: PriorityLevel, + postTaskPriority: PostTaskPriorityLevel, + node: CallbackNode, + callback: SchedulerCallback, +) { + deadline = getCurrentTime() + yieldInterval; + let result; + try { + currentPriorityLevel_DEPRECATED = priorityLevel; + const didTimeout_DEPRECATED = false; + result = callback(didTimeout_DEPRECATED); + } catch (error) { + node._status = 'errored'; + throw error; + } finally { + currentPriorityLevel_DEPRECATED = NormalPriority; + } + 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.bind(null, node)); + } +} + +function handlePostTaskError(node, error) { + if (node._status === 'canceled') { + // This task was canceled by unstable_cancelCallback, and there were no + // other user errors. So this must be an AbortError. Suppress it. + } else { + // Otherwise, surface to the user. + throw error; + } +} + +export function unstable_cancelCallback(node: CallbackNode) { + if (node._status === 'ok') { + // Unless the task already failed due to a user error, mark it as canceled. + node._status = 'canceled'; + } + 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 deleted file mode 100644 index 32b91cd3ae0fc..0000000000000 --- a/packages/scheduler/src/__tests__/SchedulerPostTask-test.js +++ /dev/null @@ -1,267 +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. - * - * @emails react-core - * @jest-environment node - */ - -/* eslint-disable no-for-of-loops/no-for-of-loops */ - -'use strict'; - -let Scheduler; -let runtime; -let performance; -let cancelCallback; -let scheduleCallback; -let NormalPriority; - -// 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 -describe('SchedulerPostTask', () => { - beforeEach(() => { - jest.resetModules(); - - // Un-mock scheduler - 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; - NormalPriority = Scheduler.unstable_NormalPriority; - }); - - afterEach(() => { - if (!runtime.isLogEmpty()) { - throw Error('Test exited without clearing log.'); - } - }); - - function installMockBrowserRuntime() { - let hasPendingTask = false; - let timerIDCounter = 0; - let eventLog = []; - - // Mock window functions - const window = {}; - global.window = window; - - let currentTime = 0; - window.performance = { - now() { - return currentTime; - }, - }; - - 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'); - } - log('Post Task'); - hasPendingTask = true; - nextTask = callback; - }; - - function ensureLogIsEmpty() { - if (eventLog.length !== 0) { - throw Error('Log is not empty. Call assertLog before continuing.'); - } - } - function advanceTime(ms) { - currentTime += ms; - } - function fireNextTask() { - 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(); - } - function log(val) { - eventLog.push(val); - } - function isLogEmpty() { - return eventLog.length === 0; - } - function assertLog(expected) { - const actual = eventLog; - eventLog = []; - expect(actual).toEqual(expected); - } - return { - advanceTime, - fireNextTask, - log, - isLogEmpty, - assertLog, - }; - } - - it('task that finishes before deadline', () => { - scheduleCallback(NormalPriority, () => { - runtime.log('Task'); - }); - runtime.assertLog(['Post Task']); - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'Task']); - }); - - it('task with continuation', () => { - scheduleCallback(NormalPriority, () => { - runtime.log('Task'); - while (!Scheduler.unstable_shouldYield()) { - runtime.advanceTime(1); - } - runtime.log(`Yield at ${performance.now()}ms`); - return () => { - runtime.log('Continuation'); - }; - }); - runtime.assertLog(['Post Task']); - - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'Task', 'Yield at 5ms', 'Post Task']); - - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'Continuation']); - }); - - it('multiple tasks', () => { - scheduleCallback(NormalPriority, () => { - runtime.log('A'); - }); - 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', - ]); - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'B']); - }); - - it('cancels tasks', () => { - const task = scheduleCallback(NormalPriority, () => { - runtime.log('Task'); - }); - runtime.assertLog(['Post Task']); - cancelCallback(task); - runtime.assertLog([]); - }); - - it('throws when a task errors then continues in a new event', () => { - 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']); - }); - - it('schedule new task after queue has emptied', () => { - scheduleCallback(NormalPriority, () => { - runtime.log('A'); - }); - - runtime.assertLog(['Post Task']); - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'A']); - - scheduleCallback(NormalPriority, () => { - runtime.log('B'); - }); - runtime.assertLog(['Post Task']); - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'B']); - }); - - it('schedule new task after a cancellation', () => { - const handle = scheduleCallback(NormalPriority, () => { - runtime.log('A'); - }); - - runtime.assertLog(['Post Task']); - cancelCallback(handle); - - runtime.fireNextTask(); - runtime.assertLog(['Task Event']); - - scheduleCallback(NormalPriority, () => { - runtime.log('B'); - }); - runtime.assertLog(['Post Task']); - runtime.fireNextTask(); - runtime.assertLog(['Task Event', 'B']); - }); -}); 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,