Skip to content

Commit 060de3e

Browse files
committed
[Scheduler] Re-throw unhandled errors
Because `postTask` returns a promise, errors inside a `postTask` callback result in the promise being rejected. If we don't catch those errors, then the browser will report an "Unhandled promise rejection" error. This is a confusing message to see in the console, because the fact that `postTask` is a promise-based API is an implementation detail from the perspective of the developer. "Promise rejection" is a red herring. On the other hand, if we do catch those errors, then we need to report the error to the user in some other way. What we really want is the default error reporting behavior that a normal, non-Promise browser event gets. So, we'll re-throw inside `setTimeout`.
1 parent b6e1d08 commit 060de3e

File tree

2 files changed

+49
-57
lines changed

2 files changed

+49
-57
lines changed

packages/scheduler/src/SchedulerPostTask.js

+35-41
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export {
3939

4040
// Capture local references to native APIs, in case a polyfill overrides them.
4141
const perf = window.performance;
42+
const setTimeout = window.setTimeout;
4243

4344
// Use experimental Chrome Scheduler postTask API.
4445
const scheduler = global.scheduler;
@@ -112,7 +113,7 @@ export function unstable_scheduleCallback<T>(
112113
runTask.bind(null, priorityLevel, postTaskPriority, node, callback),
113114
postTaskOptions,
114115
)
115-
.catch(handlePostTaskError);
116+
.catch(handleAbortError);
116117

117118
return node;
118119
}
@@ -124,56 +125,49 @@ function runTask<T>(
124125
callback: SchedulerCallback<T>,
125126
) {
126127
deadline = getCurrentTime() + yieldInterval;
128+
let result;
127129
try {
128130
currentPriorityLevel_DEPRECATED = priorityLevel;
129131
const didTimeout_DEPRECATED = false;
130-
const result = callback(didTimeout_DEPRECATED);
131-
if (typeof result === 'function') {
132-
// Assume this is a continuation
133-
const continuation: SchedulerCallback<T> = (result: any);
134-
const continuationController = new TaskController();
135-
const continuationOptions = {
136-
priority: postTaskPriority,
137-
signal: continuationController.signal,
138-
};
139-
// Update the original callback node's controller, since even though we're
140-
// posting a new task, conceptually it's the same one.
141-
node._controller = continuationController;
142-
scheduler
143-
.postTask(
144-
runTask.bind(
145-
null,
146-
priorityLevel,
147-
postTaskPriority,
148-
node,
149-
continuation,
150-
),
151-
continuationOptions,
152-
)
153-
.catch(handlePostTaskError);
154-
}
132+
result = callback(didTimeout_DEPRECATED);
133+
} catch (error) {
134+
// We're inside a promise. If we don't handle this error, then it will
135+
// trigger an "Unhandled promise rejection" error. We don't want that, but
136+
// we do want the default error reporting behavior that normal (non-Promise)
137+
// tasks get for unhandled errors.
138+
//
139+
// So we'll re-throw the error inside a regular browser task.
140+
setTimeout(() => {
141+
throw error;
142+
});
155143
} finally {
156144
currentPriorityLevel_DEPRECATED = NormalPriority;
157145
}
146+
if (typeof result === 'function') {
147+
// Assume this is a continuation
148+
const continuation: SchedulerCallback<T> = (result: any);
149+
const continuationController = new TaskController();
150+
const continuationOptions = {
151+
priority: postTaskPriority,
152+
signal: continuationController.signal,
153+
};
154+
// Update the original callback node's controller, since even though we're
155+
// posting a new task, conceptually it's the same one.
156+
node._controller = continuationController;
157+
scheduler
158+
.postTask(
159+
runTask.bind(null, priorityLevel, postTaskPriority, node, continuation),
160+
continuationOptions,
161+
)
162+
.catch(handleAbortError);
163+
}
158164
}
159165

160-
function handlePostTaskError(error) {
161-
// This error is either a user error thrown by a callback, or an AbortError
162-
// as a result of a cancellation.
163-
//
164-
// User errors trigger a global `error` event even if we don't rethrow them.
165-
// In fact, if we do rethrow them, they'll get reported to the console twice.
166-
// I'm not entirely sure the current `postTask` spec makes sense here. If I
167-
// catch a `postTask` call, it shouldn't trigger a global error.
168-
//
166+
function handleAbortError(error) {
169167
// Abort errors are an implementation detail. We don't expose the
170168
// TaskController to the user, nor do we expose the promise that is returned
171-
// from `postTask`. So we shouldn't rethrow those, either, since there's no
172-
// way to handle them. (If we did return the promise to the user, then it
173-
// should be up to them to handle the AbortError.)
174-
//
175-
// In either case, we can suppress the error, barring changes to the spec
176-
// or the Scheduler API.
169+
// from `postTask`. So we should suppress them, since there's no way for the
170+
// user to handle them.
177171
}
178172

179173
export function unstable_cancelCallback(node: CallbackNode) {

packages/scheduler/src/__tests__/SchedulerPostTask-test.js

+14-16
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ describe('SchedulerPostTask', () => {
7070
},
7171
};
7272

73+
// Note: setTimeout is used to report errors and nothing else
74+
window.setTimeout = cb => {
75+
try {
76+
cb();
77+
} catch (error) {
78+
runtime.log(`Error: ${error.message}`);
79+
}
80+
};
81+
7382
// Mock browser scheduler.
7483
const scheduler = {};
7584
global.scheduler = scheduler;
@@ -116,16 +125,10 @@ describe('SchedulerPostTask', () => {
116125
// delete the continuation task.
117126
const prevTaskQueue = taskQueue;
118127
taskQueue = new Map();
119-
for (const [, {id, callback, resolve, reject}] of prevTaskQueue) {
120-
try {
121-
log(`Task ${id} Fired`);
122-
callback(false);
123-
resolve();
124-
} catch (error) {
125-
log(`Task ${id} errored [${error.message}]`);
126-
reject(error);
127-
continue;
128-
}
128+
for (const [, {id, callback, resolve}] of prevTaskQueue) {
129+
log(`Task ${id} Fired`);
130+
callback(false);
131+
resolve();
129132
}
130133
}
131134
function log(val) {
@@ -219,12 +222,7 @@ describe('SchedulerPostTask', () => {
219222
'Post Task 1 [user-visible]',
220223
]);
221224
runtime.flushTasks();
222-
runtime.assertLog([
223-
'Task 0 Fired',
224-
'Task 0 errored [Oops!]',
225-
'Task 1 Fired',
226-
'Yay',
227-
]);
225+
runtime.assertLog(['Task 0 Fired', 'Error: Oops!', 'Task 1 Fired', 'Yay']);
228226
});
229227

230228
it('schedule new task after queue has emptied', () => {

0 commit comments

Comments
 (0)