From a7b6422aac6c98266c64e6c5a4d8fe07f69c310c Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 6 Aug 2020 14:46:24 -0500 Subject: [PATCH] [WIP][Scheduler] Call postTask directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This updates the experimental Scheduler postTask build to call postTask directly, instead of managing our own custom queue and work loop. We still use a deadline 5ms mechanism to implement `shouldYield`. The main thing that postTask is currently missing is the continuation feature — when yielding to the main thread, the yielding task is sent to the back of the queue, instead of maintaining its position. While this would be nice to have, even without it, postTask may be good enough to replace our userspace implementation. We'll run some tests to see. TODO: Need to update the tests. --- .eslintrc.js | 6 + packages/scheduler/src/SchedulerPostTask.js | 245 ++++++++++++++++ .../src/__tests__/SchedulerPostTask-test.js | 267 ------------------ .../forks/SchedulerHostConfig.post-task.js | 99 ------- packages/scheduler/unstable_post_task.js | 2 +- scripts/rollup/forks.js | 2 - scripts/rollup/validate/eslintrc.cjs.js | 2 + scripts/rollup/validate/eslintrc.cjs2015.js | 2 + scripts/rollup/validate/eslintrc.fb.js | 2 + scripts/rollup/validate/eslintrc.rn.js | 2 + scripts/rollup/validate/eslintrc.umd.js | 2 + 11 files changed, 262 insertions(+), 369 deletions(-) create mode 100644 packages/scheduler/src/SchedulerPostTask.js delete mode 100644 packages/scheduler/src/__tests__/SchedulerPostTask-test.js delete mode 100644 packages/scheduler/src/forks/SchedulerHostConfig.post-task.js 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,