Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use setImmediate when available over MessageChannel #20834

Merged
merged 10 commits into from Feb 17, 2021
264 changes: 264 additions & 0 deletions packages/scheduler/src/__tests__/SchedulerDOMSetImmediate-test.js
@@ -0,0 +1,264 @@
/**
* 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;

// The Scheduler implementation uses browser APIs like `MessageChannel` and
// `setTimeout` to schedule work on the main thread. Most of our tests treat
// these as implementation details; however, the sequence and timing of these
// APIs are not precisely specified, and can vary across browsers.
//
// To prevent regressions, we need the ability to simulate specific edge cases
// that we may encounter in various browsers.
//
// This test suite mocks all browser methods used in our implementation. It
// assumes as little as possible about the order and timing of events.
describe('SchedulerDOMSetImmediate', () => {
beforeEach(() => {
jest.resetModules();

// Un-mock scheduler
jest.mock('scheduler', () => require.requireActual('scheduler'));

runtime = installMockBrowserRuntime();
performance = global.performance;
Scheduler = require('scheduler');
cancelCallback = Scheduler.unstable_cancelCallback;
scheduleCallback = Scheduler.unstable_scheduleCallback;
NormalPriority = Scheduler.unstable_NormalPriority;
});

afterEach(() => {
delete global.performance;

if (!runtime.isLogEmpty()) {
throw Error('Test exited without clearing log.');
}
});

function installMockBrowserRuntime() {
let timerIDCounter = 0;
// let timerIDs = new Map();

let eventLog = [];

let currentTime = 0;

global.performance = {
now() {
return currentTime;
},
};

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 = () => {};

window.setTimeout = (cb, delay) => {
const id = timerIDCounter++;
log(`Set Timer`);
// TODO
return id;
};
window.clearTimeout = id => {
// TODO
};

// Unused: we expect setImmediate to be preferred.
window.MessageChannel = function() {};

let pendingSetImmediateCallback = null;
window.setImmediate = function(cb) {
if (pendingSetImmediateCallback) {
throw Error('Message event already scheduled');
}
log('Set Immediate');
pendingSetImmediateCallback = cb;
};

function ensureLogIsEmpty() {
if (eventLog.length !== 0) {
throw Error('Log is not empty. Call assertLog before continuing.');
}
}
function advanceTime(ms) {
currentTime += ms;
}
function fireSetImmediate() {
ensureLogIsEmpty();
if (!pendingSetImmediateCallback) {
throw Error('No setImmediate was scheduled');
}
const cb = pendingSetImmediateCallback;
pendingSetImmediateCallback = null;
log('setImmediate Callback');
cb();
}
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,
fireSetImmediate,
log,
isLogEmpty,
assertLog,
};
}

it('task that finishes before deadline', () => {
scheduleCallback(NormalPriority, () => {
runtime.log('Task');
});
runtime.assertLog(['Set Immediate']);
runtime.fireSetImmediate();
runtime.assertLog(['setImmediate Callback', 'Task']);
});

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(['Set Immediate']);

runtime.fireSetImmediate();
runtime.assertLog([
'setImmediate Callback',
'Task',
'Yield at 5ms',
'Set Immediate',
]);

runtime.fireSetImmediate();
runtime.assertLog(['setImmediate Callback', 'Continuation']);
});

it('multiple tasks', () => {
scheduleCallback(NormalPriority, () => {
runtime.log('A');
});
scheduleCallback(NormalPriority, () => {
runtime.log('B');
});
runtime.assertLog(['Set Immediate']);
runtime.fireSetImmediate();
runtime.assertLog(['setImmediate Callback', 'A', 'B']);
});

it('multiple tasks with a yield in between', () => {
scheduleCallback(NormalPriority, () => {
runtime.log('A');
runtime.advanceTime(4999);
});
scheduleCallback(NormalPriority, () => {
runtime.log('B');
});
runtime.assertLog(['Set Immediate']);
runtime.fireSetImmediate();
runtime.assertLog([
'setImmediate Callback',
'A',
// Ran out of time. Post a continuation event.
'Set Immediate',
]);
runtime.fireSetImmediate();
runtime.assertLog(['setImmediate Callback', 'B']);
});

it('cancels tasks', () => {
const task = scheduleCallback(NormalPriority, () => {
runtime.log('Task');
});
runtime.assertLog(['Set Immediate']);
cancelCallback(task);
runtime.assertLog([]);
});

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(['Set Immediate']);

expect(() => runtime.fireSetImmediate()).toThrow('Oops!');
runtime.assertLog(['setImmediate Callback', 'Oops!', 'Set Immediate']);

runtime.fireSetImmediate();
runtime.assertLog(['setImmediate Callback', 'Yay']);
});

it('schedule new task after queue has emptied', () => {
scheduleCallback(NormalPriority, () => {
runtime.log('A');
});

runtime.assertLog(['Set Immediate']);
runtime.fireSetImmediate();
runtime.assertLog(['setImmediate Callback', 'A']);

scheduleCallback(NormalPriority, () => {
runtime.log('B');
});
runtime.assertLog(['Set Immediate']);
runtime.fireSetImmediate();
runtime.assertLog(['setImmediate Callback', 'B']);
});

it('schedule new task after a cancellation', () => {
const handle = scheduleCallback(NormalPriority, () => {
runtime.log('A');
});

runtime.assertLog(['Set Immediate']);
cancelCallback(handle);

runtime.fireSetImmediate();
runtime.assertLog(['setImmediate Callback']);

scheduleCallback(NormalPriority, () => {
runtime.log('B');
});
runtime.assertLog(['Set Immediate']);
runtime.fireSetImmediate();
runtime.assertLog(['setImmediate Callback', 'B']);
});
});
32 changes: 27 additions & 5 deletions packages/scheduler/src/forks/SchedulerDOM.js
Expand Up @@ -88,6 +88,7 @@ var isHostTimeoutScheduled = false;
// Capture local references to native APIs, in case a polyfill overrides them.
const setTimeout = window.setTimeout;
const clearTimeout = window.clearTimeout;
const setImmediate = window.setImmediate; // IE and Node.js + jsdom

if (typeof console !== 'undefined') {
// TODO: Scheduler no longer requires these methods to be polyfilled. But
Expand Down Expand Up @@ -533,7 +534,7 @@ const performWorkUntilDeadline = () => {
if (hasMoreWork) {
// If there's more work, schedule the next message event at the end
// of the preceding one.
port.postMessage(null);
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
scheduledHostCallback = null;
Expand All @@ -547,15 +548,36 @@ const performWorkUntilDeadline = () => {
needsPaint = false;
};

const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
let schedulePerformWorkUntilDeadline;
if (typeof setImmediate === 'function') {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think ideally we would create two separate bundles and resolve to the correct one at the packaging level. Maybe we can tackle this when we switch to ES Modules.

// Node.js and old IE.
// There's a few reasons for why we prefer setImmediate.
//
// Unlike MessageChannel, it doesn't prevent a Node.js process from exiting.
// (Even though this is a DOM fork of the Scheduler, you could get here
// with a mix of Node.js 15+, which has a MessageChannel, and jsdom.)
// https://github.com/facebook/react/issues/20756
//
// But also, it runs earlier which is the semantic we want.
// If other browsers ever implement it, it's better to use it.
// Although both of these would be inferior to native scheduling.
schedulePerformWorkUntilDeadline = () => {
setImmediate(performWorkUntilDeadline);
};
} else {
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
}

function requestHostCallback(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
port.postMessage(null);
schedulePerformWorkUntilDeadline();
}
}

Expand Down