/**
* Copyright (c) Meta Platforms, Inc. and 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
*/
'use strict';
let Scheduler;
let runWithPriority;
let ImmediatePriority;
let UserBlockingPriority;
let NormalPriority;
let LowPriority;
let IdlePriority;
let scheduleCallback;
let cancelCallback;
let wrapCallback;
let getCurrentPriorityLevel;
let shouldYield;
let waitForAll;
let assertLog;
let waitFor;
let waitForPaint;
describe('Scheduler', () => {
beforeEach(() => {
jest.resetModules();
jest.mock('scheduler', () => require('scheduler/unstable_mock'));
Scheduler = require('scheduler');
runWithPriority = Scheduler.unstable_runWithPriority;
ImmediatePriority = Scheduler.unstable_ImmediatePriority;
UserBlockingPriority = Scheduler.unstable_UserBlockingPriority;
NormalPriority = Scheduler.unstable_NormalPriority;
LowPriority = Scheduler.unstable_LowPriority;
IdlePriority = Scheduler.unstable_IdlePriority;
scheduleCallback = Scheduler.unstable_scheduleCallback;
cancelCallback = Scheduler.unstable_cancelCallback;
wrapCallback = Scheduler.unstable_wrapCallback;
getCurrentPriorityLevel = Scheduler.unstable_getCurrentPriorityLevel;
shouldYield = Scheduler.unstable_shouldYield;
const InternalTestUtils = require('internal-test-utils');
waitForAll = InternalTestUtils.waitForAll;
assertLog = InternalTestUtils.assertLog;
waitFor = InternalTestUtils.waitFor;
waitForPaint = InternalTestUtils.waitForPaint;
});
it('flushes work incrementally', async () => {
scheduleCallback(NormalPriority, () => Scheduler.log('A'));
scheduleCallback(NormalPriority, () => Scheduler.log('B'));
scheduleCallback(NormalPriority, () => Scheduler.log('C'));
scheduleCallback(NormalPriority, () => Scheduler.log('D'));
await waitFor(['A', 'B']);
await waitFor(['C']);
await waitForAll(['D']);
});
it('cancels work', async () => {
scheduleCallback(NormalPriority, () => Scheduler.log('A'));
const callbackHandleB = scheduleCallback(NormalPriority, () =>
Scheduler.log('B'),
);
scheduleCallback(NormalPriority, () => Scheduler.log('C'));
cancelCallback(callbackHandleB);
await waitForAll([
'A',
// B should have been cancelled
'C',
]);
});
it('executes the highest priority callbacks first', async () => {
scheduleCallback(NormalPriority, () => Scheduler.log('A'));
scheduleCallback(NormalPriority, () => Scheduler.log('B'));
// Yield before B is flushed
await waitFor(['A']);
scheduleCallback(UserBlockingPriority, () => Scheduler.log('C'));
scheduleCallback(UserBlockingPriority, () => Scheduler.log('D'));
// C and D should come first, because they are higher priority
await waitForAll(['C', 'D', 'B']);
});
it('expires work', async () => {
scheduleCallback(NormalPriority, didTimeout => {
Scheduler.unstable_advanceTime(100);
Scheduler.log(`A (did timeout: ${didTimeout})`);
});
scheduleCallback(UserBlockingPriority, didTimeout => {
Scheduler.unstable_advanceTime(100);
Scheduler.log(`B (did timeout: ${didTimeout})`);
});
scheduleCallback(UserBlockingPriority, didTimeout => {
Scheduler.unstable_advanceTime(100);
Scheduler.log(`C (did timeout: ${didTimeout})`);
});
// Advance time, but not by enough to expire any work
Scheduler.unstable_advanceTime(249);
assertLog([]);
// Schedule a few more callbacks
scheduleCallback(NormalPriority, didTimeout => {
Scheduler.unstable_advanceTime(100);
Scheduler.log(`D (did timeout: ${didTimeout})`);
});
scheduleCallback(NormalPriority, didTimeout => {
Scheduler.unstable_advanceTime(100);
Scheduler.log(`E (did timeout: ${didTimeout})`);
});
// Advance by just a bit more to expire the user blocking callbacks
Scheduler.unstable_advanceTime(1);
await waitFor(['B (did timeout: true)', 'C (did timeout: true)']);
// Expire A
Scheduler.unstable_advanceTime(4600);
await waitFor(['A (did timeout: true)']);
// Flush the rest without expiring
await waitForAll(['D (did timeout: false)', 'E (did timeout: true)']);
});
it('has a default expiration of ~5 seconds', () => {
scheduleCallback(NormalPriority, () => Scheduler.log('A'));
Scheduler.unstable_advanceTime(4999);
assertLog([]);
Scheduler.unstable_advanceTime(1);
Scheduler.unstable_flushExpired();
assertLog(['A']);
});
it('continues working on same task after yielding', async () => {
scheduleCallback(NormalPriority, () => {
Scheduler.unstable_advanceTime(100);
Scheduler.log('A');
});
scheduleCallback(NormalPriority, () => {
Scheduler.unstable_advanceTime(100);
Scheduler.log('B');
});
let didYield = false;
const tasks = [
['C1', 100],
['C2', 100],
['C3', 100],
];
const C = () => {
while (tasks.length > 0) {
const [label, ms] = tasks.shift();
Scheduler.unstable_advanceTime(ms);
Scheduler.log(label);
if (shouldYield()) {
didYield = true;
return C;
}
}
};
scheduleCallback(NormalPriority, C);
scheduleCallback(NormalPriority, () => {
Scheduler.unstable_advanceTime(100);
Scheduler.log('D');
});
scheduleCallback(NormalPriority, () => {
Scheduler.unstable_advanceTime(100);
Scheduler.log('E');
});
// Flush, then yield while in the middle of C.
expect(didYield).toBe(false);
await waitFor(['A', 'B', 'C1']);
expect(didYield).toBe(true);
// When we resume, we should continue working on C.
await waitForAll(['C2', 'C3', 'D', 'E']);
});
it('continuation callbacks inherit the expiration of the previous callback', async () => {
const tasks = [
['A', 125],
['B', 124],
['C', 100],
['D', 100],
];
const work = () => {
while (tasks.length > 0) {
const [label, ms] = tasks.shift();
Scheduler.unstable_advanceTime(ms);
Scheduler.log(label);
if (shouldYield()) {
return work;
}
}
};
// Schedule a high priority callback
scheduleCallback(UserBlockingPriority, work);
// Flush until just before the expiration time
await waitFor(['A', 'B']);
// Advance time by just a bit more. This should expire all the remaining work.
Scheduler.unstable_advanceTime(1);
Scheduler.unstable_flushExpired();
assertLog(['C', 'D']);
});
it('continuations are interrupted by higher priority work', async () => {
const tasks = [
['A', 100],
['B', 100],
['C', 100],
['D', 100],
];
const work = () => {
while (tasks.length > 0) {
const [label, ms] = tasks.shift();
Scheduler.unstable_advanceTime(ms);
Scheduler.log(label);
if (tasks.length > 0 && shouldYield()) {
return work;
}
}
};
scheduleCallback(NormalPriority, work);
await waitFor(['A']);
scheduleCallback(UserBlockingPriority, () => {
Scheduler.unstable_advanceTime(100);
Scheduler.log('High pri');
});
await waitForAll(['High pri', 'B', 'C', 'D']);
});
it(
'continuations do not block higher priority work scheduled ' +
'inside an executing callback',
async () => {
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.unstable_advanceTime(ms);
Scheduler.log(label);
if (label === 'B') {
// Schedule high pri work from inside another callback
Scheduler.log('Schedule high pri');
scheduleCallback(UserBlockingPriority, () => {
Scheduler.unstable_advanceTime(100);
Scheduler.log('High pri');
});
}
if (tasks.length > 0) {
// Return a continuation
return work;
}
}
};
scheduleCallback(NormalPriority, work);
await waitForAll([
'A',
'B',
'Schedule high pri',
// The high pri callback should fire before the continuation of the
// lower pri work
'High pri',
// Continue low pri work
'C',
'D',
]);
},
);
it('cancelling a continuation', async () => {
const task = scheduleCallback(NormalPriority, () => {
Scheduler.log('Yield');
return () => {
Scheduler.log('Continuation');
};
});
await waitFor(['Yield']);
cancelCallback(task);
await waitForAll([]);
});
it('top-level immediate callbacks fire in a subsequent task', () => {
scheduleCallback(ImmediatePriority, () => Scheduler.log('A'));
scheduleCallback(ImmediatePriority, () => Scheduler.log('B'));
scheduleCallback(ImmediatePriority, () => Scheduler.log('C'));
scheduleCallback(ImmediatePriority, () => Scheduler.log('D'));
// Immediate callback hasn't fired, yet.
assertLog([]);
// They all flush immediately within the subsequent task.
Scheduler.unstable_flushExpired();
assertLog(['A', 'B', 'C', 'D']);
});
it('nested immediate callbacks are added to the queue of immediate callbacks', () => {
scheduleCallback(ImmediatePriority, () => Scheduler.log('A'));
scheduleCallback(ImmediatePriority, () => {
Scheduler.log('B');
// This callback should go to the end of the queue
scheduleCallback(ImmediatePriority, () => Scheduler.log('C'));
});
scheduleCallback(ImmediatePriority, () => Scheduler.log('D'));
assertLog([]);
// C should flush at the end
Scheduler.unstable_flushExpired();
assertLog(['A', 'B', 'D', 'C']);
});
it('wrapped callbacks have same signature as original callback', () => {
const wrappedCallback = wrapCallback((...args) => ({args}));
expect(wrappedCallback('a', 'b')).toEqual({args: ['a', 'b']});
});
it('wrapped callbacks inherit the current priority', () => {
const wrappedCallback = runWithPriority(NormalPriority, () =>
wrapCallback(() => {
Scheduler.log(getCurrentPriorityLevel());
}),
);
const wrappedUserBlockingCallback = runWithPriority(
UserBlockingPriority,
() =>
wrapCallback(() => {
Scheduler.log(getCurrentPriorityLevel());
}),
);
wrappedCallback();
assertLog([NormalPriority]);
wrappedUserBlockingCallback();
assertLog([UserBlockingPriority]);
});
it('wrapped callbacks inherit the current priority even when nested', () => {
let wrappedCallback;
let wrappedUserBlockingCallback;
runWithPriority(NormalPriority, () => {
wrappedCallback = wrapCallback(() => {
Scheduler.log(getCurrentPriorityLevel());
});
wrappedUserBlockingCallback = runWithPriority(UserBlockingPriority, () =>
wrapCallback(() => {
Scheduler.log(getCurrentPriorityLevel());
}),
);
});
wrappedCallback();
assertLog([NormalPriority]);
wrappedUserBlockingCallback();
assertLog([UserBlockingPriority]);
});
it("immediate callbacks fire even if there's an error", () => {
scheduleCallback(ImmediatePriority, () => {
Scheduler.log('A');
throw new Error('Oops A');
});
scheduleCallback(ImmediatePriority, () => {
Scheduler.log('B');
});
scheduleCallback(ImmediatePriority, () => {
Scheduler.log('C');
throw new Error('Oops C');
});
expect(() => Scheduler.unstable_flushExpired()).toThrow('Oops A');
assertLog(['A']);
// B and C flush in a subsequent event. That way, the second error is not
// swallowed.
expect(() => Scheduler.unstable_flushExpired()).toThrow('Oops C');
assertLog(['B', 'C']);
});
it('multiple immediate callbacks can throw and there will be an error for each one', () => {
scheduleCallback(ImmediatePriority, () => {
throw new Error('First error');
});
scheduleCallback(ImmediatePriority, () => {
throw new Error('Second error');
});
expect(() => Scheduler.unstable_flushAll()).toThrow('First error');
// The next error is thrown in the subsequent event
expect(() => Scheduler.unstable_flushAll()).toThrow('Second error');
});
it('exposes the current priority level', () => {
Scheduler.log(getCurrentPriorityLevel());
runWithPriority(ImmediatePriority, () => {
Scheduler.log(getCurrentPriorityLevel());
runWithPriority(NormalPriority, () => {
Scheduler.log(getCurrentPriorityLevel());
runWithPriority(UserBlockingPriority, () => {
Scheduler.log(getCurrentPriorityLevel());
});
});
Scheduler.log(getCurrentPriorityLevel());
});
assertLog([
NormalPriority,
ImmediatePriority,
NormalPriority,
UserBlockingPriority,
ImmediatePriority,
]);
});
if (__DEV__) {
// Function names are minified in prod, though you could still infer the
// priority if you have sourcemaps.
// TODO: Feature temporarily disabled while we investigate a bug in one of
// our minifiers.
it.skip('adds extra function to the JS stack whose name includes the priority level', async () => {
function inferPriorityFromCallstack() {
try {
throw Error();
} catch (e) {
const stack = e.stack;
const lines = stack.split('\n');
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
const found = line.match(
/scheduler_flushTaskAtPriority_([A-Za-z]+)/,
);
if (found !== null) {
const priorityStr = found[1];
switch (priorityStr) {
case 'Immediate':
return ImmediatePriority;
case 'UserBlocking':
return UserBlockingPriority;
case 'Normal':
return NormalPriority;
case 'Low':
return LowPriority;
case 'Idle':
return IdlePriority;
}
}
}
return null;
}
}
scheduleCallback(ImmediatePriority, () =>
Scheduler.log('Immediate: ' + inferPriorityFromCallstack()),
);
scheduleCallback(UserBlockingPriority, () =>
Scheduler.log('UserBlocking: ' + inferPriorityFromCallstack()),
);
scheduleCallback(NormalPriority, () =>
Scheduler.log('Normal: ' + inferPriorityFromCallstack()),
);
scheduleCallback(LowPriority, () =>
Scheduler.log('Low: ' + inferPriorityFromCallstack()),
);
scheduleCallback(IdlePriority, () =>
Scheduler.log('Idle: ' + inferPriorityFromCallstack()),
);
await waitForAll([
'Immediate: ' + ImmediatePriority,
'UserBlocking: ' + UserBlockingPriority,
'Normal: ' + NormalPriority,
'Low: ' + LowPriority,
'Idle: ' + IdlePriority,
]);
});
}
describe('delayed tasks', () => {
it('schedules a delayed task', async () => {
scheduleCallback(NormalPriority, () => Scheduler.log('A'), {
delay: 1000,
});
// Should flush nothing, because delay hasn't elapsed
await waitForAll([]);
// Advance time until right before the threshold
Scheduler.unstable_advanceTime(999);
// Still nothing
await waitForAll([]);
// Advance time past the threshold
Scheduler.unstable_advanceTime(1);
// Now it should flush like normal
await waitForAll(['A']);
});
it('schedules multiple delayed tasks', async () => {
scheduleCallback(NormalPriority, () => Scheduler.log('C'), {
delay: 300,
});
scheduleCallback(NormalPriority, () => Scheduler.log('B'), {
delay: 200,
});
scheduleCallback(NormalPriority, () => Scheduler.log('D'), {
delay: 400,
});
scheduleCallback(NormalPriority, () => Scheduler.log('A'), {
delay: 100,
});
// Should flush nothing, because delay hasn't elapsed
await waitForAll([]);
// Advance some time.
Scheduler.unstable_advanceTime(200);
// Both A and B are no longer delayed. They can now flush incrementally.
await waitFor(['A']);
await waitForAll(['B']);
// Advance the rest
Scheduler.unstable_advanceTime(200);
await waitForAll(['C', 'D']);
});
it('interleaves normal tasks and delayed tasks', async () => {
// 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.log('Timer 2'), {
delay: 300,
});
scheduleCallback(UserBlockingPriority, () => Scheduler.log('Timer 1'), {
delay: 100,
});
// Schedule some tasks at default priority.
scheduleCallback(NormalPriority, () => {
Scheduler.log('A');
Scheduler.unstable_advanceTime(100);
});
scheduleCallback(NormalPriority, () => {
Scheduler.log('B');
Scheduler.unstable_advanceTime(100);
});
scheduleCallback(NormalPriority, () => {
Scheduler.log('C');
Scheduler.unstable_advanceTime(100);
});
scheduleCallback(NormalPriority, () => {
Scheduler.log('D');
Scheduler.unstable_advanceTime(100);
});
// Flush all the work. The timers should be interleaved with the
// other tasks.
await waitForAll(['A', 'Timer 1', 'B', 'C', 'Timer 2', 'D']);
});
it('interleaves delayed tasks with time-sliced tasks', async () => {
// 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.log('Timer 2'), {
delay: 300,
});
scheduleCallback(UserBlockingPriority, () => Scheduler.log('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.unstable_advanceTime(ms);
Scheduler.log(label);
if (tasks.length > 0) {
return work;
}
}
};
scheduleCallback(NormalPriority, work);
// Flush all the work. The timers should be interleaved with the
// other tasks.
await waitForAll(['A', 'Timer 1', 'B', 'C', 'Timer 2', 'D']);
});
it('cancels a delayed task', async () => {
// Schedule several tasks with the same delay
const options = {delay: 100};
scheduleCallback(NormalPriority, () => Scheduler.log('A'), options);
const taskB = scheduleCallback(
NormalPriority,
() => Scheduler.log('B'),
options,
);
const taskC = scheduleCallback(
NormalPriority,
() => Scheduler.log('C'),
options,
);
// Cancel B before its delay has elapsed
await waitForAll([]);
cancelCallback(taskB);
// Cancel C after its delay has elapsed
Scheduler.unstable_advanceTime(500);
cancelCallback(taskC);
// Only A should flush
await waitForAll(['A']);
});
it('gracefully handles scheduled tasks that are not a function', async () => {
scheduleCallback(ImmediatePriority, null);
await waitForAll([]);
scheduleCallback(ImmediatePriority, undefined);
await waitForAll([]);
scheduleCallback(ImmediatePriority, {});
await waitForAll([]);
scheduleCallback(ImmediatePriority, 42);
await waitForAll([]);
});
it('toFlushUntilNextPaint stops if a continuation is returned', async () => {
scheduleCallback(NormalPriority, () => {
Scheduler.log('Original Task');
Scheduler.log('shouldYield: ' + shouldYield());
Scheduler.log('Return a continuation');
return () => {
Scheduler.log('Continuation Task');
};
});
await waitForPaint([
'Original Task',
// Immediately before returning a continuation, `shouldYield` returns
// false, which means there must be time remaining in the frame.
'shouldYield: false',
'Return a continuation',
// The continuation should not flush yet.
]);
// No time has elapsed
expect(Scheduler.unstable_now()).toBe(0);
// Continue the task
await waitForAll(['Continuation Task']);
});
it("toFlushAndYield keeps flushing even if there's a continuation", async () => {
scheduleCallback(NormalPriority, () => {
Scheduler.log('Original Task');
Scheduler.log('shouldYield: ' + shouldYield());
Scheduler.log('Return a continuation');
return () => {
Scheduler.log('Continuation Task');
};
});
await waitForAll([
'Original Task',
// Immediately before returning a continuation, `shouldYield` returns
// false, which means there must be time remaining in the frame.
'shouldYield: false',
'Return a continuation',
// The continuation should flush immediately, even though the task
// yielded a continuation.
'Continuation Task',
]);
});
});
});