[schedule] Support multiple callbacks in scheduler (#12746)

* Support using id to cancel scheduled callback

**what is the change?:**
see title

**why make this change?:**
Once we support multiple callbacks you will need to use the id to
specify which callback you mean.

**test plan:**
Added a test, ran all tests, lint, etc.

* ran prettier

* fix lint

* Use object for storing callback info in scheduler

* Wrap initial test in a describe block

* Support multiple callbacks in `ReactScheduler`

**what is the change?:**
We keep a queue of callbacks instead of just one at a time, and call
them in order first by their timeoutTime and then by the order which
they were scheduled in.

**why make this change?:**
We plan on using this module to coordinate JS outside of React, so we
will need to schedule more than one callback at a time.

**test plan:**
Added a boatload of shiny new tests. :)

Plus ran all the old ones.

NOTE: The tests do not yet cover the vital logic of callbacks timing
out, and later commits will add the missing test coverage.

* Heuristic to avoid looking for timed out callbacks when none timed out

**what is the change?:**
Tracks the current soonest timeOut time for all scheduled callbacks.

**why make this change?:**
We were checking every scheduled callback to see if it timed out on
every tick. It's more efficient to skip that O(n) check if we know that
none have timed out.

**test plan:**
Ran existing tests.

Will write new tests to cover timeout behavior in more detail soon.

* Put multiple callback support under a disabled feature flag

**what is the change?:**
See title

**why make this change?:**
We don't have error handling in place yet, so should maintain the old
behavior until that is in place.

But want to get this far to continue making incremental changes.

**test plan:**
Updated and ran tests.

* Hide support for multiple callbacks under a feature flag

**what is the change?:**
see title

**why make this change?:**
We haven't added error handling yet, so should not expose this feature.

**test plan:**
Ran all tests, temporarily split out the tests for multiple callbacks
into separate file. Will recombine once we remove the flag.

* Fix nits from code review

See comments on https://github.com/facebook/react/pull/12743

* update checklist in comments

* Remove nested loop which calls additional timed out callbacks

**what is the change?:**
We used to re-run any callbacks which time out whilst other callbacks
are running, but now we will only check once for timed out callbacks
then then run them.

**why make this change?:**
To simplify the code and the behavior of this module.

**test plan:**
Ran all existing tests.

* Remove feature flag

**what is the change?:**
see title

**why make this change?:**
Because only React is using this, and it sounds like async. rendering
won't hit any different behavior due to these changes.

**test plan:**
Existing tests pass, and this allowed us to recombine all tests to run
in both 'test' and 'test-build' modes.

* remove outdated file

