diff --git a/packages/react-dom/src/__tests__/ReactUpdates-test.js b/packages/react-dom/src/__tests__/ReactUpdates-test.js index 66a3a899072c..8e23a6d5c161 100644 --- a/packages/react-dom/src/__tests__/ReactUpdates-test.js +++ b/packages/react-dom/src/__tests__/ReactUpdates-test.js @@ -1631,6 +1631,7 @@ describe('ReactUpdates', () => { ReactDOM.render(, container); while (error === null) { Scheduler.unstable_flushNumberOfYields(1); + Scheduler.unstable_clearYields(); } expect(error).toContain('Warning: Maximum update depth exceeded.'); expect(stack).toContain('in NonTerminating'); @@ -1653,9 +1654,9 @@ describe('ReactUpdates', () => { React.useEffect(() => { if (step < LIMIT) { setStep(x => x + 1); - Scheduler.unstable_yieldValue(step); } }); + Scheduler.unstable_yieldValue(step); return step; } @@ -1663,24 +1664,11 @@ describe('ReactUpdates', () => { act(() => { ReactDOM.render(, container); }); - - // Verify we can flush them asynchronously without warning - for (let i = 0; i < LIMIT * 2; i++) { - Scheduler.unstable_flushNumberOfYields(1); - } expect(container.textContent).toBe('50'); - - // Verify restarting from 0 doesn't cross the limit act(() => { _setStep(0); - // flush once to update the dom - Scheduler.unstable_flushNumberOfYields(1); - expect(container.textContent).toBe('0'); - for (let i = 0; i < LIMIT * 2; i++) { - Scheduler.unstable_flushNumberOfYields(1); - } - expect(container.textContent).toBe('50'); }); + expect(container.textContent).toBe('50'); }); it('can have many updates inside useEffect without triggering a warning', () => { diff --git a/packages/scheduler/src/Scheduler.js b/packages/scheduler/src/Scheduler.js index cdb9930d4c97..ea0961bec342 100644 --- a/packages/scheduler/src/Scheduler.js +++ b/packages/scheduler/src/Scheduler.js @@ -18,6 +18,7 @@ import { forceFrameRate, requestPaint, } from './SchedulerHostConfig'; +import {push, pop, peek} from './SchedulerMinHeap'; // TODO: Use symbols? var ImmediatePriority = 1; @@ -40,9 +41,12 @@ var LOW_PRIORITY_TIMEOUT = 10000; // Never times out var IDLE_PRIORITY = maxSigned31BitInt; -// Tasks are stored as a circular, doubly linked list. -var firstTask = null; -var firstDelayedTask = null; +// Tasks are stored on a min heap +var taskQueue = []; +var timerQueue = []; + +// Incrementing id counter. Used to maintain insertion order. +var taskIdCounter = 0; // Pausing the scheduler is useful for debugging. var isSchedulerPaused = false; @@ -72,35 +76,16 @@ function scheduler_flushTaskAtPriority_Idle(callback, didTimeout) { return callback(didTimeout); } -function flushTask(task, currentTime) { - // Remove the task from the list before calling the callback. That way the - // list is in a consistent state even if the callback throws. - const next = task.next; - if (next === task) { - // This is the only scheduled task. Clear the list. - firstTask = null; - } else { - // Remove the task from its position in the list. - if (task === firstTask) { - firstTask = next; - } - const previous = task.previous; - previous.next = next; - next.previous = previous; - } - task.next = task.previous = null; - - // Now it's safe to execute the task. - var callback = task.callback; +function flushTask(task, callback, currentTime) { var previousPriorityLevel = currentPriorityLevel; var previousTask = currentTask; currentPriorityLevel = task.priorityLevel; currentTask = task; - var continuationCallback; try { var didUserCallbackTimeout = task.expirationTime <= currentTime; // Add an extra function to the callstack. Profiling tools can use this // to infer the priority of work that appears higher in the stack. + var continuationCallback; switch (currentPriorityLevel) { case ImmediatePriority: continuationCallback = scheduler_flushTaskAtPriority_Immediate( @@ -133,76 +118,32 @@ function flushTask(task, currentTime) { ); break; } - } catch (error) { - throw error; + return typeof continuationCallback === 'function' + ? continuationCallback + : null; } finally { currentPriorityLevel = previousPriorityLevel; currentTask = previousTask; } - - // A callback may return a continuation. The continuation should be scheduled - // with the same priority and expiration as the just-finished callback. - if (typeof continuationCallback === 'function') { - var expirationTime = task.expirationTime; - var continuationTask = task; - continuationTask.callback = continuationCallback; - - // Insert the new callback into the list, sorted by its timeout. This is - // almost the same as the code in `scheduleCallback`, except the callback - // is inserted into the list *before* callbacks of equal timeout instead - // of after. - if (firstTask === null) { - // This is the first callback in the list. - firstTask = continuationTask.next = continuationTask.previous = continuationTask; - } else { - var nextAfterContinuation = null; - var t = firstTask; - do { - if (expirationTime <= t.expirationTime) { - // This task times out at or after the continuation. We will insert - // the continuation *before* this task. - nextAfterContinuation = t; - break; - } - t = t.next; - } while (t !== firstTask); - if (nextAfterContinuation === null) { - // No equal or lower priority task was found, which means the new task - // is the lowest priority task in the list. - nextAfterContinuation = firstTask; - } else if (nextAfterContinuation === firstTask) { - // The new task is the highest priority task in the list. - firstTask = continuationTask; - } - - const previous = nextAfterContinuation.previous; - previous.next = nextAfterContinuation.previous = continuationTask; - continuationTask.next = nextAfterContinuation; - continuationTask.previous = previous; - } - } } 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 - ); + 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); + } else { + // Remaining timers are pending. + return; + } + timer = peek(timerQueue); } } @@ -211,24 +152,19 @@ function handleTimeout(currentTime) { advanceTimers(currentTime); if (!isHostCallbackScheduled) { - if (firstTask !== null) { + if (peek(taskQueue) !== null) { isHostCallbackScheduled = true; requestHostCallback(flushWork); - } else if (firstDelayedTask !== null) { - requestHostTimeout( - handleTimeout, - firstDelayedTask.startTime - currentTime, - ); + } else { + const firstTimer = peek(timerQueue); + if (firstTimer !== null) { + requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); + } } } } function flushWork(hasTimeRemaining, initialTime) { - // Exit right away if we're currently paused - if (enableSchedulerDebugging && isSchedulerPaused) { - return; - } - // We'll need a host callback the next time work is scheduled. isHostCallbackScheduled = false; if (isHostTimeoutScheduled) { @@ -237,47 +173,44 @@ function flushWork(hasTimeRemaining, initialTime) { cancelHostTimeout(); } - let currentTime = initialTime; - advanceTimers(currentTime); - isPerformingWork = true; try { - if (!hasTimeRemaining) { - // Flush all the expired callbacks without yielding. - // TODO: Split flushWork into two separate functions instead of using - // a boolean argument? - while ( - firstTask !== null && - firstTask.expirationTime <= currentTime && - !(enableSchedulerDebugging && isSchedulerPaused) + let currentTime = initialTime; + advanceTimers(currentTime); + let task = peek(taskQueue); + while (task !== null && !(enableSchedulerDebugging && isSchedulerPaused)) { + if ( + task.expirationTime > currentTime && + (!hasTimeRemaining || shouldYieldToHost()) ) { - flushTask(firstTask, currentTime); + // This task hasn't expired, and we've reached the deadline. + break; + } + const callback = task.callback; + if (callback !== null) { + task.callback = null; + const continuation = flushTask(task, callback, currentTime); + if (continuation !== null) { + task.callback = continuation; + } else { + if (task === peek(taskQueue)) { + pop(taskQueue); + } + } currentTime = getCurrentTime(); advanceTimers(currentTime); + } else { + pop(taskQueue); } - } else { - // Keep flushing callbacks until we run out of time in the frame. - if (firstTask !== null) { - do { - flushTask(firstTask, currentTime); - currentTime = getCurrentTime(); - advanceTimers(currentTime); - } while ( - firstTask !== null && - !shouldYieldToHost() && - !(enableSchedulerDebugging && isSchedulerPaused) - ); - } + task = peek(taskQueue); } // Return whether there's additional work - if (firstTask !== null) { + if (task !== null) { return true; } else { - if (firstDelayedTask !== null) { - requestHostTimeout( - handleTimeout, - firstDelayedTask.startTime - currentTime, - ); + let firstTimer = peek(timerQueue); + if (firstTimer !== null) { + requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); } return false; } @@ -388,18 +321,19 @@ function unstable_scheduleCallback(priorityLevel, callback, options) { var expirationTime = startTime + timeout; var newTask = { + id: taskIdCounter++, callback, priorityLevel, startTime, expirationTime, - next: null, - previous: null, + sortIndex: -1, }; if (startTime > currentTime) { // This is a delayed task. - insertDelayedTask(newTask, startTime); - if (firstTask === null && firstDelayedTask === newTask) { + 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. @@ -411,7 +345,8 @@ function unstable_scheduleCallback(priorityLevel, callback, options) { requestHostTimeout(handleTimeout, startTime - currentTime); } } else { - insertScheduledTask(newTask, expirationTime); + newTask.sortIndex = expirationTime; + push(taskQueue, newTask); // Schedule a host callback, if needed. If we're already performing work, // wait until the next time we yield. if (!isHostCallbackScheduled && !isPerformingWork) { @@ -423,74 +358,6 @@ function unstable_scheduleCallback(priorityLevel, callback, options) { 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 - if (firstTask === null) { - // This is the first task in the list. - firstTask = newTask.next = newTask.previous = newTask; - } else { - var next = null; - var task = firstTask; - do { - if (expirationTime < task.expirationTime) { - // The new task times out before this one. - next = task; - break; - } - task = task.next; - } while (task !== firstTask); - - if (next === null) { - // No task with a later timeout was found, which means the new task has - // the latest timeout in the list. - next = firstTask; - } else if (next === firstTask) { - // The new task has the earliest expiration in the entire list. - firstTask = newTask; - } - - var previous = next.previous; - previous.next = next.previous = newTask; - newTask.next = next; - newTask.previous = previous; - } -} - -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; - } -} - function unstable_pauseExecution() { isSchedulerPaused = true; } @@ -504,34 +371,14 @@ function unstable_continueExecution() { } function unstable_getFirstCallbackNode() { - return firstTask; + return peek(taskQueue); } function unstable_cancelCallback(task) { - var next = task.next; - if (next === null) { - // Already cancelled. - return; - } - - if (task === next) { - if (task === firstTask) { - firstTask = null; - } else if (task === firstDelayedTask) { - firstDelayedTask = null; - } - } else { - if (task === firstTask) { - firstTask = next; - } else if (task === firstDelayedTask) { - firstDelayedTask = next; - } - var previous = task.previous; - previous.next = next; - next.previous = previous; - } - - task.next = task.previous = null; + // 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() { @@ -541,9 +388,12 @@ function unstable_getCurrentPriorityLevel() { function unstable_shouldYield() { const currentTime = getCurrentTime(); advanceTimers(currentTime); + const firstTask = peek(taskQueue); return ( - (currentTask !== null && + (firstTask !== currentTask && + currentTask !== null && firstTask !== null && + firstTask.callback !== null && firstTask.startTime <= currentTime && firstTask.expirationTime < currentTask.expirationTime) || shouldYieldToHost() diff --git a/packages/scheduler/src/SchedulerMinHeap.js b/packages/scheduler/src/SchedulerMinHeap.js new file mode 100644 index 000000000000..e74d5d6853b1 --- /dev/null +++ b/packages/scheduler/src/SchedulerMinHeap.js @@ -0,0 +1,91 @@ +/** + * 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 + */ + +type Heap = Array; +type Node = { + id: number, + sortIndex: number, +}; + +export function push(heap: Heap, node: Node): void { + const index = heap.length; + heap.push(node); + siftUp(heap, node, index); +} + +export function peek(heap: Heap): Node | null { + const first = heap[0]; + return first === undefined ? null : first; +} + +export function pop(heap: Heap): Node | null { + const first = heap[0]; + if (first !== undefined) { + const last = heap.pop(); + if (last !== first) { + heap[0] = last; + siftDown(heap, last, 0); + } + return first; + } else { + return null; + } +} + +function siftUp(heap, node, index) { + while (true) { + const parentIndex = Math.floor((index - 1) / 2); + const parent = heap[parentIndex]; + if (parent !== undefined && compare(parent, node) > 0) { + // The parent is larger. Swap positions. + heap[parentIndex] = node; + heap[index] = parent; + index = parentIndex; + } else { + // The parent is smaller. Exit. + return; + } + } +} + +function siftDown(heap, node, index) { + const length = heap.length; + while (index < length) { + const leftIndex = (index + 1) * 2 - 1; + const left = heap[leftIndex]; + const rightIndex = leftIndex + 1; + const right = heap[rightIndex]; + + // If the left or right node is smaller, swap with the smaller of those. + if (left !== undefined && compare(left, node) < 0) { + if (right !== undefined && compare(right, left) < 0) { + heap[index] = right; + heap[rightIndex] = node; + index = rightIndex; + } else { + heap[index] = left; + heap[leftIndex] = node; + index = leftIndex; + } + } else if (right !== undefined && compare(right, node) < 0) { + heap[index] = right; + heap[rightIndex] = node; + index = rightIndex; + } else { + // Neither child is smaller. Exit. + return; + } + } +} + +function compare(a, b) { + // Compare sort index first, then task id. + const diff = a.sortIndex - b.sortIndex; + return diff !== 0 ? diff : a.id - b.id; +}