diff --git a/packages/scheduler/src/Scheduler.js b/packages/scheduler/src/Scheduler.js index 73ed0af352..c400e095a0 100644 --- a/packages/scheduler/src/Scheduler.js +++ b/packages/scheduler/src/Scheduler.js @@ -11,6 +11,8 @@ import {enableSchedulerDebugging} from './SchedulerFeatureFlags'; import { requestHostCallback, + requestHostTimeout, + cancelHostTimeout, shouldYieldToHost, getCurrentTime, forceFrameRate, @@ -39,6 +41,7 @@ var IDLE_PRIORITY = maxSigned31BitInt; // Tasks are stored as a circular, doubly linked list. var firstTask = null; +var firstDelayedTask = null; // Pausing the scheduler is useful for debugging. var isSchedulerPaused = false; @@ -50,6 +53,7 @@ var currentPriorityLevel = NormalPriority; var isPerformingWork = false; var isHostCallbackScheduled = false; +var isHostTimeoutScheduled = false; function flushTask(task, currentTime) { // Remove the task from the list before calling the callback. That way the @@ -135,14 +139,62 @@ function flushTask(task, currentTime) { } } +function advanceTimers(currentTime) { + // Check for tasks that are no longer delayed and add them to the queue. + if (firstDelayedTask !== null && firstDelayedTask.startTime <= currentTime) { + do { + const task = firstDelayedTask; + const next = task.next; + if (task === next) { + firstDelayedTask = null; + } else { + firstDelayedTask = next; + const previous = task.previous; + previous.next = next; + next.previous = previous; + } + task.next = task.previous = null; + insertScheduledTask(task, task.expirationTime); + } while ( + firstDelayedTask !== null && + firstDelayedTask.startTime <= currentTime + ); + } +} + +function handleTimeout(currentTime) { + isHostTimeoutScheduled = false; + advanceTimers(currentTime); + + if (!isHostCallbackScheduled) { + if (firstTask !== null) { + isHostCallbackScheduled = true; + requestHostCallback(flushWork); + } else if (firstDelayedTask !== null) { + requestHostTimeout( + handleTimeout, + firstDelayedTask.startTime - currentTime, + ); + } + } +} + function flushWork(hasTimeRemaining, initialTime) { // Exit right away if we're currently paused if (enableSchedulerDebugging && isSchedulerPaused) { return; } - // We'll need a new host callback the next time work is scheduled. + // 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(); + } + + let currentTime = initialTime; + advanceTimers(currentTime); isPerformingWork = true; try { @@ -150,7 +202,6 @@ function flushWork(hasTimeRemaining, initialTime) { // Flush all the expired callbacks without yielding. // TODO: Split flushWork into two separate functions instead of using // a boolean argument? - let currentTime = initialTime; while ( firstTask !== null && firstTask.expirationTime <= currentTime && @@ -158,14 +209,15 @@ function flushWork(hasTimeRemaining, initialTime) { ) { flushTask(firstTask, currentTime); currentTime = getCurrentTime(); + advanceTimers(currentTime); } } else { // Keep flushing callbacks until we run out of time in the frame. - let currentTime = initialTime; if (firstTask !== null) { do { flushTask(firstTask, currentTime); currentTime = getCurrentTime(); + advanceTimers(currentTime); } while ( firstTask !== null && !shouldYieldToHost() && @@ -174,7 +226,17 @@ function flushWork(hasTimeRemaining, initialTime) { } } // Return whether there's additional work - return firstTask !== null; + if (firstTask !== null) { + return true; + } else { + if (firstDelayedTask !== null) { + requestHostTimeout( + handleTimeout, + firstDelayedTask.startTime - currentTime, + ); + } + return false; + } } finally { isPerformingWork = false; } @@ -242,34 +304,41 @@ function unstable_wrapCallback(callback) { }; } -function unstable_scheduleCallback(priorityLevel, callback, options) { - var startTime = getCurrentTime(); +function timeoutForPriorityLevel(priorityLevel) { + switch (priorityLevel) { + case ImmediatePriority: + return IMMEDIATE_PRIORITY_TIMEOUT; + case UserBlockingPriority: + return USER_BLOCKING_PRIORITY; + case IdlePriority: + return IDLE_PRIORITY; + case LowPriority: + return LOW_PRIORITY_TIMEOUT; + case NormalPriority: + default: + return NORMAL_PRIORITY_TIMEOUT; + } +} +function unstable_scheduleCallback(priorityLevel, callback, options) { + var currentTime = getCurrentTime(); + + var startTime; var timeout; - if ( - typeof options === 'object' && - options !== null && - typeof options.timeout === 'number' - ) { - timeout = options.timeout; - } else { - switch (priorityLevel) { - case ImmediatePriority: - timeout = IMMEDIATE_PRIORITY_TIMEOUT; - break; - case UserBlockingPriority: - timeout = USER_BLOCKING_PRIORITY; - break; - case IdlePriority: - timeout = IDLE_PRIORITY; - break; - case LowPriority: - timeout = LOW_PRIORITY_TIMEOUT; - break; - case NormalPriority: - default: - timeout = NORMAL_PRIORITY_TIMEOUT; + if (typeof options === 'object' && options !== null) { + var delay = options.delay; + if (typeof delay === 'number' && delay > 0) { + startTime = currentTime + delay; + } else { + startTime = currentTime; } + timeout = + typeof options.timeout === 'number' + ? options.timeout + : timeoutForPriorityLevel(priorityLevel); + } else { + timeout = timeoutForPriorityLevel(priorityLevel); + startTime = currentTime; } var expirationTime = startTime + timeout; @@ -283,6 +352,34 @@ function unstable_scheduleCallback(priorityLevel, callback, options) { previous: null, }; + if (startTime > currentTime) { + // This is a delayed task. + insertDelayedTask(newTask, startTime); + if (firstTask === null && firstDelayedTask === newTask) { + // 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 { + insertScheduledTask(newTask, expirationTime); + // 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 insertScheduledTask(newTask, expirationTime) { // Insert the new task into the list, ordered first by its timeout, then by // insertion. So the new task is inserted after any other task the // same timeout @@ -315,15 +412,39 @@ function unstable_scheduleCallback(priorityLevel, callback, options) { newTask.next = next; newTask.previous = previous; } +} - // 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); +function insertDelayedTask(newTask, startTime) { + // Insert the new task into the list, ordered by its start time. + if (firstDelayedTask === null) { + // This is the first task in the list. + firstDelayedTask = newTask.next = newTask.previous = newTask; + } else { + var next = null; + var task = firstDelayedTask; + do { + if (startTime < task.startTime) { + // The new task times out before this one. + next = task; + break; + } + task = task.next; + } while (task !== firstDelayedTask); + + if (next === null) { + // No task with a later timeout was found, which means the new task has + // the latest timeout in the list. + next = firstDelayedTask; + } else if (next === firstDelayedTask) { + // The new task has the earliest expiration in the entire list. + firstDelayedTask = newTask; + } + + var previous = next.previous; + previous.next = next.previous = newTask; + newTask.next = next; + newTask.previous = previous; } - - return newTask; } function unstable_pauseExecution() { @@ -349,13 +470,17 @@ function unstable_cancelCallback(task) { return; } - if (next === task) { - // This is the only scheduled task. Clear the list. - firstTask = null; + if (task === next) { + if (task === firstTask) { + firstTask = null; + } else if (task === firstDelayedTask) { + firstDelayedTask = null; + } } else { - // Remove the task from its position in the list. if (task === firstTask) { firstTask = next; + } else if (task === firstDelayedTask) { + firstDelayedTask = next; } var previous = task.previous; previous.next = next; @@ -370,9 +495,12 @@ function unstable_getCurrentPriorityLevel() { } function unstable_shouldYield() { + const currentTime = getCurrentTime(); + advanceTimers(currentTime); return ( (currentTask !== null && firstTask !== null && + firstTask.startTime <= currentTime && firstTask.expirationTime < currentTask.expirationTime) || shouldYieldToHost() ); diff --git a/packages/scheduler/src/__tests__/Scheduler-test.js b/packages/scheduler/src/__tests__/Scheduler-test.js index fee2e97c45..4594baf5fb 100644 --- a/packages/scheduler/src/__tests__/Scheduler-test.js +++ b/packages/scheduler/src/__tests__/Scheduler-test.js @@ -395,4 +395,201 @@ describe('Scheduler', () => { ImmediatePriority, ]); }); + + describe('delayed tasks', () => { + it('schedules a delayed task', () => { + scheduleCallback(NormalPriority, () => Scheduler.yieldValue('A'), { + delay: 1000, + }); + + // Should flush nothing, because delay hasn't elapsed + expect(Scheduler).toFlushAndYield([]); + + // Advance time until right before the threshold + Scheduler.advanceTime(999); + // Still nothing + expect(Scheduler).toFlushAndYield([]); + + // Advance time past the threshold + Scheduler.advanceTime(1); + + // Now it should flush like normal + expect(Scheduler).toFlushAndYield(['A']); + }); + + it('schedules multiple delayed tasks', () => { + scheduleCallback(NormalPriority, () => Scheduler.yieldValue('C'), { + delay: 300, + }); + + scheduleCallback(NormalPriority, () => Scheduler.yieldValue('B'), { + delay: 200, + }); + + scheduleCallback(NormalPriority, () => Scheduler.yieldValue('D'), { + delay: 400, + }); + + scheduleCallback(NormalPriority, () => Scheduler.yieldValue('A'), { + delay: 100, + }); + + // Should flush nothing, because delay hasn't elapsed + expect(Scheduler).toFlushAndYield([]); + + // Advance some time. + Scheduler.advanceTime(200); + // Both A and B are no longer delayed. They can now flush incrementally. + expect(Scheduler).toFlushAndYieldThrough(['A']); + expect(Scheduler).toFlushAndYield(['B']); + + // Advance the rest + Scheduler.advanceTime(200); + expect(Scheduler).toFlushAndYield(['C', 'D']); + }); + + it('interleaves normal tasks and delayed tasks', () => { + // Schedule some high priority callbacks with a delay. When their delay + // elapses, they will be the most important callback in the queue. + scheduleCallback( + UserBlockingPriority, + () => Scheduler.yieldValue('Timer 2'), + {delay: 300}, + ); + scheduleCallback( + UserBlockingPriority, + () => Scheduler.yieldValue('Timer 1'), + {delay: 100}, + ); + + // Schedule some tasks at default priority. + scheduleCallback(NormalPriority, () => { + Scheduler.yieldValue('A'); + Scheduler.advanceTime(100); + }); + scheduleCallback(NormalPriority, () => { + Scheduler.yieldValue('B'); + Scheduler.advanceTime(100); + }); + scheduleCallback(NormalPriority, () => { + Scheduler.yieldValue('C'); + Scheduler.advanceTime(100); + }); + scheduleCallback(NormalPriority, () => { + Scheduler.yieldValue('D'); + Scheduler.advanceTime(100); + }); + + // Flush all the work. The timers should be interleaved with the + // other tasks. + expect(Scheduler).toFlushAndYield([ + 'A', + 'Timer 1', + 'B', + 'C', + 'Timer 2', + 'D', + ]); + }); + + it('interleaves delayed tasks with time-sliced tasks', () => { + // Schedule some high priority callbacks with a delay. When their delay + // elapses, they will be the most important callback in the queue. + scheduleCallback( + UserBlockingPriority, + () => Scheduler.yieldValue('Timer 2'), + {delay: 300}, + ); + scheduleCallback( + UserBlockingPriority, + () => Scheduler.yieldValue('Timer 1'), + {delay: 100}, + ); + + // Schedule a time-sliced task at default priority. + const tasks = [['A', 100], ['B', 100], ['C', 100], ['D', 100]]; + const work = () => { + while (tasks.length > 0) { + const task = tasks.shift(); + const [label, ms] = task; + Scheduler.advanceTime(ms); + Scheduler.yieldValue(label); + if (tasks.length > 0 && shouldYield()) { + return work; + } + } + }; + scheduleCallback(NormalPriority, work); + + // Flush all the work. The timers should be interleaved with the + // other tasks. + expect(Scheduler).toFlushAndYield([ + 'A', + 'Timer 1', + 'B', + 'C', + 'Timer 2', + 'D', + ]); + }); + + it('schedules callback with both delay and timeout', () => { + scheduleCallback( + NormalPriority, + () => { + Scheduler.yieldValue('A'); + Scheduler.advanceTime(100); + }, + {delay: 100, timeout: 900}, + ); + + Scheduler.advanceTime(99); + // Does not flush because delay has not elapsed + expect(Scheduler).toFlushAndYield([]); + + // Delay has elapsed but task has not expired + Scheduler.advanceTime(1); + expect(Scheduler).toFlushExpired([]); + + // Still not expired + Scheduler.advanceTime(899); + expect(Scheduler).toFlushExpired([]); + + // Now it expires + Scheduler.advanceTime(1); + expect(Scheduler).toHaveYielded(['A']); + }); + + it('cancels a delayed task', () => { + // Schedule several tasks with the same delay + const options = {delay: 100}; + + scheduleCallback( + NormalPriority, + () => Scheduler.yieldValue('A'), + options, + ); + const taskB = scheduleCallback( + NormalPriority, + () => Scheduler.yieldValue('B'), + options, + ); + const taskC = scheduleCallback( + NormalPriority, + () => Scheduler.yieldValue('C'), + options, + ); + + // Cancel B before its delay has elapsed + expect(Scheduler).toFlushAndYield([]); + cancelCallback(taskB); + + // Cancel C after its delay has elapsed + Scheduler.advanceTime(500); + cancelCallback(taskC); + + // Only A should flush + expect(Scheduler).toFlushAndYield(['A']); + }); + }); }); diff --git a/packages/scheduler/src/forks/SchedulerHostConfig.default.js b/packages/scheduler/src/forks/SchedulerHostConfig.default.js index 1078425caa..1f66de8a16 100644 --- a/packages/scheduler/src/forks/SchedulerHostConfig.default.js +++ b/packages/scheduler/src/forks/SchedulerHostConfig.default.js @@ -15,6 +15,8 @@ export let requestHostCallback; export let cancelHostCallback; +export let requestHostTimeout; +export let cancelHostTimeout; export let shouldYieldToHost; export let getCurrentTime; export let forceFrameRate; @@ -88,6 +90,7 @@ if ( // If this accidentally gets imported in a non-browser environment, e.g. JavaScriptCore, // fallback to a naive implementation. let _callback = null; + let _timeoutID = null; const _flushCallback = function() { if (_callback !== null) { try { @@ -113,6 +116,12 @@ if ( cancelHostCallback = function() { _callback = null; }; + requestHostTimeout = function(cb, ms) { + _timeoutID = setTimeout(cb, ms); + }; + cancelHostTimeout = function() { + clearTimeout(_timeoutID); + }; shouldYieldToHost = function() { return false; }; @@ -141,6 +150,8 @@ if ( let isAnimationFrameScheduled = false; + let timeoutID = -1; + let frameDeadline = 0; // We start out assuming that we run at 30fps but then the heuristic tracking // will adjust this value to a faster fps if we get more frequent animation @@ -269,4 +280,15 @@ if ( scheduledHostCallback = null; isMessageEventScheduled = false; }; + + requestHostTimeout = function(callback, ms) { + timeoutID = localSetTimeout(() => { + callback(getCurrentTime()); + }, ms); + }; + + cancelHostTimeout = function() { + localClearTimeout(timeoutID); + timeoutID = -1; + }; } diff --git a/packages/scheduler/src/forks/SchedulerHostConfig.mock.js b/packages/scheduler/src/forks/SchedulerHostConfig.mock.js index 51ad74b967..95d974e0c3 100644 --- a/packages/scheduler/src/forks/SchedulerHostConfig.mock.js +++ b/packages/scheduler/src/forks/SchedulerHostConfig.mock.js @@ -9,6 +9,8 @@ let currentTime: number = 0; let scheduledCallback: ((boolean, number) => void) | null = null; +let scheduledTimeout: (number => void) | null = null; +let timeoutTime: number = -1; let yieldedValues: Array | null = null; let expectedNumberOfYields: number = -1; let didStop: boolean = false; @@ -22,6 +24,16 @@ export function cancelHostCallback(): void { scheduledCallback = null; } +export function requestHostTimeout(callback: number => void, ms: number) { + scheduledTimeout = callback; + timeoutTime = currentTime + ms; +} + +export function cancelHostTimeout(): void { + scheduledTimeout = null; + timeoutTime = -1; +} + export function shouldYieldToHost(): boolean { if ( expectedNumberOfYields !== -1 && @@ -49,6 +61,8 @@ export function reset() { } currentTime = 0; scheduledCallback = null; + scheduledTimeout = null; + timeoutTime = -1; yieldedValues = null; expectedNumberOfYields = -1; didStop = false; @@ -159,6 +173,11 @@ export function yieldValue(value: mixed): void { export function advanceTime(ms: number) { currentTime += ms; if (!isFlushing) { + if (scheduledTimeout !== null && timeoutTime <= currentTime) { + scheduledTimeout(currentTime); + timeoutTime = -1; + scheduledTimeout = null; + } unstable_flushExpired(); } }