* fix typo
This commit is contained in:
Flarnie Marchan
2018-05-09 15:28:13 -07:00
committed by GitHub
parent 3fb8be5c30
commit a9abd27e4f
3 changed files with 293 additions and 54 deletions
+130 -42
View File
@@ -14,8 +14,8 @@
* control than requestAnimationFrame and requestIdleCallback.
* Current TODO items:
* X- Pull out the rIC polyfill built into React
* - Initial test coverage
* - Support for multiple callbacks
* X- Initial test coverage
* X- Support for multiple callbacks
* - Support for two priorities; serial and deferred
* - Better test coverage
* - Better docblock
@@ -31,6 +31,11 @@
// The frame rate is dynamically adjusted.
import type {Deadline} from 'react-reconciler';
type CallbackConfigType = {|
scheduledCallback: Deadline => void,
timeoutTime: number,
callbackId: number, // used for cancelling
|};
import ExecutionEnvironment from 'fbjs/lib/ExecutionEnvironment';
import warning from 'fbjs/lib/warning';
@@ -85,12 +90,31 @@ if (!ExecutionEnvironment.canUseDOM) {
clearTimeout(timeoutID);
};
} else {
// Always polyfill requestIdleCallback and cancelIdleCallback
// We keep callbacks in a queue.
// Calling rIC will push in a new callback at the end of the queue.
// When we get idle time, callbacks are removed from the front of the queue
// and called.
const pendingCallbacks: Array<CallbackConfigType> = [];
let callbackIdCounter = 0;
const getCallbackId = function(): number {
callbackIdCounter++;
return callbackIdCounter;
};
// When a callback is scheduled, we register it by adding it's id to this
// object.
// If the user calls 'cIC' with the id of that callback, it will be
// unregistered by removing the id from this object.
// Then we skip calling any callback which is not registered.
// This means cancelling is an O(1) time complexity instead of O(n).
const registeredCallbackIds: {[number]: boolean} = {};
// We track what the next soonest timeoutTime is, to be able to quickly tell
// if none of the scheduled callbacks have timed out.
let nextSoonestTimeoutTime = -1;
let scheduledRICCallback = null;
let isIdleScheduled = false;
let timeoutTime = -1;
let isAnimationFrameScheduled = false;
let frameDeadline = 0;
@@ -100,7 +124,7 @@ if (!ExecutionEnvironment.canUseDOM) {
let previousFrameTime = 33;
let activeFrameTime = 33;
const frameDeadlineObject = {
const frameDeadlineObject: Deadline = {
didTimeout: false,
timeRemaining() {
const remaining = frameDeadline - now();
@@ -108,6 +132,67 @@ if (!ExecutionEnvironment.canUseDOM) {
},
};
const safelyCallScheduledCallback = function(callback, callbackId) {
if (!registeredCallbackIds[callbackId]) {
// ignore cancelled callbacks
return;
}
try {
callback(frameDeadlineObject);
// Avoid using 'catch' to keep errors easy to debug
} finally {
// always clean up the callbackId, even if the callback throws
delete registeredCallbackIds[callbackId];
}
};
/**
* Checks for timed out callbacks, runs them, and then checks again to see if
* any more have timed out.
* Keeps doing this until there are none which have currently timed out.
*/
const callTimedOutCallbacks = function() {
if (pendingCallbacks.length === 0) {
return;
}
const currentTime = now();
// TODO: this would be more efficient if deferred callbacks are stored in
// min heap.
// Or in a linked list with links for both timeoutTime order and insertion
// order.
// For now an easy compromise is the current approach:
// Keep a pointer to the soonest timeoutTime, and check that first.
// If it has not expired, we can skip traversing the whole list.
// If it has expired, then we step through all the callbacks.
if (nextSoonestTimeoutTime === -1 || nextSoonestTimeoutTime > currentTime) {
// We know that none of them have timed out yet.
return;
}
nextSoonestTimeoutTime = -1; // we will reset it below
// keep checking until we don't find any more timed out callbacks
frameDeadlineObject.didTimeout = true;
for (let i = 0, len = pendingCallbacks.length; i < len; i++) {
const currentCallbackConfig = pendingCallbacks[i];
const timeoutTime = currentCallbackConfig.timeoutTime;
if (timeoutTime !== -1 && timeoutTime <= currentTime) {
// it has timed out!
// call it
const callback = currentCallbackConfig.scheduledCallback;
safelyCallScheduledCallback(callback, timeoutTime);
} else {
if (
timeoutTime !== -1 &&
(nextSoonestTimeoutTime === -1 ||
timeoutTime < nextSoonestTimeoutTime)
) {
nextSoonestTimeoutTime = timeoutTime;
}
}
}
};
// We use the postMessage trick to defer idle work until after the repaint.
const messageKey =
'__reactIdleCallback$' +
@@ -119,36 +204,30 @@ if (!ExecutionEnvironment.canUseDOM) {
return;
}
if (pendingCallbacks.length === 0) {
return;
}
isIdleScheduled = false;
const currentTime = now();
if (frameDeadline - currentTime <= 0) {
// There's no time left in this idle period. Check if the callback has
// a timeout and whether it's been exceeded.
if (timeoutTime !== -1 && timeoutTime <= currentTime) {
// Exceeded the timeout. Invoke the callback even though there's no
// time left.
frameDeadlineObject.didTimeout = true;
} else {
// No timeout.
if (!isAnimationFrameScheduled) {
// Schedule another animation callback so we retry later.
isAnimationFrameScheduled = true;
requestAnimationFrame(animationTick);
}
// Exit without invoking the callback.
return;
}
} else {
// There's still time left in this idle period.
frameDeadlineObject.didTimeout = false;
}
// First call anything which has timed out, until we have caught up.
callTimedOutCallbacks();
timeoutTime = -1;
const callback = scheduledRICCallback;
scheduledRICCallback = null;
if (callback !== null) {
callback(frameDeadlineObject);
let currentTime = now();
// Next, as long as we have idle time, try calling more callbacks.
while (frameDeadline - currentTime > 0 && pendingCallbacks.length > 0) {
const latestCallbackConfig = pendingCallbacks.shift();
frameDeadlineObject.didTimeout = false;
const latestCallback = latestCallbackConfig.scheduledCallback;
const newCallbackId = latestCallbackConfig.callbackId;
safelyCallScheduledCallback(latestCallback, newCallbackId);
currentTime = now();
}
if (pendingCallbacks.length > 0) {
if (!isAnimationFrameScheduled) {
// Schedule another animation callback so we retry later.
isAnimationFrameScheduled = true;
requestAnimationFrame(animationTick);
}
}
};
// Assumes that we have addEventListener in this environment. Might need
@@ -190,12 +269,23 @@ if (!ExecutionEnvironment.canUseDOM) {
callback: (deadline: Deadline) => void,
options?: {timeout: number},
): number {
// This assumes that we only schedule one callback at a time because that's
// how Fiber uses it.
scheduledRICCallback = callback;
let timeoutTime = -1;
if (options != null && typeof options.timeout === 'number') {
timeoutTime = now() + options.timeout;
}
if (timeoutTime > nextSoonestTimeoutTime) {
nextSoonestTimeoutTime = timeoutTime;
}
const newCallbackId = getCallbackId();
const scheduledCallbackConfig = {
scheduledCallback: callback,
callbackId: newCallbackId,
timeoutTime,
};
pendingCallbacks.push(scheduledCallbackConfig);
registeredCallbackIds[newCallbackId] = true;
if (!isAnimationFrameScheduled) {
// If rAF didn't already schedule one, we need to schedule a frame.
// TODO: If this rAF doesn't materialize because the browser throttles, we
@@ -204,13 +294,11 @@ if (!ExecutionEnvironment.canUseDOM) {
isAnimationFrameScheduled = true;
requestAnimationFrame(animationTick);
}
return 0;
return newCallbackId;
};
cIC = function() {
scheduledRICCallback = null;
isIdleScheduled = false;
timeoutTime = -1;
cIC = function(callbackId: number) {
delete registeredCallbackIds[callbackId];
};
}
+161 -10
View File
@@ -41,16 +41,167 @@ describe('ReactScheduler', () => {
ReactScheduler = require('react-scheduler');
});
it('rIC calls the callback within the frame when not blocked', () => {
const {rIC} = ReactScheduler;
const cb = jest.fn();
rIC(cb);
jest.runAllTimers();
expect(cb.mock.calls.length).toBe(1);
// should have ... TODO details on what we expect
expect(cb.mock.calls[0][0].didTimeout).toBe(false);
expect(typeof cb.mock.calls[0][0].timeRemaining()).toBe('number');
describe('rIC', () => {
it('calls the callback within the frame when not blocked', () => {
const {rIC} = ReactScheduler;
const cb = jest.fn();
rIC(cb);
jest.runAllTimers();
expect(cb.mock.calls.length).toBe(1);
// should not have timed out and should include a timeRemaining method
expect(cb.mock.calls[0][0].didTimeout).toBe(false);
expect(typeof cb.mock.calls[0][0].timeRemaining()).toBe('number');
});
describe('with multiple callbacks', () => {
it('accepts multiple callbacks and calls within frame when not blocked', () => {
const {rIC} = ReactScheduler;
const callbackLog = [];
const callbackA = jest.fn(() => callbackLog.push('A'));
const callbackB = jest.fn(() => callbackLog.push('B'));
rIC(callbackA);
// initially waits to call the callback
expect(callbackLog).toEqual([]);
// waits while second callback is passed
rIC(callbackB);
expect(callbackLog).toEqual([]);
// after a delay, calls as many callbacks as it has time for
jest.runAllTimers();
expect(callbackLog).toEqual(['A', 'B']);
// callbackA should not have timed out and should include a timeRemaining method
expect(callbackA.mock.calls[0][0].didTimeout).toBe(false);
expect(typeof callbackA.mock.calls[0][0].timeRemaining()).toBe(
'number',
);
// callbackA should not have timed out and should include a timeRemaining method
expect(callbackB.mock.calls[0][0].didTimeout).toBe(false);
expect(typeof callbackB.mock.calls[0][0].timeRemaining()).toBe(
'number',
);
});
it(
'schedules callbacks in correct order and' +
'keeps calling them if there is time',
() => {
const {rIC} = ReactScheduler;
const callbackLog = [];
const callbackA = jest.fn(() => {
callbackLog.push('A');
rIC(callbackC);
});
const callbackB = jest.fn(() => {
callbackLog.push('B');
});
const callbackC = jest.fn(() => {
callbackLog.push('C');
});
rIC(callbackA);
// initially waits to call the callback
expect(callbackLog).toEqual([]);
// continues waiting while B is scheduled
rIC(callbackB);
expect(callbackLog).toEqual([]);
// after a delay, calls the scheduled callbacks,
// and also calls new callbacks scheduled by current callbacks
jest.runAllTimers();
expect(callbackLog).toEqual(['A', 'B', 'C']);
},
);
it('schedules callbacks in correct order when callbacks have many nested rIC calls', () => {
const {rIC} = ReactScheduler;
const callbackLog = [];
const callbackA = jest.fn(() => {
callbackLog.push('A');
rIC(callbackC);
rIC(callbackD);
});
const callbackB = jest.fn(() => {
callbackLog.push('B');
rIC(callbackE);
rIC(callbackF);
});
const callbackC = jest.fn(() => {
callbackLog.push('C');
});
const callbackD = jest.fn(() => {
callbackLog.push('D');
});
const callbackE = jest.fn(() => {
callbackLog.push('E');
});
const callbackF = jest.fn(() => {
callbackLog.push('F');
});
rIC(callbackA);
rIC(callbackB);
// initially waits to call the callback
expect(callbackLog).toEqual([]);
// while flushing callbacks, calls as many as it has time for
jest.runAllTimers();
expect(callbackLog).toEqual(['A', 'B', 'C', 'D', 'E', 'F']);
});
it('schedules callbacks in correct order when they use rIC to schedule themselves', () => {
const {rIC} = ReactScheduler;
const callbackLog = [];
let callbackAIterations = 0;
const callbackA = jest.fn(() => {
if (callbackAIterations < 1) {
rIC(callbackA);
}
callbackLog.push('A' + callbackAIterations);
callbackAIterations++;
});
const callbackB = jest.fn(() => callbackLog.push('B'));
rIC(callbackA);
// initially waits to call the callback
expect(callbackLog).toEqual([]);
rIC(callbackB);
expect(callbackLog).toEqual([]);
// after a delay, calls the latest callback passed
jest.runAllTimers();
expect(callbackLog).toEqual(['A0', 'B', 'A1']);
});
});
});
// TODO: test cIC and now
describe('cIC', () => {
it('cancels the scheduled callback', () => {
const {rIC, cIC} = ReactScheduler;
const cb = jest.fn();
const callbackId = rIC(cb);
expect(cb.mock.calls.length).toBe(0);
cIC(callbackId);
jest.runAllTimers();
expect(cb.mock.calls.length).toBe(0);
});
describe('with multiple callbacks', () => {
it('when one callback cancels the next one', () => {
const {rIC, cIC} = ReactScheduler;
const callbackLog = [];
let callbackBId;
const callbackA = jest.fn(() => {
callbackLog.push('A');
cIC(callbackBId);
});
const callbackB = jest.fn(() => callbackLog.push('B'));
rIC(callbackA);
callbackBId = rIC(callbackB);
// Initially doesn't call anything
expect(callbackLog).toEqual([]);
jest.runAllTimers();
// B should not get called because A cancelled B
expect(callbackLog).toEqual(['A']);
expect(callbackB.mock.calls.length).toBe(0);
});
});
});
// TODO: test 'now'
});
@@ -12,11 +12,11 @@ import typeof * as FeatureFlagsShimType from './ReactFeatureFlags.www';
// Re-export dynamic flags from the www version.
export const {
enableGetDerivedStateFromCatch,
debugRenderPhaseSideEffects,
debugRenderPhaseSideEffectsForStrictMode,
warnAboutDeprecatedLifecycles,
enableGetDerivedStateFromCatch,
replayFailedUnitOfWorkWithInvokeGuardedCallback,
warnAboutDeprecatedLifecycles,
} = require('ReactFeatureFlags');
// The rest of the flags are static for better dead code elimination.