diff --git a/fixtures/schedule/index.html b/fixtures/schedule/index.html index 24fd26aee95f2..d5b243a3c2f84 100644 --- a/fixtures/schedule/index.html +++ b/fixtures/schedule/index.html @@ -69,6 +69,16 @@

Tests:

Actual:
+
  • +

    When some callbacks throw errors, still calls them all within the same frame

    +

    IMPORTANT: Open the console when you run this! Inspect the logs there!

    + +
  • +
  • +

    When some callbacks throw errors and some also time out, still calls them all within the same frame

    +

    IMPORTANT: Open the console when you run this! Inspect the logs there!

    + +
  • @@ -134,6 +144,9 @@

    Tests:

    // test 4 [ ], + // test 5 + [ + ], ]; const expectedResults = [ @@ -182,6 +195,10 @@

    Tests:

    'cbD called with argument of {"didTimeout":false}', 'frame 3 started... we stop counting now.', ], + // test 5 + [ + // ... TODO + ], ]; function runTestOne() { // Test 1 @@ -276,7 +293,7 @@

    Tests:

    updateTestResult(4, 'scheduled cbA'); scheduleWork(cbB, {timeout: 100}); // times out later updateTestResult(4, 'scheduled cbB'); - scheduleWork(cbC, {timeout: 2}); // will time out fast + scheduleWork(cbC, {timeout: 1}); // will time out fast updateTestResult(4, 'scheduled cbC'); scheduleWork(cbD); // won't time out updateTestResult(4, 'scheduled cbD'); @@ -287,6 +304,165 @@

    Tests:

    displayTestResult(4); checkTestResult(4); }); + +} + +// Error handling + +function runTestFive() { + // Test 5 + // When some callbacks throw errors, still calls them all within the same frame + const cbA = (x) => { + console.log('cbA called with argument of ' + JSON.stringify(x)); + } + const cbB = (x) => { + console.log('cbB called with argument of ' + JSON.stringify(x)); + console.log('cbB is about to throw an error!'); + throw new Error('error B'); + } + const cbC = (x) => { + console.log('cbC called with argument of ' + JSON.stringify(x)); + } + const cbD = (x) => { + console.log('cbD called with argument of ' + JSON.stringify(x)); + console.log('cbD is about to throw an error!'); + throw new Error('error D'); + } + const cbE = (x) => { + console.log('cbE called with argument of ' + JSON.stringify(x)); + console.log('This was the last callback! ------------------'); + } + + console.log('We are aiming to roughly emulate the way ' + + '`requestAnimationFrame` handles errors from callbacks.'); + + console.log('about to run the simulation of what it should look like...:'); + + requestAnimationFrame(() => { + console.log('frame 1 started'); + requestAnimationFrame(() => { + console.log('frame 2 started'); + requestAnimationFrame(() => { + console.log('frame 3 started... we stop counting now.'); + console.log('about to wait a moment and start this again but ' + + 'with the scheduler instead of requestAnimationFrame'); + setTimeout(runSchedulerCode, 1000); + }); + }); + }); + requestAnimationFrame(cbA); + console.log('scheduled cbA'); + requestAnimationFrame(cbB); // will throw error + console.log('scheduled cbB'); + requestAnimationFrame(cbC); + console.log('scheduled cbC'); + requestAnimationFrame(cbD); // will throw error + console.log('scheduled cbD'); + requestAnimationFrame(cbE); + console.log('scheduled cbE'); + + + function runSchedulerCode() { + console.log('-------------------------------------------------------------'); + console.log('now lets see what it looks like using the scheduler...:'); + requestAnimationFrame(() => { + console.log('frame 1 started'); + requestAnimationFrame(() => { + console.log('frame 2 started'); + requestAnimationFrame(() => { + console.log('frame 3 started... we stop counting now.'); + }); + }); + }); + scheduleWork(cbA); + console.log('scheduled cbA'); + scheduleWork(cbB); // will throw error + console.log('scheduled cbB'); + scheduleWork(cbC); + console.log('scheduled cbC'); + scheduleWork(cbD); // will throw error + console.log('scheduled cbD'); + scheduleWork(cbE); + console.log('scheduled cbE'); + }; +} + +function runTestSix() { + // Test 6 + // When some callbacks throw errors, still calls them all within the same frame + const cbA = (x) => { + console.log('cbA called with argument of ' + JSON.stringify(x)); + console.log('cbA is about to throw an error!'); + throw new Error('error A'); + } + const cbB = (x) => { + console.log('cbB called with argument of ' + JSON.stringify(x)); + } + const cbC = (x) => { + console.log('cbC called with argument of ' + JSON.stringify(x)); + } + const cbD = (x) => { + console.log('cbD called with argument of ' + JSON.stringify(x)); + console.log('cbD is about to throw an error!'); + throw new Error('error D'); + } + const cbE = (x) => { + console.log('cbE called with argument of ' + JSON.stringify(x)); + console.log('This was the last callback! ------------------'); + } + + console.log('We are aiming to roughly emulate the way ' + + '`requestAnimationFrame` handles errors from callbacks.'); + + console.log('about to run the simulation of what it should look like...:'); + + requestAnimationFrame(() => { + console.log('frame 1 started'); + requestAnimationFrame(() => { + console.log('frame 2 started'); + requestAnimationFrame(() => { + console.log('frame 3 started... we stop counting now.'); + console.log('about to wait a moment and start this again but ' + + 'with the scheduler instead of requestAnimationFrame'); + setTimeout(runSchedulerCode, 1000); + }); + }); + }); + requestAnimationFrame(cbC); + console.log('scheduled cbC first; simulating timing out'); + requestAnimationFrame(cbD); // will throw error + console.log('scheduled cbD first; simulating timing out'); + requestAnimationFrame(cbE); + console.log('scheduled cbE first; simulating timing out'); + requestAnimationFrame(cbA); + console.log('scheduled cbA'); // will throw error + requestAnimationFrame(cbB); + console.log('scheduled cbB'); + + + function runSchedulerCode() { + console.log('-------------------------------------------------------------'); + console.log('now lets see what it looks like using the scheduler...:'); + requestAnimationFrame(() => { + console.log('frame 1 started'); + requestAnimationFrame(() => { + console.log('frame 2 started'); + requestAnimationFrame(() => { + console.log('frame 3 started... we stop counting now.'); + }); + }); + }); + scheduleWork(cbA); + console.log('scheduled cbA'); + scheduleWork(cbB); // will throw error + console.log('scheduled cbB'); + scheduleWork(cbC, {timeout: 1}); + console.log('scheduled cbC'); + scheduleWork(cbD, {timeout: 1}); // will throw error + console.log('scheduled cbD'); + scheduleWork(cbE, {timeout: 1}); + console.log('scheduled cbE'); + }; } diff --git a/packages/react-scheduler/src/ReactScheduler.js b/packages/react-scheduler/src/ReactScheduler.js index ddcfa17a5663c..cd21d1c3fcd00 100644 --- a/packages/react-scheduler/src/ReactScheduler.js +++ b/packages/react-scheduler/src/ReactScheduler.js @@ -129,6 +129,33 @@ if (!ExecutionEnvironment.canUseDOM) { }, }; + /** + * Handles the case where a callback errors: + * - don't catch the error, because this changes debugging behavior + * - do start a new postMessage callback, to call any remaining callbacks, + * - but only if there is an error, so there is not extra overhead. + */ + const callUnsafely = function( + callbackConfig: CallbackConfigType, + arg: Deadline, + ) { + const callback = callbackConfig.scheduledCallback; + let finishedCalling = false; + try { + callback(arg); + finishedCalling = true; + } finally { + // always remove it from linked list + cancelScheduledWork(callbackConfig); + + if (!finishedCalling) { + // an error must have been thrown + isIdleScheduled = true; + window.postMessage(messageKey, '*'); + } + } + }; + /** * Checks for timed out callbacks, runs them, and then checks again to see if * any more have timed out. @@ -152,32 +179,42 @@ if (!ExecutionEnvironment.canUseDOM) { // 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; + // NOTE: we intentionally wait to update the nextSoonestTimeoutTime until + // after successfully calling any timed out callbacks. + // If a timed out callback throws an error, we could get stuck in a state + // where the nextSoonestTimeoutTime was set wrong. + let updatedNextSoonestTimeoutTime = -1; // we will update nextSoonestTimeoutTime below + const timedOutCallbacks = []; + + // iterate once to find timed out callbacks and find nextSoonestTimeoutTime let currentCallbackConfig = headOfPendingCallbacksLinkedList; while (currentCallbackConfig !== null) { const timeoutTime = currentCallbackConfig.timeoutTime; if (timeoutTime !== -1 && timeoutTime <= currentTime) { // it has timed out! - // call it - const callback = currentCallbackConfig.scheduledCallback; - // TODO: error handling - callback(frameDeadlineObject); - // remove it from linked list - cancelScheduledWork(currentCallbackConfig); + timedOutCallbacks.push(currentCallbackConfig); } else { if ( timeoutTime !== -1 && - (nextSoonestTimeoutTime === -1 || - timeoutTime < nextSoonestTimeoutTime) + (updatedNextSoonestTimeoutTime === -1 || + timeoutTime < updatedNextSoonestTimeoutTime) ) { - nextSoonestTimeoutTime = timeoutTime; + updatedNextSoonestTimeoutTime = timeoutTime; } } currentCallbackConfig = currentCallbackConfig.next; } + + if (timedOutCallbacks.length > 0) { + frameDeadlineObject.didTimeout = true; + for (let i = 0, len = timedOutCallbacks.length; i < len; i++) { + callUnsafely(timedOutCallbacks[i], frameDeadlineObject); + } + } + + // NOTE: we intentionally wait to update the nextSoonestTimeoutTime until + // after successfully calling any timed out callbacks. + nextSoonestTimeoutTime = updatedNextSoonestTimeoutTime; }; // We use the postMessage trick to defer idle work until after the repaint. @@ -206,20 +243,9 @@ if (!ExecutionEnvironment.canUseDOM) { headOfPendingCallbacksLinkedList !== null ) { const latestCallbackConfig = headOfPendingCallbacksLinkedList; - // move head of list to next callback - headOfPendingCallbacksLinkedList = latestCallbackConfig.next; - if (headOfPendingCallbacksLinkedList !== null) { - headOfPendingCallbacksLinkedList.prev = null; - } else { - // if headOfPendingCallbacksLinkedList is null, - // then the list must be empty. - // make sure we set the tail to null as well. - tailOfPendingCallbacksLinkedList = null; - } frameDeadlineObject.didTimeout = false; - const latestCallback = latestCallbackConfig.scheduledCallback; - // TODO: before using this outside of React we need to add error handling - latestCallback(frameDeadlineObject); + // callUnsafely will remove it from the head of the linked list + callUnsafely(latestCallbackConfig, frameDeadlineObject); currentTime = now(); } if (headOfPendingCallbacksLinkedList !== null) { @@ -315,6 +341,15 @@ if (!ExecutionEnvironment.canUseDOM) { cancelScheduledWork = function( callbackConfig: CallbackIdType /* CallbackConfigType */, ) { + if ( + callbackConfig.prev === null && + headOfPendingCallbacksLinkedList !== callbackConfig + ) { + // this callbackConfig has already been cancelled. + // cancelScheduledWork should be idempotent, a no-op after first call. + return; + } + /** * There are four possible cases: * - Head/nodeToRemove/Tail -> null @@ -331,6 +366,8 @@ if (!ExecutionEnvironment.canUseDOM) { */ const next = callbackConfig.next; const prev = callbackConfig.prev; + callbackConfig.next = null; + callbackConfig.prev = null; if (next !== null) { // we have a next diff --git a/packages/react-scheduler/src/__tests__/ReactScheduler-test.js b/packages/react-scheduler/src/__tests__/ReactScheduler-test.js index 88c49289fdeee..7cc6a9f279332 100644 --- a/packages/react-scheduler/src/__tests__/ReactScheduler-test.js +++ b/packages/react-scheduler/src/__tests__/ReactScheduler-test.js @@ -20,6 +20,8 @@ describe('ReactScheduler', () => { let rAFCallbacks = []; let postMessageCallback; let postMessageEvents = []; + let postMessageErrors = []; + let catchPostMessageErrors = false; function runPostMessageCallbacks(config: FrameTimeoutConfigType) { let timeLeftInFrame = 0; @@ -31,7 +33,17 @@ describe('ReactScheduler', () => { currentTime = startOfLatestFrame + frameSize - timeLeftInFrame; if (postMessageCallback) { while (postMessageEvents.length) { - postMessageCallback(postMessageEvents.shift()); + if (catchPostMessageErrors) { + // catch errors for testing error handling + try { + postMessageCallback(postMessageEvents.shift()); + } catch (e) { + postMessageErrors.push(e); + } + } else { + // we are not expecting errors + postMessageCallback(postMessageEvents.shift()); + } } } } @@ -64,6 +76,7 @@ describe('ReactScheduler', () => { const originalAddEventListener = global.addEventListener; postMessageCallback = null; postMessageEvents = []; + postMessageErrors = []; global.addEventListener = function(eventName, callback, useCapture) { if (eventName === 'message') { postMessageCallback = callback; @@ -280,8 +293,6 @@ describe('ReactScheduler', () => { describe('when there is some time left in the frame', () => { it('calls timed out callbacks and then any more pending callbacks, defers others if time runs out', () => { - // TODO first call timed out callbacks - // then any non-timed out callbacks if there is time const {scheduleWork} = ReactScheduler; startOfLatestFrame = 1000000000000; currentTime = startOfLatestFrame - 10; @@ -340,6 +351,27 @@ describe('ReactScheduler', () => { }); describe('with multiple callbacks', () => { + it('when called more than once', () => { + const {scheduleWork, cancelScheduledWork} = ReactScheduler; + const callbackLog = []; + const callbackA = jest.fn(() => callbackLog.push('A')); + const callbackB = jest.fn(() => callbackLog.push('B')); + const callbackC = jest.fn(() => callbackLog.push('C')); + scheduleWork(callbackA); + const callbackId = scheduleWork(callbackB); + scheduleWork(callbackC); + cancelScheduledWork(callbackId); + cancelScheduledWork(callbackId); + cancelScheduledWork(callbackId); + // Initially doesn't call anything + expect(callbackLog).toEqual([]); + advanceOneFrame({timeLeftInFrame: 15}); + + // Should still call A and C + expect(callbackLog).toEqual(['A', 'C']); + expect(callbackB).toHaveBeenCalledTimes(0); + }); + it('when one callback cancels the next one', () => { const {scheduleWork, cancelScheduledWork} = ReactScheduler; const callbackLog = []; @@ -361,5 +393,354 @@ describe('ReactScheduler', () => { }); }); + describe('when callbacks throw errors', () => { + describe('when some callbacks throw', () => { + /** + * + + + * | rAF postMessage | + * | | + * | +---------------------+ | + * | | paint/layout | cbA() cbB() cbC() cbD() cbE() | + * | +---------------------+ ^ ^ | + * | | | | + * + | | + + * + + + * throw errors + * + * + */ + it('still calls all callbacks within same frame', () => { + const {scheduleWork} = ReactScheduler; + const callbackLog = []; + const callbackA = jest.fn(() => callbackLog.push('A')); + const callbackB = jest.fn(() => { + callbackLog.push('B'); + throw new Error('B error'); + }); + const callbackC = jest.fn(() => callbackLog.push('C')); + const callbackD = jest.fn(() => { + callbackLog.push('D'); + throw new Error('D error'); + }); + const callbackE = jest.fn(() => callbackLog.push('E')); + scheduleWork(callbackA); + scheduleWork(callbackB); + scheduleWork(callbackC); + scheduleWork(callbackD); + scheduleWork(callbackE); + // Initially doesn't call anything + expect(callbackLog).toEqual([]); + catchPostMessageErrors = true; + advanceOneFrame({timeLeftInFrame: 15}); + // calls all callbacks + expect(callbackLog).toEqual(['A', 'B', 'C', 'D', 'E']); + // errors should still get thrown + const postMessageErrorMessages = postMessageErrors.map(e => e.message); + expect(postMessageErrorMessages).toEqual(['B error', 'D error']); + catchPostMessageErrors = false; + }); + + /** + * timed out + * + + +--+ + * + rAF postMessage | | | + + * | | | | | + * | +---------------------+ v v v | + * | | paint/layout | cbA() cbB() cbC() cbD() cbE() | + * | +---------------------+ ^ ^ | + * | | | | + * + | | + + * + + + * throw errors + * + * + */ + it('and with some timed out callbacks, still calls all callbacks within same frame', () => { + const {scheduleWork} = ReactScheduler; + const callbackLog = []; + const callbackA = jest.fn(() => { + callbackLog.push('A'); + throw new Error('A error'); + }); + const callbackB = jest.fn(() => callbackLog.push('B')); + const callbackC = jest.fn(() => callbackLog.push('C')); + const callbackD = jest.fn(() => { + callbackLog.push('D'); + throw new Error('D error'); + }); + const callbackE = jest.fn(() => callbackLog.push('E')); + scheduleWork(callbackA); + scheduleWork(callbackB); + scheduleWork(callbackC, {timeout: 2}); // times out fast + scheduleWork(callbackD, {timeout: 2}); // times out fast + scheduleWork(callbackE, {timeout: 2}); // times out fast + // Initially doesn't call anything + expect(callbackLog).toEqual([]); + catchPostMessageErrors = true; + advanceOneFrame({timeLeftInFrame: 15}); + // calls all callbacks; calls timed out ones first + expect(callbackLog).toEqual(['C', 'D', 'E', 'A', 'B']); + // errors should still get thrown + const postMessageErrorMessages = postMessageErrors.map(e => e.message); + expect(postMessageErrorMessages).toEqual(['D error', 'A error']); + catchPostMessageErrors = false; + }); + }); + describe('when all scheduled callbacks throw', () => { + /** + * + + + * | rAF postMessage | + * | | + * | +---------------------+ | + * | | paint/layout | cbA() cbB() cbC() cbD() cbE() | + * | +---------------------+ ^ ^ ^ ^ ^ | + * | | | | | | | + * + | | | | | + + * | + + + + + * + all callbacks throw errors + * + * + */ + it('still calls all callbacks within same frame', () => { + const {scheduleWork} = ReactScheduler; + const callbackLog = []; + const callbackA = jest.fn(() => { + callbackLog.push('A'); + throw new Error('A error'); + }); + const callbackB = jest.fn(() => { + callbackLog.push('B'); + throw new Error('B error'); + }); + const callbackC = jest.fn(() => { + callbackLog.push('C'); + throw new Error('C error'); + }); + const callbackD = jest.fn(() => { + callbackLog.push('D'); + throw new Error('D error'); + }); + const callbackE = jest.fn(() => { + callbackLog.push('E'); + throw new Error('E error'); + }); + scheduleWork(callbackA); + scheduleWork(callbackB); + scheduleWork(callbackC); + scheduleWork(callbackD); + scheduleWork(callbackE); + // Initially doesn't call anything + expect(callbackLog).toEqual([]); + catchPostMessageErrors = true; + advanceOneFrame({timeLeftInFrame: 15}); + // calls all callbacks + expect(callbackLog).toEqual(['A', 'B', 'C', 'D', 'E']); + // errors should still get thrown + const postMessageErrorMessages = postMessageErrors.map(e => e.message); + expect(postMessageErrorMessages).toEqual([ + 'A error', + 'B error', + 'C error', + 'D error', + 'E error', + ]); + catchPostMessageErrors = false; + }); + + /** + * postMessage + * + + + * | rAF all callbacks time out | + * | | + * | +---------------------+ | + * | | paint/layout | cbA() cbB() cbC() cbD() cbE() | + * | +---------------------+ ^ ^ ^ ^ ^ | + * | | | | | | | + * + | | | | | + + * | + + + + + * + all callbacks throw errors + * + * + */ + it('and with all timed out callbacks, still calls all callbacks within same frame', () => { + const {scheduleWork} = ReactScheduler; + const callbackLog = []; + const callbackA = jest.fn(() => { + callbackLog.push('A'); + throw new Error('A error'); + }); + const callbackB = jest.fn(() => { + callbackLog.push('B'); + throw new Error('B error'); + }); + const callbackC = jest.fn(() => { + callbackLog.push('C'); + throw new Error('C error'); + }); + const callbackD = jest.fn(() => { + callbackLog.push('D'); + throw new Error('D error'); + }); + const callbackE = jest.fn(() => { + callbackLog.push('E'); + throw new Error('E error'); + }); + scheduleWork(callbackA, {timeout: 2}); // times out fast + scheduleWork(callbackB, {timeout: 2}); // times out fast + scheduleWork(callbackC, {timeout: 2}); // times out fast + scheduleWork(callbackD, {timeout: 2}); // times out fast + scheduleWork(callbackE, {timeout: 2}); // times out fast + // Initially doesn't call anything + expect(callbackLog).toEqual([]); + catchPostMessageErrors = true; + advanceOneFrame({timeLeftInFrame: 15}); + // calls all callbacks + expect(callbackLog).toEqual(['A', 'B', 'C', 'D', 'E']); + // errors should still get thrown + const postMessageErrorMessages = postMessageErrors.map(e => e.message); + expect(postMessageErrorMessages).toEqual([ + 'A error', + 'B error', + 'C error', + 'D error', + 'E error', + ]); + catchPostMessageErrors = false; + }); + }); + describe('when callbacks throw over multiple frames', () => { + /** + * + * **Detail View of Frame 1** + * + * + + + * | rAF postMessage | + * | | + * | +---------------------+ | + * | | paint/layout | cbA() cbB() | ... Frame 2 + * | +---------------------+ ^ ^ | + * | | | | + * + + | + + * errors | + * + + * takes long time + * and pushes rest of + * callbacks into + * next frame -> + * + * + * + * **Overview of frames 1-4** + * + * + * + + + + + + * | | | | | + * | +--+ | +--+ | +--+ | +--+ | + * | +--+ A,B+-> +--+ C,D+-> +--+ E,F+-> +--+ G | + * + ^ + ^ + ^ + + + * | | | + * error error error + * + * + */ + it('still calls all callbacks within same frame', () => { + const {scheduleWork} = ReactScheduler; + startOfLatestFrame = 1000000000000; + currentTime = startOfLatestFrame - 10; + catchPostMessageErrors = true; + const callbackLog = []; + const callbackA = jest.fn(() => { + callbackLog.push('A'); + throw new Error('A error'); + }); + const callbackB = jest.fn(() => { + callbackLog.push('B'); + // time passes, causing us to run out of idle time + currentTime += 25; + }); + const callbackC = jest.fn(() => { + callbackLog.push('C'); + throw new Error('C error'); + }); + const callbackD = jest.fn(() => { + callbackLog.push('D'); + // time passes, causing us to run out of idle time + currentTime += 25; + }); + const callbackE = jest.fn(() => { + callbackLog.push('E'); + throw new Error('E error'); + }); + const callbackF = jest.fn(() => { + callbackLog.push('F'); + // time passes, causing us to run out of idle time + currentTime += 25; + }); + const callbackG = jest.fn(() => callbackLog.push('G')); + + scheduleWork(callbackA); + scheduleWork(callbackB); + scheduleWork(callbackC); + scheduleWork(callbackD); + scheduleWork(callbackE); + scheduleWork(callbackF); + scheduleWork(callbackG); + + // does nothing initially + expect(callbackLog).toEqual([]); + + // frame 1; + // callback A runs and throws, callback B takes up rest of frame + advanceOneFrame({timeLeftInFrame: 15}); // runs rAF and postMessage callbacks + + // calls A and B + expect(callbackLog).toEqual(['A', 'B']); + // error was thrown from A + let postMessageErrorMessages = postMessageErrors.map(e => e.message); + expect(postMessageErrorMessages).toEqual(['A error']); + + // frame 2; + // callback C runs and throws, callback D takes up rest of frame + advanceOneFrame({timeLeftInFrame: 15}); // runs rAF and postMessage callbacks + + // calls C and D + expect(callbackLog).toEqual(['A', 'B', 'C', 'D']); + // error was thrown from A + postMessageErrorMessages = postMessageErrors.map(e => e.message); + expect(postMessageErrorMessages).toEqual(['A error', 'C error']); + + // frame 3; + // callback E runs and throws, callback F takes up rest of frame + advanceOneFrame({timeLeftInFrame: 15}); // runs rAF and postMessage callbacks + + // calls E and F + expect(callbackLog).toEqual(['A', 'B', 'C', 'D', 'E', 'F']); + // error was thrown from A + postMessageErrorMessages = postMessageErrors.map(e => e.message); + expect(postMessageErrorMessages).toEqual([ + 'A error', + 'C error', + 'E error', + ]); + + // frame 4; + // callback G runs and it's the last one + advanceOneFrame({timeLeftInFrame: 15}); // runs rAF and postMessage callbacks + + // calls G + expect(callbackLog).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G']); + // error was thrown from A + postMessageErrorMessages = postMessageErrors.map(e => e.message); + expect(postMessageErrorMessages).toEqual([ + 'A error', + 'C error', + 'E error', + ]); + + catchPostMessageErrors = true; + }); + }); + }); + // TODO: test 'now' });