From 12688725f5c3e4b08c2ab8c8fed401c874dca6e1 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Mon, 24 Sep 2018 17:17:32 -0700 Subject: [PATCH] [scheduler] Priority levels, continuations, and wrapped callbacks All of these features are based on features of React's internal scheduler. The eventual goal is to lift as much as possible out of the React internals into the Scheduler package. Includes some renaming of existing methods. - `scheduleWork` is now `scheduleCallback` - `cancelScheduledWork` is now `cancelCallback` Priority levels --------------- Adds the ability to schedule callbacks at different priority levels. The current levels are (final names TBD): - Immediate priority. Fires at the end of the outermost currently executing (similar to a microtask). - Interactive priority. Fires within a few hundred milliseconds. This should only be used to provide quick feedback to the user as a result of an interaction. - Normal priority. This is the default. Fires within several seconds. - "Maybe" priority. Only fires if there's nothing else to do. Used for prerendering or warming a cache. The priority is changed using `runWithPriority`: ```js runWithPriority(InteractivePriority, () => { scheduleCallback(callback); }); ``` Continuations ------------- Adds the ability for a callback to yield without losing its place in the queue, by returning a continuation. The continuation will have the same expiration as the callback that yielded. Wrapped callbacks ----------------- Adds the ability to wrap a callback so that, when it is called, it receives the priority of the current execution context. --- packages/scheduler/src/Scheduler.js | 246 ++++++++++-- .../src/__tests__/Scheduler-test.internal.js | 354 ++++++++++++++++-- 2 files changed, 528 insertions(+), 72 deletions(-) diff --git a/packages/scheduler/src/Scheduler.js b/packages/scheduler/src/Scheduler.js index 07bade1aee5b2..80a29cdac7de0 100644 --- a/packages/scheduler/src/Scheduler.js +++ b/packages/scheduler/src/Scheduler.js @@ -8,14 +8,34 @@ /* eslint-disable no-var */ -// TODO: Currently there's only a single priority level, Deferred. Will add -// additional priorities. -var DEFERRED_TIMEOUT = 5000; +// TODO: Use symbols? +var ImmediatePriority = 1; +var InteractivePriority = 2; +var DefaultPriority = 3; +var MaybePriority = 4; + +// 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 INTERACTIVE_PRIORITY_TIMEOUT = 250; +var DEFAULT_PRIORITY_TIMEOUT = 5000; +// Never times out +var MAYBE_PRIORITY_TIMEOUT = maxSigned31BitInt; // Callbacks are stored as a circular, doubly linked list. var firstCallbackNode = null; -var isPerformingWork = false; +var currentPriorityLevel = DefaultPriority; +var currentEventStartTime = -1; +var currentExpirationTime = -1; + +// This is set when a callback is being executed, to prevent re-entrancy. +var isExecutingCallback = false; var isHostCallbackScheduled = false; @@ -25,6 +45,14 @@ var hasNativePerformanceNow = var timeRemaining; if (hasNativePerformanceNow) { timeRemaining = function() { + if ( + firstCallbackNode !== null && + firstCallbackNode.expirationTime < currentExpirationTime + ) { + // A higher priority callback was scheduled. Yield so we can switch to + // working on that. + return 0; + } // We assume that if we have a performance timer that the rAF callback // gets a performance timer value. Not sure if this is always true. var remaining = getFrameDeadline() - performance.now(); @@ -33,6 +61,12 @@ if (hasNativePerformanceNow) { } else { timeRemaining = function() { // Fallback to Date.now() + if ( + firstCallbackNode !== null && + firstCallbackNode.expirationTime < currentExpirationTime + ) { + return 0; + } var remaining = getFrameDeadline() - Date.now(); return remaining > 0 ? remaining : 0; }; @@ -44,22 +78,22 @@ var deadlineObject = { }; function ensureHostCallbackIsScheduled() { - if (isPerformingWork) { + if (isExecutingCallback) { // Don't schedule work yet; wait until the next time we yield. return; } - // Schedule the host callback using the earliest timeout in the list. - var timesOutAt = firstCallbackNode.timesOutAt; + // Schedule the host callback using the earliest expiration in the list. + var expirationTime = firstCallbackNode.expirationTime; if (!isHostCallbackScheduled) { isHostCallbackScheduled = true; } else { // Cancel the existing host callback. cancelCallback(); } - requestCallback(flushWork, timesOutAt); + requestCallback(flushWork, expirationTime); } -function flushFirstCallback(node) { +function flushFirstCallback() { var flushedNode = firstCallbackNode; // Remove the node from the list before calling the callback. That way the @@ -70,20 +104,101 @@ function flushFirstCallback(node) { firstCallbackNode = null; next = null; } else { - var previous = firstCallbackNode.previous; - firstCallbackNode = previous.next = next; - next.previous = previous; + var lastCallbackNode = firstCallbackNode.previous; + firstCallbackNode = lastCallbackNode.next = next; + next.previous = lastCallbackNode; } flushedNode.next = flushedNode.previous = null; // Now it's safe to call the callback. var callback = flushedNode.callback; - callback(deadlineObject); + var expirationTime = flushedNode.expirationTime; + var priorityLevel = flushedNode.priorityLevel; + var previousPriorityLevel = currentPriorityLevel; + var previousExpirationTime = currentExpirationTime; + currentPriorityLevel = priorityLevel; + currentExpirationTime = expirationTime; + var continuationCallback; + try { + continuationCallback = callback(deadlineObject); + } finally { + currentPriorityLevel = previousPriorityLevel; + currentExpirationTime = previousExpirationTime; + } + + if (typeof continuationCallback === 'function') { + var continuationNode: CallbackNode = { + callback: continuationCallback, + priorityLevel, + expirationTime, + next: null, + previous: null, + }; + + // Insert the new callback into the list, sorted by its timeout. + if (firstCallbackNode === null) { + // This is the first callback in the list. + firstCallbackNode = continuationNode.next = continuationNode.previous = continuationNode; + } else { + var nextAfterContinuation = null; + var node = firstCallbackNode; + do { + if (node.expirationTime >= expirationTime) { + // This callback is equal or lower priority than the new one. + nextAfterContinuation = node; + break; + } + node = node.next; + } while (node !== firstCallbackNode); + + if (nextAfterContinuation === null) { + // No equal or lower priority callback was found, which means the new + // callback is the lowest priority callback in the list. + nextAfterContinuation = firstCallbackNode; + } else if (nextAfterContinuation === firstCallbackNode) { + // The new callback is the highest priority callback in the list. + firstCallbackNode = continuationNode; + ensureHostCallbackIsScheduled(firstCallbackNode); + } + + var previous = nextAfterContinuation.previous; + previous.next = nextAfterContinuation.previous = continuationNode; + continuationNode.next = nextAfterContinuation; + continuationNode.previous = previous; + } + } +} + +function flushImmediateWork() { + if ( + currentEventStartTime === -1 && + firstCallbackNode !== null && + firstCallbackNode.priorityLevel === ImmediatePriority + ) { + isExecutingCallback = true; + deadlineObject.didTimeout = true; + try { + do { + flushFirstCallback(); + } while ( + firstCallbackNode !== null && + firstCallbackNode.priorityLevel === ImmediatePriority + ); + } finally { + isExecutingCallback = false; + if (firstCallbackNode !== null) { + // There's still work remaining. Request another callback. + ensureHostCallbackIsScheduled(firstCallbackNode); + } else { + isHostCallbackScheduled = false; + } + } + } } function flushWork(didTimeout) { - isPerformingWork = true; + isExecutingCallback = true; deadlineObject.didTimeout = didTimeout; try { if (didTimeout) { @@ -93,12 +208,12 @@ function flushWork(didTimeout) { // earlier than that time. Then read the current time again and repeat. // This optimizes for as few performance.now calls as possible. var currentTime = getCurrentTime(); - if (firstCallbackNode.timesOutAt <= currentTime) { + if (firstCallbackNode.expirationTime <= currentTime) { do { flushFirstCallback(); } while ( firstCallbackNode !== null && - firstCallbackNode.timesOutAt <= currentTime + firstCallbackNode.expirationTime <= currentTime ); continue; } @@ -116,36 +231,93 @@ function flushWork(didTimeout) { } } } finally { - isPerformingWork = false; + isExecutingCallback = false; if (firstCallbackNode !== null) { // There's still work remaining. Request another callback. ensureHostCallbackIsScheduled(firstCallbackNode); } else { isHostCallbackScheduled = false; } + flushImmediateWork(); + } +} + +function unstable_runWithPriority(eventHandler, priorityLevel) { + switch (priorityLevel) { + case ImmediatePriority: + case InteractivePriority: + case DefaultPriority: + case MaybePriority: + break; + default: + priorityLevel = DefaultPriority; + } + + var previousPriorityLevel = currentPriorityLevel; + var previousEventStartTime = currentEventStartTime; + currentPriorityLevel = priorityLevel; + currentEventStartTime = getCurrentTime(); + + try { + return eventHandler(); + } finally { + currentPriorityLevel = previousPriorityLevel; + currentEventStartTime = previousEventStartTime; + flushImmediateWork(); } } -function unstable_scheduleWork(callback, options) { - var currentTime = getCurrentTime(); +function unstable_wrap(callback) { + var parentPriorityLevel = currentPriorityLevel; + return function() { + var previousPriorityLevel = currentPriorityLevel; + var previousEventStartTime = currentEventStartTime; + currentPriorityLevel = parentPriorityLevel; + currentEventStartTime = getCurrentTime(); + + try { + return callback.apply(this, arguments); + } finally { + currentPriorityLevel = previousPriorityLevel; + currentEventStartTime = previousEventStartTime; + flushImmediateWork(); + } + }; +} - var timesOutAt; +function unstable_scheduleWork(callback, deprecated_options) { + var startTime = + currentEventStartTime !== -1 ? currentEventStartTime : getCurrentTime(); + + var expirationTime; if ( - options !== undefined && - options !== null && - options.timeout !== null && - options.timeout !== undefined + typeof deprecated_options === 'object' && + deprecated_options !== null && + typeof deprecated_options.timeout === 'number' ) { - // Check for an explicit timeout - timesOutAt = currentTime + options.timeout; + // FIXME: Remove this branch once we lift expiration times out of React. + expirationTime = startTime + deprecated_options.timeout; } else { - // Compute an absolute timeout using the default constant. - timesOutAt = currentTime + DEFERRED_TIMEOUT; + switch (currentPriorityLevel) { + case ImmediatePriority: + expirationTime = startTime + IMMEDIATE_PRIORITY_TIMEOUT; + break; + case InteractivePriority: + expirationTime = startTime + INTERACTIVE_PRIORITY_TIMEOUT; + break; + case MaybePriority: + expirationTime = startTime + MAYBE_PRIORITY_TIMEOUT; + break; + case DefaultPriority: + default: + expirationTime = startTime + DEFAULT_PRIORITY_TIMEOUT; + } } var newNode = { callback, - timesOutAt, + priorityLevel: currentPriorityLevel, + expirationTime, next: null, previous: null, }; @@ -159,8 +331,8 @@ function unstable_scheduleWork(callback, options) { var next = null; var node = firstCallbackNode; do { - if (node.timesOutAt > timesOutAt) { - // The new callback times out before this one. + if (node.expirationTime > expirationTime) { + // The new callback expires before this one. next = node; break; } @@ -168,11 +340,11 @@ function unstable_scheduleWork(callback, options) { } while (node !== firstCallbackNode); if (next === null) { - // No callback with a later timeout was found, which means the new - // callback has the latest timeout in the list. + // No callback with a later expiration was found, which means the new + // callback has the latest expiration in the list. next = firstCallbackNode; } else if (next === firstCallbackNode) { - // The new callback has the earliest timeout in the entire list. + // The new callback has the earliest expiration in the entire list. firstCallbackNode = newNode; ensureHostCallbackIsScheduled(firstCallbackNode); } @@ -299,6 +471,7 @@ if (typeof window === 'undefined') { getFrameDeadline = impl[2]; } else { if (typeof console !== 'undefined') { + // TODO: Remove fb.me link if (typeof localRequestAnimationFrame !== 'function') { console.error( "This browser doesn't support requestAnimationFrame. " + @@ -441,7 +614,12 @@ if (typeof window === 'undefined') { } export { + ImmediatePriority as unstable_ImmediatePriority, + InteractivePriority as unstable_InteractivePriority, + DefaultPriority as unstable_DefaultPriority, + unstable_runWithPriority, unstable_scheduleWork, unstable_cancelScheduledWork, + unstable_wrap, getCurrentTime as unstable_now, }; diff --git a/packages/scheduler/src/__tests__/Scheduler-test.internal.js b/packages/scheduler/src/__tests__/Scheduler-test.internal.js index eaaae4ec911ce..7cf8a466ff89c 100644 --- a/packages/scheduler/src/__tests__/Scheduler-test.internal.js +++ b/packages/scheduler/src/__tests__/Scheduler-test.internal.js @@ -9,8 +9,13 @@ 'use strict'; +let runWithPriority; +let ImmediatePriority; +let InteractivePriority; +// let DefaultPriority; let scheduleWork; let cancelScheduledWork; +let wrap; let flushWork; let advanceTime; let doWork; @@ -24,12 +29,16 @@ describe('Scheduler', () => { jest.resetModules(); let _flushWork = null; + let isFlushing = false; let timeoutID = -1; let endOfFrame = -1; let currentTime = 0; flushWork = frameSize => { + if (isFlushing) { + throw new Error('Already flushing work.'); + } if (frameSize === null || frameSize === undefined) { frameSize = Infinity; } @@ -39,8 +48,10 @@ describe('Scheduler', () => { timeoutID = -1; endOfFrame = currentTime + frameSize; try { - _flushWork(); + isFlushing = true; + _flushWork(false); } finally { + isFlushing = false; endOfFrame = -1; } const yields = yieldedValues; @@ -54,6 +65,9 @@ describe('Scheduler', () => { }; doWork = (label, timeCost) => { + if (typeof timeCost !== 'number') { + throw new Error('Second arg must be a number.'); + } advanceTime(timeCost); yieldValue(label); }; @@ -69,14 +83,30 @@ describe('Scheduler', () => { return yields; }; + function onTimeout() { + if (_flushWork === null) { + return; + } + if (isFlushing) { + // Jest fires timers synchronously when jest.advanceTimersByTime is + // called. Use setImmediate to prevent re-entrancy. + setImmediate(onTimeout); + } else { + try { + isFlushing = true; + _flushWork(true); + } finally { + isFlushing = false; + } + } + } + function requestCallback(fw, absoluteTimeout) { if (_flushWork !== null) { throw new Error('Work is already scheduled.'); } _flushWork = fw; - timeoutID = setTimeout(() => { - _flushWork(true); - }, absoluteTimeout - currentTime); + timeoutID = setTimeout(onTimeout, absoluteTimeout - currentTime); } function cancelCallback() { if (_flushWork === null) { @@ -91,12 +121,19 @@ describe('Scheduler', () => { // Override host implementation delete global.performance; - global.Date.now = () => currentTime; + global.Date.now = () => { + return currentTime; + }; window._schedMock = [requestCallback, cancelCallback, getTimeRemaining]; - const Scheduler = require('scheduler'); - scheduleWork = Scheduler.unstable_scheduleWork; - cancelScheduledWork = Scheduler.unstable_cancelScheduledWork; + const Schedule = require('scheduler'); + runWithPriority = Schedule.unstable_runWithPriority; + ImmediatePriority = Schedule.unstable_ImmediatePriority; + InteractivePriority = Schedule.unstable_InteractivePriority; + // DefaultPriority = Schedule.unstable_DefaultPriority; + scheduleWork = Schedule.unstable_scheduleWork; + cancelScheduledWork = Schedule.unstable_cancelScheduledWork; + wrap = Schedule.unstable_wrap; }); it('flushes work incrementally', () => { @@ -124,51 +161,292 @@ describe('Scheduler', () => { ]); }); - it('prioritizes callbacks according to their timeouts', () => { - scheduleWork(() => doWork('A', 10), {timeout: 5000}); - scheduleWork(() => doWork('B', 20), {timeout: 5000}); - scheduleWork(() => doWork('C', 30), {timeout: 1000}); - scheduleWork(() => doWork('D', 40), {timeout: 5000}); + it('executes the highest priority callbacks first', () => { + scheduleWork(() => doWork('A', 100)); + scheduleWork(() => doWork('B', 100)); + + // Yield before B is flushed + expect(flushWork(100)).toEqual(['A']); - // C should be first because it has the earliest timeout - expect(flushWork()).toEqual(['C', 'A', 'B', 'D']); + runWithPriority(() => { + scheduleWork(() => doWork('C', 100)); + scheduleWork(() => doWork('D', 100)); + }, InteractivePriority); + + // C and D should come first, because they are higher priority + expect(flushWork()).toEqual(['C', 'D', 'B']); }); - it('times out work', () => { - scheduleWork(() => doWork('A', 100), {timeout: 5000}); - scheduleWork(() => doWork('B', 200), {timeout: 5000}); - scheduleWork(() => doWork('C', 300), {timeout: 1000}); - scheduleWork(() => doWork('D', 400), {timeout: 5000}); + it('expires work', () => { + scheduleWork(() => doWork('A', 100)); + runWithPriority(() => { + scheduleWork(() => doWork('B', 100)); + }, InteractivePriority); + scheduleWork(() => doWork('C', 100)); + runWithPriority(() => { + scheduleWork(() => doWork('D', 100)); + }, InteractivePriority); - // Advance time, but not by enough to flush any work - advanceTime(999); + // Advance time, but not by enough to expire any work + advanceTime(249); expect(clearYieldedValues()).toEqual([]); - // Advance by just a bit more to flush C + // Advance by just a bit more to expire the high pri callbacks advanceTime(1); - expect(clearYieldedValues()).toEqual(['C']); + expect(clearYieldedValues()).toEqual(['B', 'D']); - // Flush the rest - advanceTime(4000); - expect(clearYieldedValues()).toEqual(['A', 'B', 'D']); + // Expire the rest + advanceTime(10000); + expect(clearYieldedValues()).toEqual(['A', 'C']); }); - it('has a default timeout of 5 seconds', () => { + it('has a default expiration of ~5 seconds', () => { scheduleWork(() => doWork('A', 100)); - scheduleWork(() => doWork('B', 200)); - scheduleWork(() => doWork('C', 300), {timeout: 1000}); - scheduleWork(() => doWork('D', 400)); - // Flush C - advanceTime(1000); - expect(clearYieldedValues()).toEqual(['C']); + advanceTime(4999); + expect(clearYieldedValues()).toEqual([]); + + advanceTime(1); + expect(clearYieldedValues()).toEqual(['A']); + }); + + it('continues working on same task after yielding', () => { + scheduleWork(() => doWork('A', 100)); + scheduleWork(() => doWork('B', 100)); + + const tasks = [['C1', 100], ['C2', 100], ['C3', 100]]; + const C = deadline => { + while (tasks.length > 0) { + doWork(...tasks.shift()); + if ( + tasks.length > 0 && + !deadline.didTimeout && + deadline.timeRemaining() <= 0 + ) { + yieldValue('Yield!'); + return C; + } + } + }; + + scheduleWork(C); + + scheduleWork(() => doWork('D', 100)); + scheduleWork(() => doWork('E', 100)); + + expect(flushWork(300)).toEqual(['A', 'B', 'C1', 'Yield!']); + + expect(flushWork()).toEqual(['C2', 'C3', 'D', 'E']); + }); + + it('continuation callbacks inherit the expiration of the previous callback', () => { + const tasks = [['A', 125], ['B', 125], ['C', 125], ['D', 125]]; + const work = deadline => { + while (tasks.length > 0) { + doWork(...tasks.shift()); + if ( + tasks.length > 0 && + !deadline.didTimeout && + deadline.timeRemaining() <= 0 + ) { + yieldValue('Yield!'); + return work; + } + } + }; + + // Schedule a high priority callback + runWithPriority(() => scheduleWork(work), InteractivePriority); - // Advance time until right before the rest of the work expires - advanceTime(3699); + // Flush until just before the expiration time + expect(flushWork(249)).toEqual(['A', 'B', 'Yield!']); + + // Advance time by just a bit more. This should expire all the remaining work. + advanceTime(1); + expect(clearYieldedValues()).toEqual(['C', 'D']); + }); + + it('nested callbacks inherit the priority of the currently executing callback', () => { + runWithPriority(() => { + scheduleWork(() => { + doWork('Parent callback', 100); + scheduleWork(() => { + doWork('Nested callback', 100); + }); + }); + }, InteractivePriority); + + expect(flushWork(100)).toEqual(['Parent callback']); + + // The nested callback has interactive priority, so it should + // expire quickly. + advanceTime(250 + 100); + expect(clearYieldedValues()).toEqual(['Nested callback']); + }); + + it('continuations are interrupted by higher priority work', () => { + const tasks = [['A', 100], ['B', 100], ['C', 100], ['D', 100]]; + const work = deadline => { + while (tasks.length > 0) { + doWork(...tasks.shift()); + if ( + tasks.length > 0 && + !deadline.didTimeout && + deadline.timeRemaining() <= 0 + ) { + yieldValue('Yield!'); + return work; + } + } + }; + scheduleWork(work); + expect(flushWork(100)).toEqual(['A', 'Yield!']); + + runWithPriority(() => { + scheduleWork(() => doWork('High pri', 100)); + }, InteractivePriority); + + expect(flushWork()).toEqual(['High pri', 'B', 'C', 'D']); + }); + + it( + 'continutations are interrupted by higher priority work scheduled ' + + 'inside an executing callback', + () => { + const tasks = [['A', 100], ['B', 100], ['C', 100], ['D', 100]]; + const work = deadline => { + while (tasks.length > 0) { + const task = tasks.shift(); + doWork(...task); + if (task[0] === 'B') { + // Schedule high pri work from inside another callback + yieldValue('Schedule high pri'); + runWithPriority( + () => scheduleWork(() => doWork('High pri', 100)), + InteractivePriority, + ); + } + if ( + tasks.length > 0 && + !deadline.didTimeout && + deadline.timeRemaining() <= 0 + ) { + yieldValue('Yield!'); + return work; + } + } + }; + scheduleWork(work); + expect(flushWork()).toEqual([ + 'A', + 'B', + 'Schedule high pri', + // Even though there's time left in the frame, the low pri callback + // should yield to the high pri callback + 'Yield!', + 'High pri', + // Continue low pri work + 'C', + 'D', + ]); + }, + ); + + it('immediate callbacks fire at the end of outermost event', () => { + runWithPriority(() => { + scheduleWork(() => yieldValue('A')); + scheduleWork(() => yieldValue('B')); + // Nested event + runWithPriority(() => { + scheduleWork(() => yieldValue('C')); + // Nothing should have fired yet + expect(clearYieldedValues()).toEqual([]); + }, ImmediatePriority); + // Nothing should have fired yet + expect(clearYieldedValues()).toEqual([]); + }, ImmediatePriority); + // The callbacks were called at the end of the outer event + expect(clearYieldedValues()).toEqual(['A', 'B', 'C']); + }); + + it('wrapped callbacks have same signature as original callback', () => { + const wrappedCallback = wrap((...args) => ({args})); + expect(wrappedCallback('a', 'b')).toEqual({args: ['a', 'b']}); + }); + + it('wrapped callbacks inherit the current priority', () => { + const wrappedCallback = wrap(() => { + scheduleWork(() => { + doWork('Normal', 100); + }); + }); + const wrappedInteractiveCallback = runWithPriority( + () => + wrap(() => { + scheduleWork(() => { + doWork('Interactive', 100); + }); + }), + InteractivePriority, + ); + + // This should schedule a normal callback + wrappedCallback(); + // This should schedule an interactive callback + wrappedInteractiveCallback(); + + advanceTime(249); expect(clearYieldedValues()).toEqual([]); + advanceTime(1); + expect(clearYieldedValues()).toEqual(['Interactive']); + + advanceTime(10000); + expect(clearYieldedValues()).toEqual(['Normal']); + }); - // Now advance by just a bit more + it('wrapped callbacks inherit the current even when nested', () => { + const wrappedCallback = wrap(() => { + scheduleWork(() => { + doWork('Normal', 100); + }); + }); + const wrappedInteractiveCallback = runWithPriority( + () => + wrap(() => { + scheduleWork(() => { + doWork('Interactive', 100); + }); + }), + InteractivePriority, + ); + + runWithPriority(() => { + // This should schedule a normal callback + wrappedCallback(); + // This should schedule an interactive callback + wrappedInteractiveCallback(); + }, InteractivePriority); + + advanceTime(249); + expect(clearYieldedValues()).toEqual([]); advanceTime(1); - expect(clearYieldedValues()).toEqual(['A', 'B', 'D']); + expect(clearYieldedValues()).toEqual(['Interactive']); + + advanceTime(10000); + expect(clearYieldedValues()).toEqual(['Normal']); + }); + + it('immediate callbacks fire at the end of callback', () => { + const immediateCallback = runWithPriority( + () => + wrap(() => { + scheduleWork(() => yieldValue('callback')); + }), + ImmediatePriority, + ); + immediateCallback(); + + // The callback was called at the end of the outer event + expect(clearYieldedValues()).toEqual(['callback']); }); });