Skip to content

Commit a9abd27

Browse files
authored
[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 #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
1 parent 3fb8be5 commit a9abd27

File tree

3 files changed

+292
-53
lines changed

3 files changed

+292
-53
lines changed

packages/react-scheduler/src/ReactScheduler.js

Lines changed: 129 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
* control than requestAnimationFrame and requestIdleCallback.
1515
* Current TODO items:
1616
* X- Pull out the rIC polyfill built into React
17-
* - Initial test coverage
18-
* - Support for multiple callbacks
17+
* X- Initial test coverage
18+
* X- Support for multiple callbacks
1919
* - Support for two priorities; serial and deferred
2020
* - Better test coverage
2121
* - Better docblock
@@ -31,6 +31,11 @@
3131
// The frame rate is dynamically adjusted.
3232

3333
import type {Deadline} from 'react-reconciler';
34+
type CallbackConfigType = {|
35+
scheduledCallback: Deadline => void,
36+
timeoutTime: number,
37+
callbackId: number, // used for cancelling
38+
|};
3439

3540
import ExecutionEnvironment from 'fbjs/lib/ExecutionEnvironment';
3641
import warning from 'fbjs/lib/warning';
@@ -85,12 +90,31 @@ if (!ExecutionEnvironment.canUseDOM) {
8590
clearTimeout(timeoutID);
8691
};
8792
} else {
88-
// Always polyfill requestIdleCallback and cancelIdleCallback
93+
// We keep callbacks in a queue.
94+
// Calling rIC will push in a new callback at the end of the queue.
95+
// When we get idle time, callbacks are removed from the front of the queue
96+
// and called.
97+
const pendingCallbacks: Array<CallbackConfigType> = [];
8998

90-
let scheduledRICCallback = null;
91-
let isIdleScheduled = false;
92-
let timeoutTime = -1;
99+
let callbackIdCounter = 0;
100+
const getCallbackId = function(): number {
101+
callbackIdCounter++;
102+
return callbackIdCounter;
103+
};
104+
105+
// When a callback is scheduled, we register it by adding it's id to this
106+
// object.
107+
// If the user calls 'cIC' with the id of that callback, it will be
108+
// unregistered by removing the id from this object.
109+
// Then we skip calling any callback which is not registered.
110+
// This means cancelling is an O(1) time complexity instead of O(n).
111+
const registeredCallbackIds: {[number]: boolean} = {};
93112

113+
// We track what the next soonest timeoutTime is, to be able to quickly tell
114+
// if none of the scheduled callbacks have timed out.
115+
let nextSoonestTimeoutTime = -1;
116+
117+
let isIdleScheduled = false;
94118
let isAnimationFrameScheduled = false;
95119

96120
let frameDeadline = 0;
@@ -100,14 +124,75 @@ if (!ExecutionEnvironment.canUseDOM) {
100124
let previousFrameTime = 33;
101125
let activeFrameTime = 33;
102126

103-
const frameDeadlineObject = {
127+
const frameDeadlineObject: Deadline = {
104128
didTimeout: false,
105129
timeRemaining() {
106130
const remaining = frameDeadline - now();
107131
return remaining > 0 ? remaining : 0;
108132
},
109133
};
110134

135+
const safelyCallScheduledCallback = function(callback, callbackId) {
136+
if (!registeredCallbackIds[callbackId]) {
137+
// ignore cancelled callbacks
138+
return;
139+
}
140+
try {
141+
callback(frameDeadlineObject);
142+
// Avoid using 'catch' to keep errors easy to debug
143+
} finally {
144+
// always clean up the callbackId, even if the callback throws
145+
delete registeredCallbackIds[callbackId];
146+
}
147+
};
148+
149+
/**
150+
* Checks for timed out callbacks, runs them, and then checks again to see if
151+
* any more have timed out.
152+
* Keeps doing this until there are none which have currently timed out.
153+
*/
154+
const callTimedOutCallbacks = function() {
155+
if (pendingCallbacks.length === 0) {
156+
return;
157+
}
158+
159+
const currentTime = now();
160+
// TODO: this would be more efficient if deferred callbacks are stored in
161+
// min heap.
162+
// Or in a linked list with links for both timeoutTime order and insertion
163+
// order.
164+
// For now an easy compromise is the current approach:
165+
// Keep a pointer to the soonest timeoutTime, and check that first.
166+
// If it has not expired, we can skip traversing the whole list.
167+
// If it has expired, then we step through all the callbacks.
168+
if (nextSoonestTimeoutTime === -1 || nextSoonestTimeoutTime > currentTime) {
169+
// We know that none of them have timed out yet.
170+
return;
171+
}
172+
nextSoonestTimeoutTime = -1; // we will reset it below
173+
174+
// keep checking until we don't find any more timed out callbacks
175+
frameDeadlineObject.didTimeout = true;
176+
for (let i = 0, len = pendingCallbacks.length; i < len; i++) {
177+
const currentCallbackConfig = pendingCallbacks[i];
178+
const timeoutTime = currentCallbackConfig.timeoutTime;
179+
if (timeoutTime !== -1 && timeoutTime <= currentTime) {
180+
// it has timed out!
181+
// call it
182+
const callback = currentCallbackConfig.scheduledCallback;
183+
safelyCallScheduledCallback(callback, timeoutTime);
184+
} else {
185+
if (
186+
timeoutTime !== -1 &&
187+
(nextSoonestTimeoutTime === -1 ||
188+
timeoutTime < nextSoonestTimeoutTime)
189+
) {
190+
nextSoonestTimeoutTime = timeoutTime;
191+
}
192+
}
193+
}
194+
};
195+
111196
// We use the postMessage trick to defer idle work until after the repaint.
112197
const messageKey =
113198
'__reactIdleCallback$' +
@@ -119,36 +204,30 @@ if (!ExecutionEnvironment.canUseDOM) {
119204
return;
120205
}
121206

207+
if (pendingCallbacks.length === 0) {
208+
return;
209+
}
122210
isIdleScheduled = false;
123211

124-
const currentTime = now();
125-
if (frameDeadline - currentTime <= 0) {
126-
// There's no time left in this idle period. Check if the callback has
127-
// a timeout and whether it's been exceeded.
128-
if (timeoutTime !== -1 && timeoutTime <= currentTime) {
129-
// Exceeded the timeout. Invoke the callback even though there's no
130-
// time left.
131-
frameDeadlineObject.didTimeout = true;
132-
} else {
133-
// No timeout.
134-
if (!isAnimationFrameScheduled) {
135-
// Schedule another animation callback so we retry later.
136-
isAnimationFrameScheduled = true;
137-
requestAnimationFrame(animationTick);
138-
}
139-
// Exit without invoking the callback.
140-
return;
141-
}
142-
} else {
143-
// There's still time left in this idle period.
212+
// First call anything which has timed out, until we have caught up.
213+
callTimedOutCallbacks();
214+
215+
let currentTime = now();
216+
// Next, as long as we have idle time, try calling more callbacks.
217+
while (frameDeadline - currentTime > 0 && pendingCallbacks.length > 0) {
218+
const latestCallbackConfig = pendingCallbacks.shift();
144219
frameDeadlineObject.didTimeout = false;
220+
const latestCallback = latestCallbackConfig.scheduledCallback;
221+
const newCallbackId = latestCallbackConfig.callbackId;
222+
safelyCallScheduledCallback(latestCallback, newCallbackId);
223+
currentTime = now();
145224
}
146-
147-
timeoutTime = -1;
148-
const callback = scheduledRICCallback;
149-
scheduledRICCallback = null;
150-
if (callback !== null) {
151-
callback(frameDeadlineObject);
225+
if (pendingCallbacks.length > 0) {
226+
if (!isAnimationFrameScheduled) {
227+
// Schedule another animation callback so we retry later.
228+
isAnimationFrameScheduled = true;
229+
requestAnimationFrame(animationTick);
230+
}
152231
}
153232
};
154233
// Assumes that we have addEventListener in this environment. Might need
@@ -190,12 +269,23 @@ if (!ExecutionEnvironment.canUseDOM) {
190269
callback: (deadline: Deadline) => void,
191270
options?: {timeout: number},
192271
): number {
193-
// This assumes that we only schedule one callback at a time because that's
194-
// how Fiber uses it.
195-
scheduledRICCallback = callback;
272+
let timeoutTime = -1;
196273
if (options != null && typeof options.timeout === 'number') {
197274
timeoutTime = now() + options.timeout;
198275
}
276+
if (timeoutTime > nextSoonestTimeoutTime) {
277+
nextSoonestTimeoutTime = timeoutTime;
278+
}
279+
280+
const newCallbackId = getCallbackId();
281+
const scheduledCallbackConfig = {
282+
scheduledCallback: callback,
283+
callbackId: newCallbackId,
284+
timeoutTime,
285+
};
286+
pendingCallbacks.push(scheduledCallbackConfig);
287+
288+
registeredCallbackIds[newCallbackId] = true;
199289
if (!isAnimationFrameScheduled) {
200290
// If rAF didn't already schedule one, we need to schedule a frame.
201291
// TODO: If this rAF doesn't materialize because the browser throttles, we
@@ -204,13 +294,11 @@ if (!ExecutionEnvironment.canUseDOM) {
204294
isAnimationFrameScheduled = true;
205295
requestAnimationFrame(animationTick);
206296
}
207-
return 0;
297+
return newCallbackId;
208298
};
209299

210-
cIC = function() {
211-
scheduledRICCallback = null;
212-
isIdleScheduled = false;
213-
timeoutTime = -1;
300+
cIC = function(callbackId: number) {
301+
delete registeredCallbackIds[callbackId];
214302
};
215303
}
216304

0 commit comments

Comments
 (0)