Add postTask browser scheduler implementation (#19479)

* Reduce code to necessities

* Switch to postTask API

* Add SchedulerPostTask tests

* Updates from review

* Fix typo from review

* Generate build of unstable_post_task
This commit is contained in:
Ricky
2020-07-29 15:27:59 -04:00
committed by GitHub
parent 7c5595be88
commit 0cd84ab286
6 changed files with 374 additions and 243 deletions
@@ -0,0 +1,7 @@
'use strict';
if (process.env.NODE_ENV === 'production') {
module.exports = require('./cjs/scheduler-unstable_post_task.production.min.js');
} else {
module.exports = require('./cjs/scheduler-unstable_post_task.development.js');
}
+1
View File
@@ -28,6 +28,7 @@
"tracing.js",
"tracing-profiling.js",
"unstable_mock.js",
"unstable_post_task.js",
"cjs/",
"umd/"
],
@@ -0,0 +1,267 @@
/**
* 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 postTask implementation uses a new postTask browser API to
// schedule work on the main thread. Most of our tests treat this as an
// implementation detail; however, the sequence and timing of browser
// 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.s
describe('SchedulerPostTask', () => {
beforeEach(() => {
jest.resetModules();
// Un-mock scheduler
jest.mock('scheduler', () =>
require.requireActual('scheduler/unstable_post_task'),
);
jest.mock('scheduler/src/SchedulerHostConfig', () =>
require.requireActual(
'scheduler/src/forks/SchedulerHostConfig.post-task.js',
),
);
runtime = installMockBrowserRuntime();
performance = window.performance;
Scheduler = require('scheduler');
cancelCallback = Scheduler.unstable_cancelCallback;
scheduleCallback = Scheduler.unstable_scheduleCallback;
NormalPriority = Scheduler.unstable_NormalPriority;
});
afterEach(() => {
if (!runtime.isLogEmpty()) {
throw Error('Test exited without clearing log.');
}
});
function installMockBrowserRuntime() {
let hasPendingTask = false;
let timerIDCounter = 0;
let eventLog = [];
// Mock window functions
const window = {};
global.window = window;
let currentTime = 0;
window.performance = {
now() {
return currentTime;
},
};
window.setTimeout = (cb, delay) => {
const id = timerIDCounter++;
log(`Set Timer`);
// TODO
return id;
};
window.clearTimeout = id => {
// TODO
};
// Mock browser scheduler.
const scheduler = {};
global.scheduler = scheduler;
let nextTask;
scheduler.postTask = function(callback) {
if (hasPendingTask) {
throw Error('Task already scheduled');
}
log('Post Task');
hasPendingTask = true;
nextTask = callback;
};
function ensureLogIsEmpty() {
if (eventLog.length !== 0) {
throw Error('Log is not empty. Call assertLog before continuing.');
}
}
function advanceTime(ms) {
currentTime += ms;
}
function fireNextTask() {
ensureLogIsEmpty();
if (!hasPendingTask) {
throw Error('No task was scheduled');
}
hasPendingTask = false;
log('Task Event');
// If there's a continuation, it will call postTask again
// which will set nextTask. That means we need to clear
// nextTask before the invocation, otherwise we would
// delete the continuation task.
const task = nextTask;
nextTask = null;
task();
}
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,
fireNextTask,
log,
isLogEmpty,
assertLog,
};
}
it('task that finishes before deadline', () => {
scheduleCallback(NormalPriority, () => {
runtime.log('Task');
});
runtime.assertLog(['Post Task']);
runtime.fireNextTask();
runtime.assertLog(['Task Event', '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(['Post Task']);
runtime.fireNextTask();
runtime.assertLog(['Task Event', 'Task', 'Yield at 5ms', 'Post Task']);
runtime.fireNextTask();
runtime.assertLog(['Task Event', 'Continuation']);
});
it('multiple tasks', () => {
scheduleCallback(NormalPriority, () => {
runtime.log('A');
});
scheduleCallback(NormalPriority, () => {
runtime.log('B');
});
runtime.assertLog(['Post Task']);
runtime.fireNextTask();
runtime.assertLog(['Task Event', '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(['Post Task']);
runtime.fireNextTask();
runtime.assertLog([
'Task Event',
'A',
// Ran out of time. Post a continuation event.
'Post Task',
]);
runtime.fireNextTask();
runtime.assertLog(['Task Event', 'B']);
});
it('cancels tasks', () => {
const task = scheduleCallback(NormalPriority, () => {
runtime.log('Task');
});
runtime.assertLog(['Post Task']);
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(['Post Task']);
expect(() => runtime.fireNextTask()).toThrow('Oops!');
runtime.assertLog(['Task Event', 'Oops!', 'Post Task']);
runtime.fireNextTask();
runtime.assertLog(['Task Event', 'Yay']);
});
it('schedule new task after queue has emptied', () => {
scheduleCallback(NormalPriority, () => {
runtime.log('A');
});
runtime.assertLog(['Post Task']);
runtime.fireNextTask();
runtime.assertLog(['Task Event', 'A']);
scheduleCallback(NormalPriority, () => {
runtime.log('B');
});
runtime.assertLog(['Post Task']);
runtime.fireNextTask();
runtime.assertLog(['Task Event', 'B']);
});
it('schedule new task after a cancellation', () => {
const handle = scheduleCallback(NormalPriority, () => {
runtime.log('A');
});
runtime.assertLog(['Post Task']);
cancelCallback(handle);
runtime.fireNextTask();
runtime.assertLog(['Task Event']);
scheduleCallback(NormalPriority, () => {
runtime.log('B');
});
runtime.assertLog(['Post Task']);
runtime.fireNextTask();
runtime.assertLog(['Task Event', 'B']);
});
});
@@ -174,7 +174,7 @@ if (
// Using console['error'] to evade Babel and ESLint
console['error'](
'forceFrameRate takes a positive int between 0 and 125, ' +
'forcing frame rates higher than 125 fps is not unsupported',
'forcing frame rates higher than 125 fps is not supported',
);
return;
}
@@ -5,246 +5,95 @@
* LICENSE file in the root directory of this source tree.
*/
import {enableIsInputPending} from '../SchedulerFeatureFlags';
// Capture local references to native APIs, in case a polyfill overrides them.
const perf = window.performance;
const setTimeout = window.setTimeout;
const clearTimeout = window.clearTimeout;
export let requestHostCallback;
export let cancelHostCallback;
export let requestHostTimeout;
export let cancelHostTimeout;
export let shouldYieldToHost;
export let requestPaint;
export let getCurrentTime;
export let forceFrameRate;
if (
// If Scheduler runs in a non-DOM environment, it falls back to a naive
// implementation using setTimeout.
typeof window === 'undefined' ||
// Check if MessageChannel is supported, too.
typeof MessageChannel !== 'function'
) {
// 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 {
const currentTime = getCurrentTime();
const hasRemainingTime = true;
_callback(hasRemainingTime, currentTime);
_callback = null;
} catch (e) {
setTimeout(_flushCallback, 0);
throw e;
}
}
};
const initialTime = Date.now();
getCurrentTime = function() {
return Date.now() - initialTime;
};
requestHostCallback = function(cb) {
if (_callback !== null) {
// Protect against re-entrancy.
setTimeout(requestHostCallback, 0, cb);
} else {
_callback = cb;
setTimeout(_flushCallback, 0);
}
};
cancelHostCallback = function() {
_callback = null;
};
requestHostTimeout = function(cb, ms) {
_timeoutID = setTimeout(cb, ms);
};
cancelHostTimeout = function() {
clearTimeout(_timeoutID);
};
shouldYieldToHost = function() {
return false;
};
requestPaint = forceFrameRate = function() {};
} else {
// Capture local references to native APIs, in case a polyfill overrides them.
const performance = window.performance;
const Date = window.Date;
const setTimeout = window.setTimeout;
const clearTimeout = window.clearTimeout;
if (typeof console !== 'undefined') {
// 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?
const requestAnimationFrame = window.requestAnimationFrame;
const cancelAnimationFrame = window.cancelAnimationFrame;
// TODO: Remove fb.me link
if (typeof requestAnimationFrame !== 'function') {
// Using console['error'] to evade Babel and ESLint
console['error'](
"This browser doesn't support requestAnimationFrame. " +
'Make sure that you load a ' +
'polyfill in older browsers. https://fb.me/react-polyfills',
);
}
if (typeof cancelAnimationFrame !== 'function') {
// Using console['error'] to evade Babel and ESLint
console['error'](
"This browser doesn't support cancelAnimationFrame. " +
'Make sure that you load a ' +
'polyfill in older browsers. https://fb.me/react-polyfills',
);
}
}
if (
typeof performance === 'object' &&
typeof performance.now === 'function'
) {
getCurrentTime = () => performance.now();
} else {
const initialTime = Date.now();
getCurrentTime = () => Date.now() - initialTime;
}
let isMessageLoopRunning = false;
let scheduledHostCallback = null;
let taskTimeoutID = -1;
// Scheduler periodically yields in case there is other work on the main
// thread, like user events. By default, it yields multiple times per frame.
// It does not attempt to align with frame boundaries, since most tasks don't
// need to be frame aligned; for those that do, use requestAnimationFrame.
let yieldInterval = 5;
let deadline = 0;
// TODO: Make this configurable
// TODO: Adjust this based on priority?
const maxYieldInterval = 300;
let needsPaint = false;
if (
enableIsInputPending &&
navigator !== undefined &&
navigator.scheduling !== undefined &&
navigator.scheduling.isInputPending !== undefined
) {
const scheduling = navigator.scheduling;
shouldYieldToHost = function() {
const currentTime = getCurrentTime();
if (currentTime >= deadline) {
// There's no time left. We may want to yield control of the main
// thread, so the browser can perform high priority tasks. The main ones
// are painting and user input. If there's a pending paint or a pending
// input, then we should yield. But if there's neither, then we can
// yield less often while remaining responsive. We'll eventually yield
// regardless, since there could be a pending paint that wasn't
// accompanied by a call to `requestPaint`, or other main thread tasks
// like network events.
if (needsPaint || scheduling.isInputPending()) {
// There is either a pending paint or a pending input.
return true;
}
// There's no pending input. Only yield if we've reached the max
// yield interval.
return currentTime >= maxYieldInterval;
} else {
// There's still time left in the frame.
return false;
}
};
requestPaint = function() {
needsPaint = true;
};
} else {
// `isInputPending` is not available. Since we have no way of knowing if
// there's pending input, always yield at the end of the frame.
shouldYieldToHost = function() {
return getCurrentTime() >= deadline;
};
// Since we yield every frame regardless, `requestPaint` has no effect.
requestPaint = function() {};
}
forceFrameRate = function(fps) {
if (fps < 0 || fps > 125) {
// Using console['error'] to evade Babel and ESLint
console['error'](
'forceFrameRate takes a positive int between 0 and 125, ' +
'forcing frame rates higher than 125 fps is not unsupported',
);
return;
}
if (fps > 0) {
yieldInterval = Math.floor(1000 / fps);
} else {
// reset the framerate
yieldInterval = 5;
}
};
const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
// Yield after `yieldInterval` ms, regardless of where we are in the vsync
// cycle. This means there's always time remaining at the beginning of
// the message event.
deadline = currentTime + yieldInterval;
const hasTimeRemaining = true;
try {
const hasMoreWork = scheduledHostCallback(
hasTimeRemaining,
currentTime,
);
if (!hasMoreWork) {
isMessageLoopRunning = false;
scheduledHostCallback = null;
} else {
// If there's more work, schedule the next message event at the end
// of the preceding one.
port.postMessage(null);
}
} catch (error) {
// If a scheduler task throws, exit the current browser task so the
// error can be observed.
port.postMessage(null);
throw error;
}
} else {
isMessageLoopRunning = false;
}
// Yielding to the browser will give it a chance to paint, so we can
// reset this.
needsPaint = false;
};
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
requestHostCallback = function(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
port.postMessage(null);
}
};
cancelHostCallback = function() {
scheduledHostCallback = null;
};
requestHostTimeout = function(callback, ms) {
taskTimeoutID = setTimeout(() => {
callback(getCurrentTime());
}, ms);
};
cancelHostTimeout = function() {
clearTimeout(taskTimeoutID);
taskTimeoutID = -1;
};
function postTask(callback) {
// Use experimental Chrome Scheduler postTask API.
global.scheduler.postTask(callback);
}
function getNow() {
return perf.now();
}
let isTaskLoopRunning = false;
let scheduledHostCallback = null;
let taskTimeoutID = -1;
// Scheduler periodically yields in case there is other work on the main
// thread, like user events. By default, it yields multiple times per frame.
// It does not attempt to align with frame boundaries, since most tasks don't
// need to be frame aligned; for those that do, use requestAnimationFrame.
const yieldInterval = 5;
let deadline = 0;
// `isInputPending` is not available. Since we have no way of knowing if
// there's pending input, always yield at the end of the frame.
export function shouldYieldToHost() {
return getNow() >= deadline;
}
export function requestPaint() {
// Since we yield every frame regardless, `requestPaint` has no effect.
}
export function forceFrameRate(fps) {
// No-op
}
function performWorkUntilDeadline() {
if (scheduledHostCallback !== null) {
const currentTime = getNow();
// Yield after `yieldInterval` ms, regardless of where we are in the vsync
// cycle. This means there's always time remaining at the beginning of
// the message event.
deadline = currentTime + yieldInterval;
const hasTimeRemaining = true;
try {
const hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
if (!hasMoreWork) {
isTaskLoopRunning = false;
scheduledHostCallback = null;
} else {
// If there's more work, schedule the next message event at the end
// of the preceding one.
postTask(performWorkUntilDeadline);
}
} catch (error) {
// If a scheduler task throws, exit the current browser task so the
// error can be observed.
postTask(performWorkUntilDeadline);
throw error;
}
} else {
isTaskLoopRunning = false;
}
}
export function requestHostCallback(callback) {
scheduledHostCallback = callback;
if (!isTaskLoopRunning) {
isTaskLoopRunning = true;
postTask(performWorkUntilDeadline);
}
}
export function cancelHostCallback() {
scheduledHostCallback = null;
}
export function requestHostTimeout(callback, ms) {
taskTimeoutID = setTimeout(() => {
callback(getNow());
}, ms);
}
export function cancelHostTimeout() {
clearTimeout(taskTimeoutID);
taskTimeoutID = -1;
}
export const getCurrentTime = getNow;
+8 -1
View File
@@ -634,8 +634,15 @@ const bundles = [
externals: [],
},
/******* React Scheduler Post Task (experimental) *******/
{
bundleTypes: [FB_WWW_DEV, FB_WWW_PROD, FB_WWW_PROFILING],
bundleTypes: [
NODE_DEV,
NODE_PROD,
FB_WWW_DEV,
FB_WWW_PROD,
FB_WWW_PROFILING,
],
moduleType: ISOMORPHIC,
entry: 'scheduler/unstable_post_task',
global: 'SchedulerPostTask',