diff --git a/packages/scheduler/src/__tests__/SchedulerPostTaskOnly-test.js b/packages/scheduler/src/__tests__/SchedulerPostTaskOnly-test.js new file mode 100644 index 0000000000000..bc25f3dbb148e --- /dev/null +++ b/packages/scheduler/src/__tests__/SchedulerPostTaskOnly-test.js @@ -0,0 +1,275 @@ +/** + * 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; + +describe('SchedulerPostTaskOnly', () => { + beforeEach(() => { + if (!process.env.IS_BUILD) { + jest.resetModules(); + + // Un-mock scheduler + jest.mock('scheduler', () => + require.requireActual('scheduler/unstable_post_task_only'), + ); + + runtime = installMockBrowserRuntime(); + performance = window.performance; + Scheduler = require('scheduler'); + cancelCallback = Scheduler.unstable_cancelCallback; + scheduleCallback = Scheduler.unstable_scheduleCallback; + NormalPriority = Scheduler.unstable_NormalPriority; + } + }); + + afterEach(() => { + if (!process.env.IS_BUILD) { + 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; + + // TODO: Scheduler no longer requires these methods to be polyfilled. But + // maybe we want to continue warning if they don't exist, to preserve the + // option to rely on it in the future? + window.requestAnimationFrame = window.cancelAnimationFrame = () => {}; + + 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, + }; + } + + // @gate source + it('task that finishes before deadline', () => { + scheduleCallback(NormalPriority, () => { + runtime.log('Task'); + }); + runtime.assertLog(['Post Task']); + runtime.fireNextTask(); + runtime.assertLog(['Task Event', 'Task']); + }); + + // @gate source + 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']); + }); + + // @gate source + 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']); + }); + + // @gate source + 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']); + }); + + // @gate source + it('cancels tasks', () => { + const task = scheduleCallback(NormalPriority, () => { + runtime.log('Task'); + }); + runtime.assertLog(['Post Task']); + cancelCallback(task); + runtime.assertLog([]); + }); + + // @gate source + 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']); + }); + + // @gate source + 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']); + }); + + // @gate source + 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/SchedulerPostTaskOnly.js b/packages/scheduler/src/forks/SchedulerPostTaskOnly.js new file mode 100644 index 0000000000000..ba081d3236e5b --- /dev/null +++ b/packages/scheduler/src/forks/SchedulerPostTaskOnly.js @@ -0,0 +1,592 @@ +/** + * 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. + * + */ + +/* eslint-disable no-var */ + +import { + enableSchedulerDebugging, + enableProfiling, +} from '../SchedulerFeatureFlags'; + +import {push, pop, peek} from '../SchedulerMinHeap'; + +// TODO: Use symbols? +import { + ImmediatePriority, + UserBlockingPriority, + NormalPriority, + LowPriority, + IdlePriority, +} from '../SchedulerPriorities'; +import { + sharedProfilingBuffer, + markTaskRun, + markTaskYield, + markTaskCompleted, + markTaskCanceled, + markTaskErrored, + markSchedulerSuspended, + markSchedulerUnsuspended, + markTaskStart, + stopLoggingProfilingEvents, + startLoggingProfilingEvents, +} from '../SchedulerProfiling'; + +import {enableIsInputPending} from '../SchedulerFeatureFlags'; + +const perf = window.performance; + +function getCurrentTime() { + return perf.now(); +} + +// Max 31 bit integer. The max integer size in V8 for 32-bit systems. +// Math.pow(2, 30) - 1 +// 0b111111111111111111111111111111 +var maxSigned31BitInt = 1073741823; + +// Times out immediately +var IMMEDIATE_PRIORITY_TIMEOUT = -1; +// Eventually times out +var USER_BLOCKING_PRIORITY_TIMEOUT = 250; +var NORMAL_PRIORITY_TIMEOUT = 5000; +var LOW_PRIORITY_TIMEOUT = 10000; +// Never times out +var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt; + +// Tasks are stored on a min heap +var taskQueue = []; +var timerQueue = []; + +// Incrementing id counter. Used to maintain insertion order. +var taskIdCounter = 1; + +// Pausing the scheduler is useful for debugging. +var isSchedulerPaused = false; + +var currentTask = null; +var currentPriorityLevel = NormalPriority; + +// This is set while performing work, to prevent re-entrancy. +var isPerformingWork = false; + +var isHostCallbackScheduled = false; +var isHostTimeoutScheduled = false; + +// Capture local references to native APIs, in case a polyfill overrides them. +const setTimeout = window.setTimeout; +const clearTimeout = window.clearTimeout; + +if (typeof console !== 'undefined') { + // TODO: Scheduler no longer requires these methods to be polyfilled. But + // maybe we want to continue warning if they don't exist, to preserve the + // option to rely on it in the future? + const requestAnimationFrame = window.requestAnimationFrame; + const cancelAnimationFrame = window.cancelAnimationFrame; + + if (typeof requestAnimationFrame !== 'function') { + // Using console['error'] to evade Babel and ESLint + console['error']( + "This browser doesn't support requestAnimationFrame. " + + 'Make sure that you load a ' + + 'polyfill in older browsers. https://reactjs.org/link/react-polyfills', + ); + } + if (typeof cancelAnimationFrame !== 'function') { + // Using console['error'] to evade Babel and ESLint + console['error']( + "This browser doesn't support cancelAnimationFrame. " + + 'Make sure that you load a ' + + 'polyfill in older browsers. https://reactjs.org/link/react-polyfills', + ); + } +} + +function advanceTimers(currentTime) { + // Check for tasks that are no longer delayed and add them to the queue. + let timer = peek(timerQueue); + while (timer !== null) { + if (timer.callback === null) { + // Timer was cancelled. + pop(timerQueue); + } else if (timer.startTime <= currentTime) { + // Timer fired. Transfer to the task queue. + pop(timerQueue); + timer.sortIndex = timer.expirationTime; + push(taskQueue, timer); + if (enableProfiling) { + markTaskStart(timer, currentTime); + timer.isQueued = true; + } + } else { + // Remaining timers are pending. + return; + } + timer = peek(timerQueue); + } +} + +function handleTimeout(currentTime) { + isHostTimeoutScheduled = false; + advanceTimers(currentTime); + + if (!isHostCallbackScheduled) { + if (peek(taskQueue) !== null) { + isHostCallbackScheduled = true; + requestHostCallback(flushWork); + } else { + const firstTimer = peek(timerQueue); + if (firstTimer !== null) { + requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); + } + } + } +} + +function flushWork(hasTimeRemaining, initialTime) { + if (enableProfiling) { + markSchedulerUnsuspended(initialTime); + } + + // We'll need a host callback the next time work is scheduled. + isHostCallbackScheduled = false; + if (isHostTimeoutScheduled) { + // We scheduled a timeout but it's no longer needed. Cancel it. + isHostTimeoutScheduled = false; + cancelHostTimeout(); + } + + isPerformingWork = true; + const previousPriorityLevel = currentPriorityLevel; + try { + if (enableProfiling) { + try { + return workLoop(hasTimeRemaining, initialTime); + } catch (error) { + if (currentTask !== null) { + const currentTime = getCurrentTime(); + markTaskErrored(currentTask, currentTime); + currentTask.isQueued = false; + } + throw error; + } + } else { + // No catch in prod code path. + return workLoop(hasTimeRemaining, initialTime); + } + } finally { + currentTask = null; + currentPriorityLevel = previousPriorityLevel; + isPerformingWork = false; + if (enableProfiling) { + const currentTime = getCurrentTime(); + markSchedulerSuspended(currentTime); + } + } +} + +function workLoop(hasTimeRemaining, initialTime) { + let currentTime = initialTime; + advanceTimers(currentTime); + currentTask = peek(taskQueue); + while ( + currentTask !== null && + !(enableSchedulerDebugging && isSchedulerPaused) + ) { + if ( + currentTask.expirationTime > currentTime && + (!hasTimeRemaining || shouldYieldToHost()) + ) { + // This currentTask hasn't expired, and we've reached the deadline. + break; + } + const callback = currentTask.callback; + if (typeof callback === 'function') { + currentTask.callback = null; + currentPriorityLevel = currentTask.priorityLevel; + const didUserCallbackTimeout = currentTask.expirationTime <= currentTime; + markTaskRun(currentTask, currentTime); + const continuationCallback = callback(didUserCallbackTimeout); + currentTime = getCurrentTime(); + if (typeof continuationCallback === 'function') { + currentTask.callback = continuationCallback; + markTaskYield(currentTask, currentTime); + } else { + if (enableProfiling) { + markTaskCompleted(currentTask, currentTime); + currentTask.isQueued = false; + } + if (currentTask === peek(taskQueue)) { + pop(taskQueue); + } + } + advanceTimers(currentTime); + } else { + pop(taskQueue); + } + currentTask = peek(taskQueue); + } + // Return whether there's additional work + if (currentTask !== null) { + return true; + } else { + const firstTimer = peek(timerQueue); + if (firstTimer !== null) { + requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); + } + return false; + } +} + +function unstable_runWithPriority(priorityLevel, eventHandler) { + switch (priorityLevel) { + case ImmediatePriority: + case UserBlockingPriority: + case NormalPriority: + case LowPriority: + case IdlePriority: + break; + default: + priorityLevel = NormalPriority; + } + + var previousPriorityLevel = currentPriorityLevel; + currentPriorityLevel = priorityLevel; + + try { + return eventHandler(); + } finally { + currentPriorityLevel = previousPriorityLevel; + } +} + +function unstable_next(eventHandler) { + var priorityLevel; + switch (currentPriorityLevel) { + 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; + break; + } + + var previousPriorityLevel = currentPriorityLevel; + currentPriorityLevel = priorityLevel; + + try { + return eventHandler(); + } finally { + currentPriorityLevel = previousPriorityLevel; + } +} + +function unstable_wrapCallback(callback) { + var parentPriorityLevel = currentPriorityLevel; + return function() { + // This is a fork of runWithPriority, inlined for performance. + var previousPriorityLevel = currentPriorityLevel; + currentPriorityLevel = parentPriorityLevel; + + try { + return callback.apply(this, arguments); + } finally { + currentPriorityLevel = previousPriorityLevel; + } + }; +} + +function unstable_scheduleCallback(priorityLevel, callback, options) { + var currentTime = getCurrentTime(); + + var startTime; + if (typeof options === 'object' && options !== null) { + var delay = options.delay; + if (typeof delay === 'number' && delay > 0) { + startTime = currentTime + delay; + } else { + startTime = currentTime; + } + } else { + startTime = currentTime; + } + + var timeout; + switch (priorityLevel) { + case ImmediatePriority: + timeout = IMMEDIATE_PRIORITY_TIMEOUT; + break; + case UserBlockingPriority: + timeout = USER_BLOCKING_PRIORITY_TIMEOUT; + break; + case IdlePriority: + timeout = IDLE_PRIORITY_TIMEOUT; + break; + case LowPriority: + timeout = LOW_PRIORITY_TIMEOUT; + break; + case NormalPriority: + default: + timeout = NORMAL_PRIORITY_TIMEOUT; + break; + } + + var expirationTime = startTime + timeout; + + var newTask = { + id: taskIdCounter++, + callback, + priorityLevel, + startTime, + expirationTime, + sortIndex: -1, + }; + if (enableProfiling) { + newTask.isQueued = false; + } + + if (startTime > currentTime) { + // This is a delayed task. + newTask.sortIndex = startTime; + push(timerQueue, newTask); + if (peek(taskQueue) === null && newTask === peek(timerQueue)) { + // All tasks are delayed, and this is the task with the earliest delay. + if (isHostTimeoutScheduled) { + // Cancel an existing timeout. + cancelHostTimeout(); + } else { + isHostTimeoutScheduled = true; + } + // Schedule a timeout. + requestHostTimeout(handleTimeout, startTime - currentTime); + } + } else { + newTask.sortIndex = expirationTime; + push(taskQueue, newTask); + if (enableProfiling) { + markTaskStart(newTask, currentTime); + newTask.isQueued = true; + } + // Schedule a host callback, if needed. If we're already performing work, + // wait until the next time we yield. + if (!isHostCallbackScheduled && !isPerformingWork) { + isHostCallbackScheduled = true; + requestHostCallback(flushWork); + } + } + + return newTask; +} + +function unstable_pauseExecution() { + isSchedulerPaused = true; +} + +function unstable_continueExecution() { + isSchedulerPaused = false; + if (!isHostCallbackScheduled && !isPerformingWork) { + isHostCallbackScheduled = true; + requestHostCallback(flushWork); + } +} + +function unstable_getFirstCallbackNode() { + return peek(taskQueue); +} + +function unstable_cancelCallback(task) { + if (enableProfiling) { + if (task.isQueued) { + const currentTime = getCurrentTime(); + markTaskCanceled(task, currentTime); + task.isQueued = false; + } + } + + // Null out the callback to indicate the task has been canceled. (Can't + // remove from the queue because you can't remove arbitrary nodes from an + // array based heap, only the first one.) + task.callback = null; +} + +function unstable_getCurrentPriorityLevel() { + return currentPriorityLevel; +} + +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. +let yieldInterval = 5; +let deadline = 0; + +// TODO: Make this configurable +// TODO: Adjust this based on priority? +const maxYieldInterval = 300; +let needsPaint = false; + +function shouldYieldToHost() { + if ( + enableIsInputPending && + navigator !== undefined && + navigator.scheduling !== undefined && + navigator.scheduling.isInputPending !== undefined + ) { + const scheduling = navigator.scheduling; + const currentTime = getCurrentTime(); + if (currentTime >= deadline) { + // There's no time left. We may want to yield control of the main + // thread, so the browser can perform high priority tasks. The main ones + // are painting and user input. If there's a pending paint or a pending + // input, then we should yield. But if there's neither, then we can + // yield less often while remaining responsive. We'll eventually yield + // regardless, since there could be a pending paint that wasn't + // accompanied by a call to `requestPaint`, or other main thread tasks + // like network events. + if (needsPaint || scheduling.isInputPending()) { + // There is either a pending paint or a pending input. + return true; + } + // There's no pending input. Only yield if we've reached the max + // yield interval. + return currentTime >= maxYieldInterval; + } else { + // There's still time left in the frame. + return false; + } + } else { + // `isInputPending` is not available. Since we have no way of knowing if + // there's pending input, always yield at the end of the frame. + return getCurrentTime() >= deadline; + } +} + +function requestPaint() { + if ( + enableIsInputPending && + navigator !== undefined && + navigator.scheduling !== undefined && + navigator.scheduling.isInputPending !== undefined + ) { + needsPaint = true; + } + + // Since we yield every frame regardless, `requestPaint` has no effect. +} + +function forceFrameRate(fps) { + if (fps < 0 || fps > 125) { + // Using console['error'] to evade Babel and ESLint + console['error']( + 'forceFrameRate takes a positive int between 0 and 125, ' + + 'forcing frame rates higher than 125 fps is not supported', + ); + return; + } + if (fps > 0) { + yieldInterval = Math.floor(1000 / fps); + } else { + // reset the framerate + yieldInterval = 5; + } +} + +const performWorkUntilDeadline = () => { + if (scheduledHostCallback !== null) { + const currentTime = getCurrentTime(); + // 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; + } + // Yielding to the browser will give it a chance to paint, so we can + // reset this. + needsPaint = false; +}; + +function postTask(callback) { + // Use experimental Chrome Scheduler postTask API. + global.scheduler.postTask(callback); +} + +function requestHostCallback(callback) { + scheduledHostCallback = callback; + if (!isTaskLoopRunning) { + isTaskLoopRunning = true; + postTask(performWorkUntilDeadline); + } +} + +function requestHostTimeout(callback, ms) { + taskTimeoutID = setTimeout(() => { + callback(getCurrentTime()); + }, ms); +} + +function cancelHostTimeout() { + clearTimeout(taskTimeoutID); + taskTimeoutID = -1; +} + +const unstable_requestPaint = requestPaint; + +export { + ImmediatePriority as unstable_ImmediatePriority, + UserBlockingPriority as unstable_UserBlockingPriority, + NormalPriority as unstable_NormalPriority, + IdlePriority as unstable_IdlePriority, + LowPriority as unstable_LowPriority, + unstable_runWithPriority, + unstable_next, + unstable_scheduleCallback, + unstable_cancelCallback, + unstable_wrapCallback, + unstable_getCurrentPriorityLevel, + shouldYieldToHost as unstable_shouldYield, + unstable_requestPaint, + unstable_continueExecution, + unstable_pauseExecution, + unstable_getFirstCallbackNode, + getCurrentTime as unstable_now, + forceFrameRate as unstable_forceFrameRate, +}; + +export const unstable_Profiling = enableProfiling + ? { + startLoggingProfilingEvents, + stopLoggingProfilingEvents, + sharedProfilingBuffer, + } + : null; diff --git a/packages/scheduler/unstable_post_task_only.js b/packages/scheduler/unstable_post_task_only.js new file mode 100644 index 0000000000000..d6e22c230162c --- /dev/null +++ b/packages/scheduler/unstable_post_task_only.js @@ -0,0 +1,10 @@ +/** + * 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. + */ + +'use strict'; + +export * from './src/forks/SchedulerPostTaskOnly'; diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index cd873e7df7fff..51d5c495a23ca 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -659,6 +659,21 @@ const bundles = [ externals: [], }, + /******* React Scheduler Post Task Only (experimental) *******/ + { + bundleTypes: [ + NODE_DEV, + NODE_PROD, + FB_WWW_DEV, + FB_WWW_PROD, + FB_WWW_PROFILING, + ], + moduleType: ISOMORPHIC, + entry: 'scheduler/unstable_post_task_only', + global: 'SchedulerPostTaskOnly', + externals: [], + }, + /******* React Scheduler No DOM (experimental) *******/ { bundleTypes: [