From 659782cf67885507097ee9e4692c8cfa0e3c504e Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 26 Feb 2019 17:46:32 -0800 Subject: [PATCH 1/3] Add new mock build of Scheduler with flush, yield API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test environments need a way to take control of the Scheduler queue and incrementally flush work. Our current tests accomplish this either using dynamic injection, or by using Jest's fake timers feature. Both of these options are fragile and rely too much on implementation details. In this new approach, we have a separate build of Scheduler that is specifically designed for test environments. We mock the default implementation like we would any other module; in our case, via Jest. This special build has methods like `flushAll` and `yieldValue` that control when work is flushed. These methods are based on equivalent methods we've been using to write incremental React tests. Eventually we may want to migrate the React tests to interact with the mock Scheduler directly, instead of going through the host config like we currently do. For now, I'm using our custom static injection infrastructure to create the two builds of Scheduler — a default build for DOM (which falls back to a naive timer based implementation), and the new mock build. I did it this way because it allows me to share most of the implementation, which isn't specific to a host environment — e.g. everything related to the priority queue. It may be better to duplicate the shared code instead, especially considering that future environments (like React Native) may have entirely forked implementations. I'd prefer to wait until the implementation stabilizes before worrying about that, but I'm open to changing this now if we decide it's important enough. --- packages/jest-mock-scheduler/npm/index.js | 6 +- .../src/JestMockScheduler.js | 61 --- .../ReactDOMFiberAsync-test.internal.js | 57 +-- .../src/__tests__/ReactDOMHooks-test.js | 10 +- .../src/__tests__/ReactDOMRoot-test.js | 96 ++-- ...DOMServerPartialHydration-test.internal.js | 27 +- .../ReactErrorBoundaries-test.internal.js | 5 +- .../ChangeEventPlugin-test.internal.js | 16 +- .../SimpleEventPlugin-test.internal.js | 37 +- .../ReactProfilerDOM-test.internal.js | 40 +- packages/scheduler/npm/unstable_mock.js | 7 + packages/scheduler/package.json | 1 + packages/scheduler/src/Scheduler.js | 278 +---------- .../src/SchedulerHostConfig.js} | 4 +- .../scheduler/src/__tests__/Scheduler-test.js | 454 +++++++----------- .../src/__tests__/SchedulerDOM-test.js | 9 +- .../src/__tests__/SchedulerNoDOM-test.js | 43 +- .../SchedulerUMDBundle-test.internal.js | 7 + .../src/forks/SchedulerHostConfig.default.js | 264 ++++++++++ .../src/forks/SchedulerHostConfig.mock.js | 167 +++++++ packages/scheduler/unstable_mock.js | 20 + .../__tests__/ReactDOMFrameScheduling-test.js | 17 +- scripts/jest/matchers/reactTestMatchers.js | 20 + .../jest/matchers/schedulerTestMatchers.js | 73 +++ scripts/jest/setupHostConfigs.js | 5 + scripts/jest/setupTests.js | 2 - scripts/rollup/bundles.js | 12 +- scripts/rollup/forks.js | 14 +- scripts/rollup/results.json | 52 +- 29 files changed, 933 insertions(+), 871 deletions(-) delete mode 100644 packages/jest-mock-scheduler/src/JestMockScheduler.js create mode 100644 packages/scheduler/npm/unstable_mock.js rename packages/{jest-mock-scheduler/index.js => scheduler/src/SchedulerHostConfig.js} (70%) create mode 100644 packages/scheduler/src/forks/SchedulerHostConfig.default.js create mode 100644 packages/scheduler/src/forks/SchedulerHostConfig.mock.js create mode 100644 packages/scheduler/unstable_mock.js create mode 100644 scripts/jest/matchers/schedulerTestMatchers.js diff --git a/packages/jest-mock-scheduler/npm/index.js b/packages/jest-mock-scheduler/npm/index.js index d7a102c9712..9a1d6ca4d14 100644 --- a/packages/jest-mock-scheduler/npm/index.js +++ b/packages/jest-mock-scheduler/npm/index.js @@ -1,7 +1,3 @@ 'use strict'; -if (process.env.NODE_ENV === 'production') { - module.exports = require('./cjs/jest-mock-scheduler.production.min.js'); -} else { - module.exports = require('./cjs/jest-mock-scheduler.development.js'); -} +module.exports = require('scheduler/unstable_mock'); diff --git a/packages/jest-mock-scheduler/src/JestMockScheduler.js b/packages/jest-mock-scheduler/src/JestMockScheduler.js deleted file mode 100644 index 609ece2c219..00000000000 --- a/packages/jest-mock-scheduler/src/JestMockScheduler.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * 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. - */ - -// Max 31 bit integer. The max integer size in V8 for 32-bit systems. -// Math.pow(2, 30) - 1 -// 0b111111111111111111111111111111 -const maxSigned31BitInt = 1073741823; - -export function mockRestore() { - delete global._schedMock; -} - -let callback = null; -let currentTime = -1; - -function flushCallback(didTimeout, ms) { - if (callback !== null) { - let cb = callback; - callback = null; - try { - currentTime = ms; - cb(didTimeout); - } finally { - currentTime = -1; - } - } -} - -function requestHostCallback(cb, ms) { - if (currentTime !== -1) { - // Protect against re-entrancy. - setTimeout(requestHostCallback, 0, cb, ms); - } else { - callback = cb; - setTimeout(flushCallback, ms, true, ms); - setTimeout(flushCallback, maxSigned31BitInt, false, maxSigned31BitInt); - } -} - -function cancelHostCallback() { - callback = null; -} - -function shouldYieldToHost() { - return false; -} - -function getCurrentTime() { - return currentTime === -1 ? 0 : currentTime; -} - -global._schedMock = [ - requestHostCallback, - cancelHostCallback, - shouldYieldToHost, - getCurrentTime, -]; diff --git a/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.internal.js index d49a6218cb6..69013757ae8 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMFiberAsync-test.internal.js @@ -13,6 +13,7 @@ const React = require('react'); let ReactFeatureFlags = require('shared/ReactFeatureFlags'); let ReactDOM; +let Scheduler; const ConcurrentMode = React.unstable_ConcurrentMode; @@ -25,33 +26,10 @@ describe('ReactDOMFiberAsync', () => { let container; beforeEach(() => { - // TODO pull this into helper method, reduce repetition. - // mock the browser APIs which are used in schedule: - // - requestAnimationFrame should pass the DOMHighResTimeStamp argument - // - calling 'window.postMessage' should actually fire postmessage handlers - global.requestAnimationFrame = function(cb) { - return setTimeout(() => { - cb(Date.now()); - }); - }; - const originalAddEventListener = global.addEventListener; - let postMessageCallback; - global.addEventListener = function(eventName, callback, useCapture) { - if (eventName === 'message') { - postMessageCallback = callback; - } else { - originalAddEventListener(eventName, callback, useCapture); - } - }; - global.postMessage = function(messageKey, targetOrigin) { - const postMessageEvent = {source: window, data: messageKey}; - if (postMessageCallback) { - postMessageCallback(postMessageEvent); - } - }; jest.resetModules(); container = document.createElement('div'); ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); document.body.appendChild(container); }); @@ -124,6 +102,7 @@ describe('ReactDOMFiberAsync', () => { // Should flush both updates now. jest.runAllTimers(); + Scheduler.flushAll(); expect(asyncValueRef.current.textContent).toBe('hello'); expect(syncValueRef.current.textContent).toBe('hello'); }); @@ -133,6 +112,7 @@ describe('ReactDOMFiberAsync', () => { jest.resetModules(); ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); }); it('renders synchronously', () => { @@ -160,18 +140,19 @@ describe('ReactDOMFiberAsync', () => { ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); }); it('createRoot makes the entire tree async', () => { const root = ReactDOM.unstable_createRoot(container); root.render(
Hi
); expect(container.textContent).toEqual(''); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('Hi'); root.render(
Bye
); expect(container.textContent).toEqual('Hi'); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('Bye'); }); @@ -188,12 +169,12 @@ describe('ReactDOMFiberAsync', () => { const root = ReactDOM.unstable_createRoot(container); root.render(); expect(container.textContent).toEqual(''); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('0'); instance.setState({step: 1}); expect(container.textContent).toEqual('0'); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('1'); }); @@ -213,11 +194,11 @@ describe('ReactDOMFiberAsync', () => { , container, ); - jest.runAllTimers(); + Scheduler.flushAll(); instance.setState({step: 1}); expect(container.textContent).toEqual('0'); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('1'); }); @@ -239,11 +220,11 @@ describe('ReactDOMFiberAsync', () => { , container, ); - jest.runAllTimers(); + Scheduler.flushAll(); instance.setState({step: 1}); expect(container.textContent).toEqual('0'); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('1'); }); @@ -369,7 +350,7 @@ describe('ReactDOMFiberAsync', () => { , container, ); - jest.runAllTimers(); + Scheduler.flushAll(); // Updates are async by default instance.push('A'); @@ -392,7 +373,7 @@ describe('ReactDOMFiberAsync', () => { expect(ops).toEqual(['BC']); // Flush the async updates - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('ABCD'); expect(ops).toEqual(['BC', 'ABCD']); }); @@ -419,7 +400,7 @@ describe('ReactDOMFiberAsync', () => { // Test that a normal update is async inst.increment(); expect(container.textContent).toEqual('0'); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('1'); let ops = []; @@ -525,7 +506,7 @@ describe('ReactDOMFiberAsync', () => { const root = ReactDOM.unstable_createRoot(container); root.render(
); // Flush - jest.runAllTimers(); + Scheduler.flushAll(); let disableButton = disableButtonRef.current; expect(disableButton.tagName).toBe('BUTTON'); @@ -592,7 +573,7 @@ describe('ReactDOMFiberAsync', () => { const root = ReactDOM.unstable_createRoot(container); root.render(); // Flush - jest.runAllTimers(); + Scheduler.flushAll(); let disableButton = disableButtonRef.current; expect(disableButton.tagName).toBe('BUTTON'); @@ -652,7 +633,7 @@ describe('ReactDOMFiberAsync', () => { const root = ReactDOM.unstable_createRoot(container); root.render(); // Flush - jest.runAllTimers(); + Scheduler.flushAll(); let enableButton = enableButtonRef.current; expect(enableButton.tagName).toBe('BUTTON'); diff --git a/packages/react-dom/src/__tests__/ReactDOMHooks-test.js b/packages/react-dom/src/__tests__/ReactDOMHooks-test.js index 84ad3454a18..5dff31ad17a 100644 --- a/packages/react-dom/src/__tests__/ReactDOMHooks-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMHooks-test.js @@ -11,6 +11,7 @@ let React; let ReactDOM; +let Scheduler; describe('ReactDOMHooks', () => { let container; @@ -20,6 +21,7 @@ describe('ReactDOMHooks', () => { React = require('react'); ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); container = document.createElement('div'); document.body.appendChild(container); @@ -55,7 +57,7 @@ describe('ReactDOMHooks', () => { expect(container.textContent).toBe('1'); expect(container2.textContent).toBe(''); expect(container3.textContent).toBe(''); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toBe('1'); expect(container2.textContent).toBe('2'); expect(container3.textContent).toBe('3'); @@ -64,7 +66,7 @@ describe('ReactDOMHooks', () => { expect(container.textContent).toBe('2'); expect(container2.textContent).toBe('2'); // Not flushed yet expect(container3.textContent).toBe('3'); // Not flushed yet - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toBe('2'); expect(container2.textContent).toBe('4'); expect(container3.textContent).toBe('6'); @@ -166,14 +168,14 @@ describe('ReactDOMHooks', () => { , ); - jest.runAllTimers(); + Scheduler.flushAll(); inputRef.current.value = 'abc'; inputRef.current.dispatchEvent( new Event('input', {bubbles: true, cancelable: true}), ); - jest.runAllTimers(); + Scheduler.flushAll(); expect(labelRef.current.innerHTML).toBe('abc'); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMRoot-test.js b/packages/react-dom/src/__tests__/ReactDOMRoot-test.js index 3ae0414d3cd..670f45e28bb 100644 --- a/packages/react-dom/src/__tests__/ReactDOMRoot-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMRoot-test.js @@ -12,74 +12,36 @@ let React = require('react'); let ReactDOM = require('react-dom'); let ReactDOMServer = require('react-dom/server'); +let Scheduler = require('scheduler'); let ConcurrentMode = React.unstable_ConcurrentMode; describe('ReactDOMRoot', () => { let container; - let advanceCurrentTime; - beforeEach(() => { - container = document.createElement('div'); - // TODO pull this into helper method, reduce repetition. - // mock the browser APIs which are used in schedule: - // - requestAnimationFrame should pass the DOMHighResTimeStamp argument - // - calling 'window.postMessage' should actually fire postmessage handlers - // - must allow artificially changing time returned by Date.now - // Performance.now is not supported in the test environment - const originalDateNow = Date.now; - let advancedTime = null; - global.Date.now = function() { - if (advancedTime) { - return originalDateNow() + advancedTime; - } - return originalDateNow(); - }; - advanceCurrentTime = function(amount) { - advancedTime = amount; - }; - global.requestAnimationFrame = function(cb) { - return setTimeout(() => { - cb(Date.now()); - }); - }; - const originalAddEventListener = global.addEventListener; - let postMessageCallback; - global.addEventListener = function(eventName, callback, useCapture) { - if (eventName === 'message') { - postMessageCallback = callback; - } else { - originalAddEventListener(eventName, callback, useCapture); - } - }; - global.postMessage = function(messageKey, targetOrigin) { - const postMessageEvent = {source: window, data: messageKey}; - if (postMessageCallback) { - postMessageCallback(postMessageEvent); - } - }; - jest.resetModules(); + container = document.createElement('div'); React = require('react'); ReactDOM = require('react-dom'); ReactDOMServer = require('react-dom/server'); + Scheduler = require('scheduler'); ConcurrentMode = React.unstable_ConcurrentMode; }); it('renders children', () => { const root = ReactDOM.unstable_createRoot(container); root.render(
Hi
); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('Hi'); }); it('unmounts children', () => { const root = ReactDOM.unstable_createRoot(container); root.render(
Hi
); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('Hi'); root.unmount(); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual(''); }); @@ -91,7 +53,7 @@ describe('ReactDOMRoot', () => { ops.push('inside callback: ' + container.textContent); }); ops.push('before committing: ' + container.textContent); - jest.runAllTimers(); + Scheduler.flushAll(); ops.push('after committing: ' + container.textContent); expect(ops).toEqual([ 'before committing: ', @@ -104,7 +66,7 @@ describe('ReactDOMRoot', () => { it('resolves `work.then` callback synchronously if the work already committed', () => { const root = ReactDOM.unstable_createRoot(container); const work = root.render(Hi); - jest.runAllTimers(); + Scheduler.flushAll(); let ops = []; work.then(() => { ops.push('inside callback'); @@ -132,7 +94,7 @@ describe('ReactDOMRoot', () => { , ); - jest.runAllTimers(); + Scheduler.flushAll(); // Accepts `hydrate` option const container2 = document.createElement('div'); @@ -143,7 +105,7 @@ describe('ReactDOMRoot', () => { , ); - expect(jest.runAllTimers).toWarnDev('Extra attributes', { + expect(() => Scheduler.flushAll()).toWarnDev('Extra attributes', { withoutStack: true, }); }); @@ -157,7 +119,7 @@ describe('ReactDOMRoot', () => { d , ); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('abcd'); root.render(
@@ -165,7 +127,7 @@ describe('ReactDOMRoot', () => { c
, ); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('abdc'); }); @@ -201,7 +163,7 @@ describe('ReactDOMRoot', () => { , ); - jest.runAllTimers(); + Scheduler.flushAll(); // Hasn't updated yet expect(container.textContent).toEqual(''); @@ -230,7 +192,7 @@ describe('ReactDOMRoot', () => { const batch = root.createBatch(); batch.render(Hi); // Flush all async work. - jest.runAllTimers(); + Scheduler.flushAll(); // Root should complete without committing. expect(ops).toEqual(['Foo']); expect(container.textContent).toEqual(''); @@ -248,7 +210,7 @@ describe('ReactDOMRoot', () => { const batch = root.createBatch(); batch.render(Foo); - jest.runAllTimers(); + Scheduler.flushAll(); // Hasn't updated yet expect(container.textContent).toEqual(''); @@ -288,7 +250,7 @@ describe('ReactDOMRoot', () => { const root = ReactDOM.unstable_createRoot(container); root.render(1); - advanceCurrentTime(2000); + Scheduler.advanceTime(2000); // This batch has a later expiration time than the earlier update. const batch = root.createBatch(); @@ -296,7 +258,7 @@ describe('ReactDOMRoot', () => { batch.commit(); expect(container.textContent).toEqual(''); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('1'); }); @@ -323,7 +285,7 @@ describe('ReactDOMRoot', () => { batch1.render(1); // This batch has a later expiration time - advanceCurrentTime(2000); + Scheduler.advanceTime(2000); const batch2 = root.createBatch(); batch2.render(2); @@ -342,7 +304,7 @@ describe('ReactDOMRoot', () => { batch1.render(1); // This batch has a later expiration time - advanceCurrentTime(2000); + Scheduler.advanceTime(2000); const batch2 = root.createBatch(); batch2.render(2); @@ -352,7 +314,7 @@ describe('ReactDOMRoot', () => { expect(container.textContent).toEqual('2'); batch1.commit(); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('1'); }); @@ -378,7 +340,7 @@ describe('ReactDOMRoot', () => { it('warns when rendering with legacy API into createRoot() container', () => { const root = ReactDOM.unstable_createRoot(container); root.render(
Hi
); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('Hi'); expect(() => { ReactDOM.render(
Bye
, container); @@ -393,7 +355,7 @@ describe('ReactDOMRoot', () => { ], {withoutStack: true}, ); - jest.runAllTimers(); + Scheduler.flushAll(); // This works now but we could disallow it: expect(container.textContent).toEqual('Bye'); }); @@ -401,7 +363,7 @@ describe('ReactDOMRoot', () => { it('warns when hydrating with legacy API into createRoot() container', () => { const root = ReactDOM.unstable_createRoot(container); root.render(
Hi
); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('Hi'); expect(() => { ReactDOM.hydrate(
Hi
, container); @@ -421,7 +383,7 @@ describe('ReactDOMRoot', () => { it('warns when unmounting with legacy API (no previous content)', () => { const root = ReactDOM.unstable_createRoot(container); root.render(
Hi
); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('Hi'); let unmounted = false; expect(() => { @@ -437,10 +399,10 @@ describe('ReactDOMRoot', () => { {withoutStack: true}, ); expect(unmounted).toBe(false); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('Hi'); root.unmount(); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual(''); }); @@ -450,17 +412,17 @@ describe('ReactDOMRoot', () => { // The rest is the same as test above. const root = ReactDOM.unstable_createRoot(container); root.render(
Hi
); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('Hi'); let unmounted = false; expect(() => { unmounted = ReactDOM.unmountComponentAtNode(container); }).toWarnDev('Did you mean to call root.unmount()?', {withoutStack: true}); expect(unmounted).toBe(false); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual('Hi'); root.unmount(); - jest.runAllTimers(); + Scheduler.flushAll(); expect(container.textContent).toEqual(''); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index c0d18287499..22e5f30f000 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -12,6 +12,7 @@ let React; let ReactDOM; let ReactDOMServer; +let Scheduler; let ReactFeatureFlags; let Suspense; let act; @@ -27,6 +28,7 @@ describe('ReactDOMServerPartialHydration', () => { ReactDOM = require('react-dom'); act = require('react-dom/test-utils').act; ReactDOMServer = require('react-dom/server'); + Scheduler = require('scheduler'); Suspense = React.Suspense; }); @@ -72,6 +74,7 @@ describe('ReactDOMServerPartialHydration', () => { suspend = true; let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); root.render(); + Scheduler.flushAll(); jest.runAllTimers(); expect(ref.current).toBe(null); @@ -80,6 +83,7 @@ describe('ReactDOMServerPartialHydration', () => { suspend = false; resolve(); await promise; + Scheduler.flushAll(); jest.runAllTimers(); // We should now have hydrated with a ref on the existing span. @@ -238,6 +242,7 @@ describe('ReactDOMServerPartialHydration', () => { suspend = true; let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); root.render(); + Scheduler.flushAll(); jest.runAllTimers(); expect(ref.current).toBe(null); @@ -253,6 +258,7 @@ describe('ReactDOMServerPartialHydration', () => { // Flushing both of these in the same batch won't be able to hydrate so we'll // probably throw away the existing subtree. + Scheduler.flushAll(); jest.runAllTimers(); // Pick up the new span. In an ideal implementation this might be the same span @@ -305,15 +311,17 @@ describe('ReactDOMServerPartialHydration', () => { suspend = true; let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); root.render(); + Scheduler.flushAll(); jest.runAllTimers(); expect(ref.current).toBe(null); // Render an update, but leave it still suspended. root.render(); + Scheduler.flushAll(); + jest.runAllTimers(); // Flushing now should delete the existing content and show the fallback. - jest.runAllTimers(); expect(container.getElementsByTagName('span').length).toBe(0); expect(ref.current).toBe(null); @@ -324,6 +332,7 @@ describe('ReactDOMServerPartialHydration', () => { resolve(); await promise; + Scheduler.flushAll(); jest.runAllTimers(); let span = container.getElementsByTagName('span')[0]; @@ -375,6 +384,7 @@ describe('ReactDOMServerPartialHydration', () => { suspend = true; let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); root.render(); + Scheduler.flushAll(); jest.runAllTimers(); expect(ref.current).toBe(null); @@ -383,6 +393,7 @@ describe('ReactDOMServerPartialHydration', () => { root.render(); // Flushing now should delete the existing content and show the fallback. + Scheduler.flushAll(); jest.runAllTimers(); expect(container.getElementsByTagName('span').length).toBe(0); @@ -394,6 +405,7 @@ describe('ReactDOMServerPartialHydration', () => { resolve(); await promise; + Scheduler.flushAll(); jest.runAllTimers(); let span = container.getElementsByTagName('span')[0]; @@ -444,6 +456,7 @@ describe('ReactDOMServerPartialHydration', () => { suspend = true; let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); root.render(); + Scheduler.flushAll(); jest.runAllTimers(); expect(ref.current).toBe(null); @@ -452,6 +465,7 @@ describe('ReactDOMServerPartialHydration', () => { root.render(); // Flushing now should delete the existing content and show the fallback. + Scheduler.flushAll(); jest.runAllTimers(); expect(container.getElementsByTagName('span').length).toBe(0); @@ -463,6 +477,7 @@ describe('ReactDOMServerPartialHydration', () => { resolve(); await promise; + Scheduler.flushAll(); jest.runAllTimers(); let span = container.getElementsByTagName('span')[0]; @@ -522,6 +537,7 @@ describe('ReactDOMServerPartialHydration', () => { , ); + Scheduler.flushAll(); jest.runAllTimers(); expect(ref.current).toBe(null); @@ -541,6 +557,7 @@ describe('ReactDOMServerPartialHydration', () => { // Flushing both of these in the same batch won't be able to hydrate so we'll // probably throw away the existing subtree. + Scheduler.flushAll(); jest.runAllTimers(); // Pick up the new span. In an ideal implementation this might be the same span @@ -603,6 +620,7 @@ describe('ReactDOMServerPartialHydration', () => { , ); + Scheduler.flushAll(); jest.runAllTimers(); expect(ref.current).toBe(null); @@ -615,6 +633,7 @@ describe('ReactDOMServerPartialHydration', () => { ); // Flushing now should delete the existing content and show the fallback. + Scheduler.flushAll(); jest.runAllTimers(); expect(container.getElementsByTagName('span').length).toBe(0); @@ -626,6 +645,7 @@ describe('ReactDOMServerPartialHydration', () => { resolve(); await promise; + Scheduler.flushAll(); jest.runAllTimers(); let span = container.getElementsByTagName('span')[0]; @@ -674,6 +694,7 @@ describe('ReactDOMServerPartialHydration', () => { suspend = false; let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); root.render(); + Scheduler.flushAll(); jest.runAllTimers(); expect(container.textContent).toBe('Hello'); @@ -746,6 +767,7 @@ describe('ReactDOMServerPartialHydration', () => { suspend = false; let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); root.render(); + Scheduler.flushAll(); jest.runAllTimers(); // We're still loading because we're waiting for the server to stream more content. @@ -761,6 +783,7 @@ describe('ReactDOMServerPartialHydration', () => { // But it is not yet hydrated. expect(ref.current).toBe(null); + Scheduler.flushAll(); jest.runAllTimers(); // Now it's hydrated. @@ -837,6 +860,7 @@ describe('ReactDOMServerPartialHydration', () => { suspend = false; let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); root.render(); + Scheduler.flushAll(); jest.runAllTimers(); // We're still loading because we're waiting for the server to stream more content. @@ -850,6 +874,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.textContent).toBe('Loading...'); expect(ref.current).toBe(null); + Scheduler.flushAll(); jest.runAllTimers(); // Hydrating should've generated an error and replaced the suspense boundary. diff --git a/packages/react-dom/src/__tests__/ReactErrorBoundaries-test.internal.js b/packages/react-dom/src/__tests__/ReactErrorBoundaries-test.internal.js index d6ef0a1e4d4..c982c787ee0 100644 --- a/packages/react-dom/src/__tests__/ReactErrorBoundaries-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactErrorBoundaries-test.internal.js @@ -13,6 +13,7 @@ let PropTypes; let React; let ReactDOM; let ReactFeatureFlags; +let Scheduler; describe('ReactErrorBoundaries', () => { let log; @@ -44,6 +45,7 @@ describe('ReactErrorBoundaries', () => { ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false; ReactDOM = require('react-dom'); React = require('react'); + Scheduler = require('scheduler'); log = []; @@ -1839,9 +1841,8 @@ describe('ReactErrorBoundaries', () => { expect(container.firstChild.textContent).toBe('Initial value'); log.length = 0; - jest.runAllTimers(); - // Flush passive effects and handle the error + Scheduler.flushAll(); expect(log).toEqual([ 'BrokenUseEffect useEffect [!]', // Handle the error diff --git a/packages/react-dom/src/events/__tests__/ChangeEventPlugin-test.internal.js b/packages/react-dom/src/events/__tests__/ChangeEventPlugin-test.internal.js index 03d2cddeaaf..5b0317d447d 100644 --- a/packages/react-dom/src/events/__tests__/ChangeEventPlugin-test.internal.js +++ b/packages/react-dom/src/events/__tests__/ChangeEventPlugin-test.internal.js @@ -12,6 +12,7 @@ const React = require('react'); let ReactDOM = require('react-dom'); let ReactFeatureFlags; +let Scheduler; const setUntrackedChecked = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, @@ -484,6 +485,7 @@ describe('ChangeEventPlugin', () => { ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); }); it('text input', () => { const root = ReactDOM.unstable_createRoot(container); @@ -515,7 +517,7 @@ describe('ChangeEventPlugin', () => { expect(ops).toEqual([]); expect(input).toBe(undefined); // Flush callbacks. - jest.runAllTimers(); + Scheduler.flushAll(); expect(ops).toEqual(['render: initial']); expect(input.value).toBe('initial'); @@ -565,7 +567,7 @@ describe('ChangeEventPlugin', () => { expect(ops).toEqual([]); expect(input).toBe(undefined); // Flush callbacks. - jest.runAllTimers(); + Scheduler.flushAll(); expect(ops).toEqual(['render: false']); expect(input.checked).toBe(false); @@ -581,7 +583,7 @@ describe('ChangeEventPlugin', () => { // Now let's make sure we're using the controlled value. root.render(); - jest.runAllTimers(); + Scheduler.flushAll(); ops = []; @@ -624,7 +626,7 @@ describe('ChangeEventPlugin', () => { expect(ops).toEqual([]); expect(textarea).toBe(undefined); // Flush callbacks. - jest.runAllTimers(); + Scheduler.flushAll(); expect(ops).toEqual(['render: initial']); expect(textarea.value).toBe('initial'); @@ -675,7 +677,7 @@ describe('ChangeEventPlugin', () => { expect(ops).toEqual([]); expect(input).toBe(undefined); // Flush callbacks. - jest.runAllTimers(); + Scheduler.flushAll(); expect(ops).toEqual(['render: initial']); expect(input.value).toBe('initial'); @@ -726,7 +728,7 @@ describe('ChangeEventPlugin', () => { expect(ops).toEqual([]); expect(input).toBe(undefined); // Flush callbacks. - jest.runAllTimers(); + Scheduler.flushAll(); expect(ops).toEqual(['render: initial']); expect(input.value).toBe('initial'); @@ -741,7 +743,7 @@ describe('ChangeEventPlugin', () => { expect(input.value).toBe('initial'); // Flush callbacks. - jest.runAllTimers(); + Scheduler.flushAll(); // Now the click update has flushed. expect(ops).toEqual(['render: ']); expect(input.value).toBe(''); diff --git a/packages/react-dom/src/events/__tests__/SimpleEventPlugin-test.internal.js b/packages/react-dom/src/events/__tests__/SimpleEventPlugin-test.internal.js index 3f11c713714..32f66e8e0c9 100644 --- a/packages/react-dom/src/events/__tests__/SimpleEventPlugin-test.internal.js +++ b/packages/react-dom/src/events/__tests__/SimpleEventPlugin-test.internal.js @@ -13,6 +13,7 @@ describe('SimpleEventPlugin', function() { let React; let ReactDOM; let ReactFeatureFlags; + let Scheduler; let onClick; let container; @@ -35,33 +36,10 @@ describe('SimpleEventPlugin', function() { } beforeEach(function() { - // TODO pull this into helper method, reduce repetition. - // mock the browser APIs which are used in schedule: - // - requestAnimationFrame should pass the DOMHighResTimeStamp argument - // - calling 'window.postMessage' should actually fire postmessage handlers - global.requestAnimationFrame = function(cb) { - return setTimeout(() => { - cb(Date.now()); - }); - }; - const originalAddEventListener = global.addEventListener; - let postMessageCallback; - global.addEventListener = function(eventName, callback, useCapture) { - if (eventName === 'message') { - postMessageCallback = callback; - } else { - originalAddEventListener(eventName, callback, useCapture); - } - }; - global.postMessage = function(messageKey, targetOrigin) { - const postMessageEvent = {source: window, data: messageKey}; - if (postMessageCallback) { - postMessageCallback(postMessageEvent); - } - }; jest.resetModules(); React = require('react'); ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); onClick = jest.fn(); }); @@ -258,6 +236,7 @@ describe('SimpleEventPlugin', function() { ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); }); it('flushes pending interactive work before extracting event handler', () => { @@ -296,7 +275,7 @@ describe('SimpleEventPlugin', function() { expect(ops).toEqual([]); expect(button).toBe(undefined); // Flush async work - jest.runAllTimers(); + Scheduler.flushAll(); expect(ops).toEqual(['render button: enabled']); ops = []; @@ -336,7 +315,7 @@ describe('SimpleEventPlugin', function() { click(); click(); click(); - jest.runAllTimers(); + Scheduler.flushAll(); expect(ops).toEqual([]); }); @@ -367,7 +346,7 @@ describe('SimpleEventPlugin', function() { // Should not have flushed yet because it's async expect(button).toBe(undefined); // Flush async work - jest.runAllTimers(); + Scheduler.flushAll(); expect(button.textContent).toEqual('Count: 0'); function click() { @@ -390,7 +369,7 @@ describe('SimpleEventPlugin', function() { click(); // Flush the remaining work - jest.runAllTimers(); + Scheduler.flushAll(); // The counter should equal the total number of clicks expect(button.textContent).toEqual('Count: 7'); }); @@ -459,7 +438,7 @@ describe('SimpleEventPlugin', function() { click(); // Flush the remaining work - jest.runAllTimers(); + Scheduler.flushAll(); // Both counters should equal the total number of clicks expect(button.textContent).toEqual('High-pri count: 7, Low-pri count: 7'); }); diff --git a/packages/react/src/__tests__/ReactProfilerDOM-test.internal.js b/packages/react/src/__tests__/ReactProfilerDOM-test.internal.js index 354ccf25b8b..21b691c4ff9 100644 --- a/packages/react/src/__tests__/ReactProfilerDOM-test.internal.js +++ b/packages/react/src/__tests__/ReactProfilerDOM-test.internal.js @@ -13,34 +13,9 @@ let React; let ReactFeatureFlags; let ReactDOM; let SchedulerTracing; +let Scheduler; let ReactCache; -function initEnvForAsyncTesting() { - // Boilerplate copied from ReactDOMRoot-test - // TODO pull this into helper method, reduce repetition. - // TODO remove `requestAnimationFrame` when upgrading to Jest 24 with Lolex - global.requestAnimationFrame = function(cb) { - return setTimeout(() => { - cb(Date.now()); - }); - }; - const originalAddEventListener = global.addEventListener; - let postMessageCallback; - global.addEventListener = function(eventName, callback, useCapture) { - if (eventName === 'message') { - postMessageCallback = callback; - } else { - originalAddEventListener(eventName, callback, useCapture); - } - }; - global.postMessage = function(messageKey, targetOrigin) { - const postMessageEvent = {source: window, data: messageKey}; - if (postMessageCallback) { - postMessageCallback(postMessageEvent); - } - }; -} - function loadModules() { ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.debugRenderPhaseSideEffects = false; @@ -51,6 +26,7 @@ function loadModules() { React = require('react'); SchedulerTracing = require('scheduler/tracing'); ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); ReactCache = require('react-cache'); } @@ -61,7 +37,6 @@ describe('ProfilerDOM', () => { let onInteractionTraced; beforeEach(() => { - initEnvForAsyncTesting(); loadModules(); onInteractionScheduledWorkCompleted = jest.fn(); @@ -114,7 +89,7 @@ describe('ProfilerDOM', () => { batch = root.createBatch(); batch.render( }> - + , ); batch.then( @@ -125,9 +100,12 @@ describe('ProfilerDOM', () => { expect(onInteractionTraced).toHaveBeenCalledTimes(1); expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); + jest.runAllTimers(); + resourcePromise.then( SchedulerTracing.unstable_wrap(() => { jest.runAllTimers(); + Scheduler.flushAll(); expect(element.textContent).toBe('Text'); expect(onInteractionTraced).toHaveBeenCalledTimes(1); @@ -154,6 +132,8 @@ describe('ProfilerDOM', () => { ); }), ); + + Scheduler.flushAll(); }); expect(onInteractionTraced).toHaveBeenCalledTimes(1); @@ -161,7 +141,7 @@ describe('ProfilerDOM', () => { interaction, ); expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); - - jest.runAllTimers(); + Scheduler.flushAll(); + jest.advanceTimersByTime(500); }); }); diff --git a/packages/scheduler/npm/unstable_mock.js b/packages/scheduler/npm/unstable_mock.js new file mode 100644 index 00000000000..e72ea3186f0 --- /dev/null +++ b/packages/scheduler/npm/unstable_mock.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/scheduler-unstable_mock.production.min.js'); +} else { + module.exports = require('./cjs/scheduler-unstable_mock.development.js'); +} diff --git a/packages/scheduler/package.json b/packages/scheduler/package.json index a5b88e3a38f..dde926df057 100644 --- a/packages/scheduler/package.json +++ b/packages/scheduler/package.json @@ -27,6 +27,7 @@ "index.js", "tracing.js", "tracing-profiling.js", + "unstable_mock.js", "cjs/", "umd/" ], diff --git a/packages/scheduler/src/Scheduler.js b/packages/scheduler/src/Scheduler.js index 6669699cb23..ab533e9cd87 100644 --- a/packages/scheduler/src/Scheduler.js +++ b/packages/scheduler/src/Scheduler.js @@ -9,6 +9,12 @@ /* eslint-disable no-var */ import {enableSchedulerDebugging} from './SchedulerFeatureFlags'; +import { + requestHostCallback, + cancelHostCallback, + shouldYieldToHost, + getCurrentTime, +} from './SchedulerHostConfig'; // TODO: Use symbols? var ImmediatePriority = 1; @@ -47,9 +53,6 @@ var isExecutingCallback = false; var isHostCallbackScheduled = false; -var hasNativePerformanceNow = - typeof performance === 'object' && typeof performance.now === 'function'; - function ensureHostCallbackIsScheduled() { if (isExecutingCallback) { // Don't schedule work yet; wait until the next time we yield. @@ -443,275 +446,6 @@ function unstable_shouldYield() { ); } -// The remaining code is essentially a polyfill for requestIdleCallback. It -// works by scheduling a requestAnimationFrame, storing the time for the start -// of the frame, then scheduling a postMessage which gets scheduled after paint. -// Within the postMessage handler do as much work as possible until time + frame -// rate. By separating the idle call into a separate event tick we ensure that -// layout, paint and other browser work is counted against the available time. -// The frame rate is dynamically adjusted. - -// We capture a local reference to any global, in case it gets polyfilled after -// this module is initially evaluated. We want to be using a -// consistent implementation. -var localDate = Date; - -// This initialization code may run even on server environments if a component -// just imports ReactDOM (e.g. for findDOMNode). Some environments might not -// have setTimeout or clearTimeout. However, we always expect them to be defined -// on the client. https://github.com/facebook/react/pull/13088 -var localSetTimeout = typeof setTimeout === 'function' ? setTimeout : undefined; -var localClearTimeout = - typeof clearTimeout === 'function' ? clearTimeout : undefined; - -// We don't expect either of these to necessarily be defined, but we will error -// later if they are missing on the client. -var localRequestAnimationFrame = - typeof requestAnimationFrame === 'function' - ? requestAnimationFrame - : undefined; -var localCancelAnimationFrame = - typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : undefined; - -var getCurrentTime; - -// requestAnimationFrame does not run when the tab is in the background. If -// we're backgrounded we prefer for that work to happen so that the page -// continues to load in the background. So we also schedule a 'setTimeout' as -// a fallback. -// TODO: Need a better heuristic for backgrounded work. -var ANIMATION_FRAME_TIMEOUT = 100; -var rAFID; -var rAFTimeoutID; -var requestAnimationFrameWithTimeout = function(callback) { - // schedule rAF and also a setTimeout - rAFID = localRequestAnimationFrame(function(timestamp) { - // cancel the setTimeout - localClearTimeout(rAFTimeoutID); - callback(timestamp); - }); - rAFTimeoutID = localSetTimeout(function() { - // cancel the requestAnimationFrame - localCancelAnimationFrame(rAFID); - callback(getCurrentTime()); - }, ANIMATION_FRAME_TIMEOUT); -}; - -if (hasNativePerformanceNow) { - var Performance = performance; - getCurrentTime = function() { - return Performance.now(); - }; -} else { - getCurrentTime = function() { - return localDate.now(); - }; -} - -var requestHostCallback; -var cancelHostCallback; -var shouldYieldToHost; - -var globalValue = null; -if (typeof window !== 'undefined') { - globalValue = window; -} else if (typeof global !== 'undefined') { - globalValue = global; -} - -if (globalValue && globalValue._schedMock) { - // Dynamic injection, only for testing purposes. - var globalImpl = globalValue._schedMock; - requestHostCallback = globalImpl[0]; - cancelHostCallback = globalImpl[1]; - shouldYieldToHost = globalImpl[2]; - getCurrentTime = globalImpl[3]; -} else 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. - var _callback = null; - var _flushCallback = function(didTimeout) { - if (_callback !== null) { - try { - _callback(didTimeout); - } finally { - _callback = null; - } - } - }; - requestHostCallback = function(cb, ms) { - if (_callback !== null) { - // Protect against re-entrancy. - setTimeout(requestHostCallback, 0, cb); - } else { - _callback = cb; - setTimeout(_flushCallback, 0, false); - } - }; - cancelHostCallback = function() { - _callback = null; - }; - shouldYieldToHost = function() { - return false; - }; -} else { - if (typeof console !== 'undefined') { - // TODO: Remove fb.me link - if (typeof localRequestAnimationFrame !== 'function') { - 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 localCancelAnimationFrame !== 'function') { - console.error( - "This browser doesn't support cancelAnimationFrame. " + - 'Make sure that you load a ' + - 'polyfill in older browsers. https://fb.me/react-polyfills', - ); - } - } - - var scheduledHostCallback = null; - var isMessageEventScheduled = false; - var timeoutTime = -1; - - var isAnimationFrameScheduled = false; - - var isFlushingHostCallback = false; - - var frameDeadline = 0; - // We start out assuming that we run at 30fps but then the heuristic tracking - // will adjust this value to a faster fps if we get more frequent animation - // frames. - var previousFrameTime = 33; - var activeFrameTime = 33; - - shouldYieldToHost = function() { - return frameDeadline <= getCurrentTime(); - }; - - // We use the postMessage trick to defer idle work until after the repaint. - var channel = new MessageChannel(); - var port = channel.port2; - channel.port1.onmessage = function(event) { - isMessageEventScheduled = false; - - var prevScheduledCallback = scheduledHostCallback; - var prevTimeoutTime = timeoutTime; - scheduledHostCallback = null; - timeoutTime = -1; - - var currentTime = getCurrentTime(); - - var didTimeout = false; - 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 (prevTimeoutTime !== -1 && prevTimeoutTime <= currentTime) { - // Exceeded the timeout. Invoke the callback even though there's no - // time left. - didTimeout = true; - } else { - // No timeout. - if (!isAnimationFrameScheduled) { - // Schedule another animation callback so we retry later. - isAnimationFrameScheduled = true; - requestAnimationFrameWithTimeout(animationTick); - } - // Exit without invoking the callback. - scheduledHostCallback = prevScheduledCallback; - timeoutTime = prevTimeoutTime; - return; - } - } - - if (prevScheduledCallback !== null) { - isFlushingHostCallback = true; - try { - prevScheduledCallback(didTimeout); - } finally { - isFlushingHostCallback = false; - } - } - }; - - var animationTick = function(rafTime) { - if (scheduledHostCallback !== null) { - // Eagerly schedule the next animation callback at the beginning of the - // frame. If the scheduler queue is not empty at the end of the frame, it - // will continue flushing inside that callback. If the queue *is* empty, - // then it will exit immediately. Posting the callback at the start of the - // frame ensures it's fired within the earliest possible frame. If we - // waited until the end of the frame to post the callback, we risk the - // browser skipping a frame and not firing the callback until the frame - // after that. - requestAnimationFrameWithTimeout(animationTick); - } else { - // No pending work. Exit. - isAnimationFrameScheduled = false; - return; - } - - var nextFrameTime = rafTime - frameDeadline + activeFrameTime; - if ( - nextFrameTime < activeFrameTime && - previousFrameTime < activeFrameTime - ) { - if (nextFrameTime < 8) { - // Defensive coding. We don't support higher frame rates than 120hz. - // If the calculated frame time gets lower than 8, it is probably a bug. - nextFrameTime = 8; - } - // If one frame goes long, then the next one can be short to catch up. - // If two frames are short in a row, then that's an indication that we - // actually have a higher frame rate than what we're currently optimizing. - // We adjust our heuristic dynamically accordingly. For example, if we're - // running on 120hz display or 90hz VR display. - // Take the max of the two in case one of them was an anomaly due to - // missed frame deadlines. - activeFrameTime = - nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime; - } else { - previousFrameTime = nextFrameTime; - } - frameDeadline = rafTime + activeFrameTime; - if (!isMessageEventScheduled) { - isMessageEventScheduled = true; - port.postMessage(undefined); - } - }; - - requestHostCallback = function(callback, absoluteTimeout) { - scheduledHostCallback = callback; - timeoutTime = absoluteTimeout; - if (isFlushingHostCallback || absoluteTimeout < 0) { - // Don't wait for the next frame. Continue working ASAP, in a new event. - port.postMessage(undefined); - } else 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 - // might want to still have setTimeout trigger rIC as a backup to ensure - // that we keep performing work. - isAnimationFrameScheduled = true; - requestAnimationFrameWithTimeout(animationTick); - } - }; - - cancelHostCallback = function() { - scheduledHostCallback = null; - isMessageEventScheduled = false; - timeoutTime = -1; - }; -} - export { ImmediatePriority as unstable_ImmediatePriority, UserBlockingPriority as unstable_UserBlockingPriority, diff --git a/packages/jest-mock-scheduler/index.js b/packages/scheduler/src/SchedulerHostConfig.js similarity index 70% rename from packages/jest-mock-scheduler/index.js rename to packages/scheduler/src/SchedulerHostConfig.js index c1311b7e234..a4af8487025 100644 --- a/packages/jest-mock-scheduler/index.js +++ b/packages/scheduler/src/SchedulerHostConfig.js @@ -3,6 +3,8 @@ * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. + * + * @flow */ -export * from './src/JestMockScheduler'; +throw new Error('This module must be shimmed by a specific build.'); diff --git a/packages/scheduler/src/__tests__/Scheduler-test.js b/packages/scheduler/src/__tests__/Scheduler-test.js index 8f9b7708de1..117daf69166 100644 --- a/packages/scheduler/src/__tests__/Scheduler-test.js +++ b/packages/scheduler/src/__tests__/Scheduler-test.js @@ -9,6 +9,7 @@ 'use strict'; +let Scheduler; let runWithPriority; let ImmediatePriority; let UserBlockingPriority; @@ -18,211 +19,44 @@ let cancelCallback; let wrapCallback; let getCurrentPriorityLevel; let shouldYield; -let flushWork; -let advanceTime; -let doWork; -let yieldedValues; -let yieldValue; -let clearYieldedValues; describe('Scheduler', () => { beforeEach(() => { - jest.useFakeTimers(); jest.resetModules(); - - const JestMockScheduler = require('jest-mock-scheduler'); - JestMockScheduler.mockRestore(); - - let _flushWork = null; - let isFlushing = false; - let timeoutID = -1; - let endOfFrame = -1; - let hasMicrotask = false; - - let currentTime = 0; - - flushWork = frameSize => { - if (isFlushing) { - throw new Error('Already flushing work.'); - } - if (frameSize === null || frameSize === undefined) { - frameSize = Infinity; - } - if (_flushWork === null) { - throw new Error('No work is scheduled.'); - } - timeoutID = -1; - endOfFrame = currentTime + frameSize; - try { - isFlushing = true; - _flushWork(false); - } finally { - isFlushing = false; - endOfFrame = -1; - if (hasMicrotask) { - onTimeout(); - } - } - const yields = yieldedValues; - yieldedValues = []; - return yields; - }; - - advanceTime = ms => { - currentTime += ms; - jest.advanceTimersByTime(ms); - }; - - doWork = (label, timeCost) => { - if (typeof timeCost !== 'number') { - throw new Error('Second arg must be a number.'); - } - advanceTime(timeCost); - yieldValue(label); - }; - - yieldedValues = []; - yieldValue = value => { - yieldedValues.push(value); - }; - - clearYieldedValues = () => { - const yields = yieldedValues; - yieldedValues = []; - return yields; - }; - - function onTimeout() { - if (_flushWork === null) { - return; - } - if (isFlushing) { - hasMicrotask = true; - } else { - try { - isFlushing = true; - _flushWork(true); - } finally { - hasMicrotask = false; - isFlushing = false; - } - } - } - - function requestHostCallback(fw, absoluteTimeout) { - if (_flushWork !== null) { - throw new Error('Work is already scheduled.'); - } - _flushWork = fw; - timeoutID = setTimeout(onTimeout, absoluteTimeout - currentTime); - } - function cancelHostCallback() { - if (_flushWork === null) { - throw new Error('No work is scheduled.'); - } - _flushWork = null; - clearTimeout(timeoutID); - } - function shouldYieldToHost() { - return endOfFrame <= currentTime; - } - function getCurrentTime() { - return currentTime; - } - - // Override host implementation - delete global.performance; - global.Date.now = () => { - return currentTime; - }; - - window._schedMock = [ - requestHostCallback, - cancelHostCallback, - shouldYieldToHost, - getCurrentTime, - ]; - - const Schedule = require('scheduler'); - runWithPriority = Schedule.unstable_runWithPriority; - ImmediatePriority = Schedule.unstable_ImmediatePriority; - UserBlockingPriority = Schedule.unstable_UserBlockingPriority; - NormalPriority = Schedule.unstable_NormalPriority; - scheduleCallback = Schedule.unstable_scheduleCallback; - cancelCallback = Schedule.unstable_cancelCallback; - wrapCallback = Schedule.unstable_wrapCallback; - getCurrentPriorityLevel = Schedule.unstable_getCurrentPriorityLevel; - shouldYield = Schedule.unstable_shouldYield; + jest.mock('scheduler', () => require('scheduler/unstable_mock')); + + Scheduler = require('scheduler'); + + runWithPriority = Scheduler.unstable_runWithPriority; + ImmediatePriority = Scheduler.unstable_ImmediatePriority; + UserBlockingPriority = Scheduler.unstable_UserBlockingPriority; + NormalPriority = Scheduler.unstable_NormalPriority; + scheduleCallback = Scheduler.unstable_scheduleCallback; + cancelCallback = Scheduler.unstable_cancelCallback; + wrapCallback = Scheduler.unstable_wrapCallback; + getCurrentPriorityLevel = Scheduler.unstable_getCurrentPriorityLevel; + shouldYield = Scheduler.unstable_shouldYield; }); it('flushes work incrementally', () => { - scheduleCallback(() => doWork('A', 100)); - scheduleCallback(() => doWork('B', 200)); - scheduleCallback(() => doWork('C', 300)); - scheduleCallback(() => doWork('D', 400)); - - expect(flushWork(300)).toEqual(['A', 'B']); - expect(flushWork(300)).toEqual(['C']); - expect(flushWork(400)).toEqual(['D']); - }); - - it('flushes work until framesize reached', () => { - scheduleCallback(() => doWork('A1_100', 100)); - scheduleCallback(() => doWork('A2_200', 200)); - scheduleCallback(() => doWork('B1_100', 100)); - scheduleCallback(() => doWork('B2_200', 200)); - scheduleCallback(() => doWork('C1_300', 300)); - scheduleCallback(() => doWork('C2_300', 300)); - scheduleCallback(() => doWork('D_3000', 3000)); - scheduleCallback(() => doWork('E1_300', 300)); - scheduleCallback(() => doWork('E2_200', 200)); - scheduleCallback(() => doWork('F1_200', 200)); - scheduleCallback(() => doWork('F2_200', 200)); - scheduleCallback(() => doWork('F3_300', 300)); - scheduleCallback(() => doWork('F4_500', 500)); - scheduleCallback(() => doWork('F5_200', 200)); - scheduleCallback(() => doWork('F6_20', 20)); - - expect(Date.now()).toEqual(0); - // No time left after A1_100 and A2_200 are run - expect(flushWork(300)).toEqual(['A1_100', 'A2_200']); - expect(Date.now()).toEqual(300); - // B2_200 is started as there is still time left after B1_100 - expect(flushWork(101)).toEqual(['B1_100', 'B2_200']); - expect(Date.now()).toEqual(600); - // C1_300 is started as there is even a little frame time - expect(flushWork(1)).toEqual(['C1_300']); - expect(Date.now()).toEqual(900); - // C2_300 is started even though there is no frame time - expect(flushWork(0)).toEqual(['C2_300']); - expect(Date.now()).toEqual(1200); - // D_3000 is very slow, but won't affect next flushes (if no - // timeouts happen) - expect(flushWork(100)).toEqual(['D_3000']); - expect(Date.now()).toEqual(4200); - expect(flushWork(400)).toEqual(['E1_300', 'E2_200']); - expect(Date.now()).toEqual(4700); - // Default timeout is 5000, so during F2_200, work will timeout and are done - // in reverse, including F2_200 - expect(flushWork(1000)).toEqual([ - 'F1_200', - 'F2_200', - 'F3_300', - 'F4_500', - 'F5_200', - 'F6_20', - ]); - expect(Date.now()).toEqual(6120); + scheduleCallback(() => Scheduler.yieldValue('A')); + scheduleCallback(() => Scheduler.yieldValue('B')); + scheduleCallback(() => Scheduler.yieldValue('C')); + scheduleCallback(() => Scheduler.yieldValue('D')); + + expect(Scheduler).toFlushAndYieldThrough(['A', 'B']); + expect(Scheduler).toFlushAndYieldThrough(['C']); + expect(Scheduler).toFlushAndYield(['D']); }); it('cancels work', () => { - scheduleCallback(() => doWork('A', 100)); - const callbackHandleB = scheduleCallback(() => doWork('B', 200)); - scheduleCallback(() => doWork('C', 300)); + scheduleCallback(() => Scheduler.yieldValue('A')); + const callbackHandleB = scheduleCallback(() => Scheduler.yieldValue('B')); + scheduleCallback(() => Scheduler.yieldValue('C')); cancelCallback(callbackHandleB); - expect(flushWork()).toEqual([ + expect(Scheduler).toFlushAndYield([ 'A', // B should have been cancelled 'C', @@ -230,86 +64,100 @@ describe('Scheduler', () => { }); it('executes the highest priority callbacks first', () => { - scheduleCallback(() => doWork('A', 100)); - scheduleCallback(() => doWork('B', 100)); + scheduleCallback(() => Scheduler.yieldValue('A')); + scheduleCallback(() => Scheduler.yieldValue('B')); // Yield before B is flushed - expect(flushWork(100)).toEqual(['A']); + expect(Scheduler).toFlushAndYieldThrough(['A']); runWithPriority(UserBlockingPriority, () => { - scheduleCallback(() => doWork('C', 100)); - scheduleCallback(() => doWork('D', 100)); + scheduleCallback(() => Scheduler.yieldValue('C')); + scheduleCallback(() => Scheduler.yieldValue('D')); }); // C and D should come first, because they are higher priority - expect(flushWork()).toEqual(['C', 'D', 'B']); + expect(Scheduler).toFlushAndYield(['C', 'D', 'B']); }); it('expires work', () => { - scheduleCallback(didTimeout => - doWork(`A (did timeout: ${didTimeout})`, 100), - ); + scheduleCallback(didTimeout => { + Scheduler.advanceTime(100); + Scheduler.yieldValue(`A (did timeout: ${didTimeout})`); + }); runWithPriority(UserBlockingPriority, () => { - scheduleCallback(didTimeout => - doWork(`B (did timeout: ${didTimeout})`, 100), - ); + scheduleCallback(didTimeout => { + Scheduler.advanceTime(100); + Scheduler.yieldValue(`B (did timeout: ${didTimeout})`); + }); }); runWithPriority(UserBlockingPriority, () => { - scheduleCallback(didTimeout => - doWork(`C (did timeout: ${didTimeout})`, 100), - ); + scheduleCallback(didTimeout => { + Scheduler.advanceTime(100); + Scheduler.yieldValue(`C (did timeout: ${didTimeout})`); + }); }); // Advance time, but not by enough to expire any work - advanceTime(249); - expect(clearYieldedValues()).toEqual([]); + Scheduler.advanceTime(249); + expect(Scheduler).toHaveYielded([]); // Schedule a few more callbacks - scheduleCallback(didTimeout => - doWork(`D (did timeout: ${didTimeout})`, 100), - ); - scheduleCallback(didTimeout => - doWork(`E (did timeout: ${didTimeout})`, 100), - ); + scheduleCallback(didTimeout => { + Scheduler.advanceTime(100); + Scheduler.yieldValue(`D (did timeout: ${didTimeout})`); + }); + scheduleCallback(didTimeout => { + Scheduler.advanceTime(100); + Scheduler.yieldValue(`E (did timeout: ${didTimeout})`); + }); // Advance by just a bit more to expire the user blocking callbacks - advanceTime(1); - expect(clearYieldedValues()).toEqual([ + Scheduler.advanceTime(1); + expect(Scheduler).toHaveYielded([ 'B (did timeout: true)', 'C (did timeout: true)', ]); // Expire A - advanceTime(4600); - expect(clearYieldedValues()).toEqual(['A (did timeout: true)']); + Scheduler.advanceTime(4600); + expect(Scheduler).toHaveYielded(['A (did timeout: true)']); // Flush the rest without expiring - expect(flushWork()).toEqual([ + expect(Scheduler).toFlushAndYield([ 'D (did timeout: false)', - 'E (did timeout: false)', + 'E (did timeout: true)', ]); }); it('has a default expiration of ~5 seconds', () => { - scheduleCallback(() => doWork('A', 100)); + scheduleCallback(() => Scheduler.yieldValue('A')); - advanceTime(4999); - expect(clearYieldedValues()).toEqual([]); + Scheduler.advanceTime(4999); + expect(Scheduler).toHaveYielded([]); - advanceTime(1); - expect(clearYieldedValues()).toEqual(['A']); + Scheduler.advanceTime(1); + expect(Scheduler).toHaveYielded(['A']); }); it('continues working on same task after yielding', () => { - scheduleCallback(() => doWork('A', 100)); - scheduleCallback(() => doWork('B', 100)); + scheduleCallback(() => { + Scheduler.advanceTime(100); + Scheduler.yieldValue('A'); + }); + scheduleCallback(() => { + Scheduler.advanceTime(100); + Scheduler.yieldValue('B'); + }); + let didYield = false; const tasks = [['C1', 100], ['C2', 100], ['C3', 100]]; const C = () => { while (tasks.length > 0) { - doWork(...tasks.shift()); + const [label, ms] = tasks.shift(); + Scheduler.advanceTime(ms); + Scheduler.yieldValue(label); if (shouldYield()) { - yieldValue('Yield!'); + didYield = true; return C; } } @@ -317,21 +165,32 @@ describe('Scheduler', () => { scheduleCallback(C); - scheduleCallback(() => doWork('D', 100)); - scheduleCallback(() => doWork('E', 100)); + scheduleCallback(() => { + Scheduler.advanceTime(100); + Scheduler.yieldValue('D'); + }); + scheduleCallback(() => { + Scheduler.advanceTime(100); + Scheduler.yieldValue('E'); + }); - expect(flushWork(300)).toEqual(['A', 'B', 'C1', 'Yield!']); + // Flush, then yield while in the middle of C. + expect(didYield).toBe(false); + expect(Scheduler).toFlushAndYieldThrough(['A', 'B', 'C1']); + expect(didYield).toBe(true); - expect(flushWork()).toEqual(['C2', 'C3', 'D', 'E']); + // When we resume, we should continue working on C. + expect(Scheduler).toFlushAndYield(['C2', 'C3', 'D', 'E']); }); it('continuation callbacks inherit the expiration of the previous callback', () => { const tasks = [['A', 125], ['B', 124], ['C', 100], ['D', 100]]; const work = () => { while (tasks.length > 0) { - doWork(...tasks.shift()); + const [label, ms] = tasks.shift(); + Scheduler.advanceTime(ms); + Scheduler.yieldValue(label); if (shouldYield()) { - yieldValue('Yield!'); return work; } } @@ -341,50 +200,56 @@ describe('Scheduler', () => { runWithPriority(UserBlockingPriority, () => scheduleCallback(work)); // Flush until just before the expiration time - expect(flushWork(249)).toEqual(['A', 'B', 'Yield!']); + expect(Scheduler).toFlushAndYieldThrough(['A', 'B']); // Advance time by just a bit more. This should expire all the remaining work. - advanceTime(1); - expect(clearYieldedValues()).toEqual(['C', 'D']); + Scheduler.advanceTime(1); + expect(Scheduler).toHaveYielded(['C', 'D']); }); it('nested callbacks inherit the priority of the currently executing callback', () => { runWithPriority(UserBlockingPriority, () => { scheduleCallback(() => { - doWork('Parent callback', 100); + Scheduler.advanceTime(100); + Scheduler.yieldValue('Parent callback'); scheduleCallback(() => { - doWork('Nested callback', 100); + Scheduler.advanceTime(100); + Scheduler.yieldValue('Nested callback'); }); }); }); - expect(flushWork(100)).toEqual(['Parent callback']); + expect(Scheduler).toFlushAndYieldThrough(['Parent callback']); // The nested callback has user-blocking priority, so it should // expire quickly. - advanceTime(250 + 100); - expect(clearYieldedValues()).toEqual(['Nested callback']); + Scheduler.advanceTime(250 + 100); + expect(Scheduler).toHaveYielded(['Nested callback']); }); it('continuations are interrupted by higher priority work', () => { const tasks = [['A', 100], ['B', 100], ['C', 100], ['D', 100]]; const work = () => { while (tasks.length > 0) { - doWork(...tasks.shift()); + const [label, ms] = tasks.shift(); + Scheduler.advanceTime(ms); + Scheduler.yieldValue(label); if (tasks.length > 0 && shouldYield()) { - yieldValue('Yield!'); return work; } } }; scheduleCallback(work); - expect(flushWork(100)).toEqual(['A', 'Yield!']); + expect(Scheduler).toFlushAndYieldThrough(['A']); runWithPriority(UserBlockingPriority, () => { - scheduleCallback(() => doWork('High pri', 100)); + scheduleCallback(() => { + Scheduler.advanceTime(100); + Scheduler.yieldValue('High pri'); + }); }); - expect(flushWork()).toEqual(['High pri', 'B', 'C', 'D']); + expect(Scheduler).toFlushAndYield(['High pri', 'B', 'C', 'D']); }); it( @@ -395,22 +260,27 @@ describe('Scheduler', () => { const work = () => { while (tasks.length > 0) { const task = tasks.shift(); - doWork(...task); + const [label, ms] = task; + Scheduler.advanceTime(ms); + Scheduler.yieldValue(label); if (task[0] === 'B') { // Schedule high pri work from inside another callback - yieldValue('Schedule high pri'); + Scheduler.yieldValue('Schedule high pri'); runWithPriority(UserBlockingPriority, () => - scheduleCallback(() => doWork('High pri', 100)), + scheduleCallback(() => { + Scheduler.advanceTime(100); + Scheduler.yieldValue('High pri'); + }), ); } if (tasks.length > 0 && shouldYield()) { - yieldValue('Yield!'); + Scheduler.yieldValue('Yield!'); return work; } } }; scheduleCallback(work); - expect(flushWork()).toEqual([ + expect(Scheduler).toFlushAndYield([ 'A', 'B', 'Schedule high pri', @@ -427,19 +297,19 @@ describe('Scheduler', () => { it('immediate callbacks fire at the end of outermost event', () => { runWithPriority(ImmediatePriority, () => { - scheduleCallback(() => yieldValue('A')); - scheduleCallback(() => yieldValue('B')); + scheduleCallback(() => Scheduler.yieldValue('A')); + scheduleCallback(() => Scheduler.yieldValue('B')); // Nested event runWithPriority(ImmediatePriority, () => { - scheduleCallback(() => yieldValue('C')); + scheduleCallback(() => Scheduler.yieldValue('C')); // Nothing should have fired yet - expect(clearYieldedValues()).toEqual([]); + expect(Scheduler).toHaveYielded([]); }); // Nothing should have fired yet - expect(clearYieldedValues()).toEqual([]); + expect(Scheduler).toHaveYielded([]); }); // The callbacks were called at the end of the outer event - expect(clearYieldedValues()).toEqual(['A', 'B', 'C']); + expect(Scheduler).toHaveYielded(['A', 'B', 'C']); }); it('wrapped callbacks have same signature as original callback', () => { @@ -450,7 +320,8 @@ describe('Scheduler', () => { it('wrapped callbacks inherit the current priority', () => { const wrappedCallback = wrapCallback(() => { scheduleCallback(() => { - doWork('Normal', 100); + Scheduler.advanceTime(100); + Scheduler.yieldValue('Normal'); }); }); const wrappedInteractiveCallback = runWithPriority( @@ -458,7 +329,8 @@ describe('Scheduler', () => { () => wrapCallback(() => { scheduleCallback(() => { - doWork('User-blocking', 100); + Scheduler.advanceTime(100); + Scheduler.yieldValue('User-blocking'); }); }), ); @@ -468,19 +340,20 @@ describe('Scheduler', () => { // This should schedule an user-blocking callback wrappedInteractiveCallback(); - advanceTime(249); - expect(clearYieldedValues()).toEqual([]); - advanceTime(1); - expect(clearYieldedValues()).toEqual(['User-blocking']); + Scheduler.advanceTime(249); + expect(Scheduler).toHaveYielded([]); + Scheduler.advanceTime(1); + expect(Scheduler).toHaveYielded(['User-blocking']); - advanceTime(10000); - expect(clearYieldedValues()).toEqual(['Normal']); + Scheduler.advanceTime(10000); + expect(Scheduler).toHaveYielded(['Normal']); }); it('wrapped callbacks inherit the current priority even when nested', () => { const wrappedCallback = wrapCallback(() => { scheduleCallback(() => { - doWork('Normal', 100); + Scheduler.advanceTime(100); + Scheduler.yieldValue('Normal'); }); }); const wrappedInteractiveCallback = runWithPriority( @@ -488,7 +361,8 @@ describe('Scheduler', () => { () => wrapCallback(() => { scheduleCallback(() => { - doWork('User-blocking', 100); + Scheduler.advanceTime(100); + Scheduler.yieldValue('User-blocking'); }); }), ); @@ -500,66 +374,66 @@ describe('Scheduler', () => { wrappedInteractiveCallback(); }); - advanceTime(249); - expect(clearYieldedValues()).toEqual([]); - advanceTime(1); - expect(clearYieldedValues()).toEqual(['User-blocking']); + Scheduler.advanceTime(249); + expect(Scheduler).toHaveYielded([]); + Scheduler.advanceTime(1); + expect(Scheduler).toHaveYielded(['User-blocking']); - advanceTime(10000); - expect(clearYieldedValues()).toEqual(['Normal']); + Scheduler.advanceTime(10000); + expect(Scheduler).toHaveYielded(['Normal']); }); it('immediate callbacks fire at the end of callback', () => { const immediateCallback = runWithPriority(ImmediatePriority, () => wrapCallback(() => { - scheduleCallback(() => yieldValue('callback')); + scheduleCallback(() => Scheduler.yieldValue('callback')); }), ); immediateCallback(); // The callback was called at the end of the outer event - expect(clearYieldedValues()).toEqual(['callback']); + expect(Scheduler).toHaveYielded(['callback']); }); it("immediate callbacks fire even if there's an error", () => { expect(() => { runWithPriority(ImmediatePriority, () => { scheduleCallback(() => { - yieldValue('A'); + Scheduler.yieldValue('A'); throw new Error('Oops A'); }); scheduleCallback(() => { - yieldValue('B'); + Scheduler.yieldValue('B'); }); scheduleCallback(() => { - yieldValue('C'); + Scheduler.yieldValue('C'); throw new Error('Oops C'); }); }); }).toThrow('Oops A'); - expect(clearYieldedValues()).toEqual(['A']); + expect(Scheduler).toHaveYielded(['A']); // B and C flush in a subsequent event. That way, the second error is not // swallowed. - expect(() => flushWork(0)).toThrow('Oops C'); - expect(clearYieldedValues()).toEqual(['B', 'C']); + expect(() => Scheduler.unstable_flushExpired()).toThrow('Oops C'); + expect(Scheduler).toHaveYielded(['B', 'C']); }); it('exposes the current priority level', () => { - yieldValue(getCurrentPriorityLevel()); + Scheduler.yieldValue(getCurrentPriorityLevel()); runWithPriority(ImmediatePriority, () => { - yieldValue(getCurrentPriorityLevel()); + Scheduler.yieldValue(getCurrentPriorityLevel()); runWithPriority(NormalPriority, () => { - yieldValue(getCurrentPriorityLevel()); + Scheduler.yieldValue(getCurrentPriorityLevel()); runWithPriority(UserBlockingPriority, () => { - yieldValue(getCurrentPriorityLevel()); + Scheduler.yieldValue(getCurrentPriorityLevel()); }); }); - yieldValue(getCurrentPriorityLevel()); + Scheduler.yieldValue(getCurrentPriorityLevel()); }); - expect(clearYieldedValues()).toEqual([ + expect(Scheduler).toHaveYielded([ NormalPriority, ImmediatePriority, NormalPriority, diff --git a/packages/scheduler/src/__tests__/SchedulerDOM-test.js b/packages/scheduler/src/__tests__/SchedulerDOM-test.js index 77fac5ffe67..7565e84454a 100644 --- a/packages/scheduler/src/__tests__/SchedulerDOM-test.js +++ b/packages/scheduler/src/__tests__/SchedulerDOM-test.js @@ -89,8 +89,13 @@ describe('SchedulerDOM', () => { }; jest.resetModules(); - const JestMockScheduler = require('jest-mock-scheduler'); - JestMockScheduler.mockRestore(); + // Un-mock scheduler + jest.mock('scheduler', () => require.requireActual('scheduler')); + jest.mock('scheduler/src/SchedulerHostConfig', () => + require.requireActual( + 'scheduler/src/forks/SchedulerHostConfig.default.js', + ), + ); Scheduler = require('scheduler'); }); diff --git a/packages/scheduler/src/__tests__/SchedulerNoDOM-test.js b/packages/scheduler/src/__tests__/SchedulerNoDOM-test.js index 5a6aa128611..55d47037e09 100644 --- a/packages/scheduler/src/__tests__/SchedulerNoDOM-test.js +++ b/packages/scheduler/src/__tests__/SchedulerNoDOM-test.js @@ -19,10 +19,17 @@ describe('SchedulerNoDOM', () => { // implementation using setTimeout. This only meant to be used for testing // purposes, like with jest's fake timer API. beforeEach(() => { - jest.useFakeTimers(); jest.resetModules(); - // Delete addEventListener to force us into the fallback mode. - window.addEventListener = undefined; + jest.useFakeTimers(); + + // Un-mock scheduler + jest.mock('scheduler', () => require.requireActual('scheduler')); + jest.mock('scheduler/src/SchedulerHostConfig', () => + require.requireActual( + 'scheduler/src/forks/SchedulerHostConfig.default.js', + ), + ); + const Scheduler = require('scheduler'); scheduleCallback = Scheduler.unstable_scheduleCallback; runWithPriority = Scheduler.unstable_runWithPriority; @@ -69,36 +76,6 @@ describe('SchedulerNoDOM', () => { expect(log).toEqual(['C', 'D', 'A', 'B']); }); - it('advanceTimersByTime expires callbacks incrementally', () => { - let log = []; - - scheduleCallback(() => { - log.push('A'); - }); - scheduleCallback(() => { - log.push('B'); - }); - runWithPriority(UserBlockingPriority, () => { - scheduleCallback(() => { - log.push('C'); - }); - scheduleCallback(() => { - log.push('D'); - }); - }); - - expect(log).toEqual([]); - jest.advanceTimersByTime(249); - expect(log).toEqual([]); - jest.advanceTimersByTime(1); - expect(log).toEqual(['C', 'D']); - - log = []; - - jest.runAllTimers(); - expect(log).toEqual(['A', 'B']); - }); - it('calls immediate callbacks immediately', () => { let log = []; diff --git a/packages/scheduler/src/__tests__/SchedulerUMDBundle-test.internal.js b/packages/scheduler/src/__tests__/SchedulerUMDBundle-test.internal.js index b22d2c27704..9812ac55b99 100644 --- a/packages/scheduler/src/__tests__/SchedulerUMDBundle-test.internal.js +++ b/packages/scheduler/src/__tests__/SchedulerUMDBundle-test.internal.js @@ -14,6 +14,13 @@ describe('Scheduling UMD bundle', () => { global.__UMD__ = true; jest.resetModules(); + + jest.mock('scheduler', () => require.requireActual('scheduler')); + jest.mock('scheduler/src/SchedulerHostConfig', () => + require.requireActual( + 'scheduler/src/forks/SchedulerHostConfig.default.js', + ), + ); }); function filterPrivateKeys(name) { diff --git a/packages/scheduler/src/forks/SchedulerHostConfig.default.js b/packages/scheduler/src/forks/SchedulerHostConfig.default.js new file mode 100644 index 00000000000..fd6bdea7437 --- /dev/null +++ b/packages/scheduler/src/forks/SchedulerHostConfig.default.js @@ -0,0 +1,264 @@ +/** + * 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. + */ + +// The DOM Scheduler implementation is similar to requestIdleCallback. It +// works by scheduling a requestAnimationFrame, storing the time for the start +// of the frame, then scheduling a postMessage which gets scheduled after paint. +// Within the postMessage handler do as much work as possible until time + frame +// rate. By separating the idle call into a separate event tick we ensure that +// layout, paint and other browser work is counted against the available time. +// The frame rate is dynamically adjusted. + +export let requestHostCallback; +export let cancelHostCallback; +export let shouldYieldToHost; +export let getCurrentTime; + +const hasNativePerformanceNow = + typeof performance === 'object' && typeof performance.now === 'function'; + +// We capture a local reference to any global, in case it gets polyfilled after +// this module is initially evaluated. We want to be using a +// consistent implementation. +const localDate = Date; + +// This initialization code may run even on server environments if a component +// just imports ReactDOM (e.g. for findDOMNode). Some environments might not +// have setTimeout or clearTimeout. However, we always expect them to be defined +// on the client. https://github.com/facebook/react/pull/13088 +const localSetTimeout = + typeof setTimeout === 'function' ? setTimeout : undefined; +const localClearTimeout = + typeof clearTimeout === 'function' ? clearTimeout : undefined; + +// We don't expect either of these to necessarily be defined, but we will error +// later if they are missing on the client. +const localRequestAnimationFrame = + typeof requestAnimationFrame === 'function' + ? requestAnimationFrame + : undefined; +const localCancelAnimationFrame = + typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : undefined; + +// requestAnimationFrame does not run when the tab is in the background. If +// we're backgrounded we prefer for that work to happen so that the page +// continues to load in the background. So we also schedule a 'setTimeout' as +// a fallback. +// TODO: Need a better heuristic for backgrounded work. +const ANIMATION_FRAME_TIMEOUT = 100; +let rAFID; +let rAFTimeoutID; +const requestAnimationFrameWithTimeout = function(callback) { + // schedule rAF and also a setTimeout + rAFID = localRequestAnimationFrame(function(timestamp) { + // cancel the setTimeout + localClearTimeout(rAFTimeoutID); + callback(timestamp); + }); + rAFTimeoutID = localSetTimeout(function() { + // cancel the requestAnimationFrame + localCancelAnimationFrame(rAFID); + callback(getCurrentTime()); + }, ANIMATION_FRAME_TIMEOUT); +}; + +if (hasNativePerformanceNow) { + const Performance = performance; + getCurrentTime = function() { + return Performance.now(); + }; +} else { + getCurrentTime = function() { + return localDate.now(); + }; +} + +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; + const _flushCallback = function(didTimeout) { + if (_callback !== null) { + try { + _callback(didTimeout); + } finally { + _callback = null; + } + } + }; + requestHostCallback = function(cb, ms) { + if (_callback !== null) { + // Protect against re-entrancy. + setTimeout(requestHostCallback, 0, cb); + } else { + _callback = cb; + setTimeout(_flushCallback, 0, false); + } + }; + cancelHostCallback = function() { + _callback = null; + }; + shouldYieldToHost = function() { + return false; + }; +} else { + if (typeof console !== 'undefined') { + // TODO: Remove fb.me link + if (typeof localRequestAnimationFrame !== 'function') { + 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 localCancelAnimationFrame !== 'function') { + console.error( + "This browser doesn't support cancelAnimationFrame. " + + 'Make sure that you load a ' + + 'polyfill in older browsers. https://fb.me/react-polyfills', + ); + } + } + + let scheduledHostCallback = null; + let isMessageEventScheduled = false; + let timeoutTime = -1; + + let isAnimationFrameScheduled = false; + + let isFlushingHostCallback = false; + + let frameDeadline = 0; + // We start out assuming that we run at 30fps but then the heuristic tracking + // will adjust this value to a faster fps if we get more frequent animation + // frames. + let previousFrameTime = 33; + let activeFrameTime = 33; + + shouldYieldToHost = function() { + return frameDeadline <= getCurrentTime(); + }; + + // We use the postMessage trick to defer idle work until after the repaint. + const channel = new MessageChannel(); + const port = channel.port2; + channel.port1.onmessage = function(event) { + isMessageEventScheduled = false; + + const prevScheduledCallback = scheduledHostCallback; + const prevTimeoutTime = timeoutTime; + scheduledHostCallback = null; + timeoutTime = -1; + + const currentTime = getCurrentTime(); + + let didTimeout = false; + 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 (prevTimeoutTime !== -1 && prevTimeoutTime <= currentTime) { + // Exceeded the timeout. Invoke the callback even though there's no + // time left. + didTimeout = true; + } else { + // No timeout. + if (!isAnimationFrameScheduled) { + // Schedule another animation callback so we retry later. + isAnimationFrameScheduled = true; + requestAnimationFrameWithTimeout(animationTick); + } + // Exit without invoking the callback. + scheduledHostCallback = prevScheduledCallback; + timeoutTime = prevTimeoutTime; + return; + } + } + + if (prevScheduledCallback !== null) { + isFlushingHostCallback = true; + try { + prevScheduledCallback(didTimeout); + } finally { + isFlushingHostCallback = false; + } + } + }; + + const animationTick = function(rafTime) { + if (scheduledHostCallback !== null) { + // Eagerly schedule the next animation callback at the beginning of the + // frame. If the scheduler queue is not empty at the end of the frame, it + // will continue flushing inside that callback. If the queue *is* empty, + // then it will exit immediately. Posting the callback at the start of the + // frame ensures it's fired within the earliest possible frame. If we + // waited until the end of the frame to post the callback, we risk the + // browser skipping a frame and not firing the callback until the frame + // after that. + requestAnimationFrameWithTimeout(animationTick); + } else { + // No pending work. Exit. + isAnimationFrameScheduled = false; + return; + } + + let nextFrameTime = rafTime - frameDeadline + activeFrameTime; + if ( + nextFrameTime < activeFrameTime && + previousFrameTime < activeFrameTime + ) { + if (nextFrameTime < 8) { + // Defensive coding. We don't support higher frame rates than 120hz. + // If the calculated frame time gets lower than 8, it is probably a bug. + nextFrameTime = 8; + } + // If one frame goes long, then the next one can be short to catch up. + // If two frames are short in a row, then that's an indication that we + // actually have a higher frame rate than what we're currently optimizing. + // We adjust our heuristic dynamically accordingly. For example, if we're + // running on 120hz display or 90hz VR display. + // Take the max of the two in case one of them was an anomaly due to + // missed frame deadlines. + activeFrameTime = + nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime; + } else { + previousFrameTime = nextFrameTime; + } + frameDeadline = rafTime + activeFrameTime; + if (!isMessageEventScheduled) { + isMessageEventScheduled = true; + port.postMessage(undefined); + } + }; + + requestHostCallback = function(callback, absoluteTimeout) { + scheduledHostCallback = callback; + timeoutTime = absoluteTimeout; + if (isFlushingHostCallback || absoluteTimeout < 0) { + // Don't wait for the next frame. Continue working ASAP, in a new event. + port.postMessage(undefined); + } else 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 + // might want to still have setTimeout trigger rIC as a backup to ensure + // that we keep performing work. + isAnimationFrameScheduled = true; + requestAnimationFrameWithTimeout(animationTick); + } + }; + + cancelHostCallback = function() { + scheduledHostCallback = null; + isMessageEventScheduled = false; + timeoutTime = -1; + }; +} diff --git a/packages/scheduler/src/forks/SchedulerHostConfig.mock.js b/packages/scheduler/src/forks/SchedulerHostConfig.mock.js new file mode 100644 index 00000000000..1fda4d2ca7a --- /dev/null +++ b/packages/scheduler/src/forks/SchedulerHostConfig.mock.js @@ -0,0 +1,167 @@ +/** + * 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. + * + * @flow + */ + +let currentTime: number = 0; +let scheduledCallback: (boolean => void) | null = null; +let scheduledCallbackExpiration: number = -1; +let yieldedValues: Array | null = null; +let expectedNumberOfYields: number = -1; +let didStop: boolean = false; +let isFlushing: boolean = false; + +export function requestHostCallback( + callback: boolean => void, + expiration: number, +) { + scheduledCallback = callback; + scheduledCallbackExpiration = expiration; +} + +export function cancelHostCallback(): void { + scheduledCallback = null; + scheduledCallbackExpiration = -1; +} + +export function shouldYieldToHost(): boolean { + if ( + (expectedNumberOfYields !== -1 && + yieldedValues !== null && + yieldedValues.length >= expectedNumberOfYields) || + (scheduledCallbackExpiration !== -1 && + scheduledCallbackExpiration <= currentTime) + ) { + // We yielded at least as many values as expected. Stop flushing. + didStop = true; + return true; + } + return false; +} + +export function getCurrentTime(): number { + return currentTime; +} + +export function reset() { + if (isFlushing) { + throw new Error('Cannot reset while already flushing work.'); + } + currentTime = 0; + scheduledCallback = null; + scheduledCallbackExpiration = -1; + yieldedValues = null; + expectedNumberOfYields = -1; + didStop = false; + isFlushing = false; +} + +// Should only be used via an assertion helper that inspects the yielded values. +export function unstable_flushNumberOfYields(count: number): void { + if (isFlushing) { + throw new Error('Already flushing work.'); + } + expectedNumberOfYields = count; + isFlushing = true; + try { + while (scheduledCallback !== null && !didStop) { + const cb = scheduledCallback; + scheduledCallback = null; + const didTimeout = + scheduledCallbackExpiration !== -1 && + scheduledCallbackExpiration <= currentTime; + cb(didTimeout); + } + } finally { + expectedNumberOfYields = -1; + didStop = false; + isFlushing = false; + } +} + +export function unstable_flushExpired() { + if (isFlushing) { + throw new Error('Already flushing work.'); + } + if (scheduledCallback !== null) { + const cb = scheduledCallback; + scheduledCallback = null; + isFlushing = true; + try { + cb(true); + } finally { + isFlushing = false; + } + } +} + +export function unstable_flushWithoutYielding(): void { + if (isFlushing) { + throw new Error('Already flushing work.'); + } + isFlushing = true; + try { + while (scheduledCallback !== null) { + const cb = scheduledCallback; + scheduledCallback = null; + const didTimeout = + scheduledCallbackExpiration !== -1 && + scheduledCallbackExpiration <= currentTime; + cb(didTimeout); + } + } finally { + expectedNumberOfYields = -1; + didStop = false; + isFlushing = false; + } +} + +export function unstable_clearYields(): Array { + if (yieldedValues === null) { + return []; + } + const values = yieldedValues; + yieldedValues = null; + return values; +} + +export function flushAll(): void { + if (yieldedValues !== null) { + throw new Error( + 'Log is not empty. Assert on the log of yielded values before ' + + 'flushing additional work.', + ); + } + unstable_flushWithoutYielding(); + if (yieldedValues !== null) { + throw new Error( + 'While flushing work, something yielded a value. Use an ' + + 'assertion helper to assert on the log of yielded values, e.g. ' + + 'expect(Scheduler).toFlushAndYield([...])', + ); + } +} + +export function yieldValue(value: mixed): void { + if (yieldedValues === null) { + yieldedValues = [value]; + } else { + yieldedValues.push(value); + } +} + +export function advanceTime(ms: number) { + currentTime += ms; + // If the host callback timed out, flush the expired work. + if ( + !isFlushing && + scheduledCallbackExpiration !== -1 && + scheduledCallbackExpiration <= currentTime + ) { + unstable_flushExpired(); + } +} diff --git a/packages/scheduler/unstable_mock.js b/packages/scheduler/unstable_mock.js new file mode 100644 index 00000000000..8ab48d336b0 --- /dev/null +++ b/packages/scheduler/unstable_mock.js @@ -0,0 +1,20 @@ +/** + * 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. + */ + +'use strict'; + +export * from './src/Scheduler'; + +export { + unstable_flushWithoutYielding, + unstable_flushNumberOfYields, + unstable_flushExpired, + unstable_clearYields, + flushAll, + yieldValue, + advanceTime, +} from './src/SchedulerHostConfig.js'; diff --git a/packages/shared/__tests__/ReactDOMFrameScheduling-test.js b/packages/shared/__tests__/ReactDOMFrameScheduling-test.js index b23ce3a7fd7..fa3d145e6cb 100644 --- a/packages/shared/__tests__/ReactDOMFrameScheduling-test.js +++ b/packages/shared/__tests__/ReactDOMFrameScheduling-test.js @@ -10,6 +10,18 @@ 'use strict'; describe('ReactDOMFrameScheduling', () => { + beforeEach(() => { + jest.resetModules(); + + // Un-mock scheduler + jest.mock('scheduler', () => require.requireActual('scheduler')); + jest.mock('scheduler/src/SchedulerHostConfig', () => + require.requireActual( + 'scheduler/src/forks/SchedulerHostConfig.default.js', + ), + ); + }); + it('warns when requestAnimationFrame is not polyfilled in the browser', () => { const previousRAF = global.requestAnimationFrame; const previousMessageChannel = global.MessageChannel; @@ -21,11 +33,6 @@ describe('ReactDOMFrameScheduling', () => { port2: {}, }; }; - jest.resetModules(); - - const JestMockScheduler = require('jest-mock-scheduler'); - JestMockScheduler.mockRestore(); - spyOnDevAndProd(console, 'error'); require('react-dom'); expect(console.error.calls.count()).toEqual(1); diff --git a/scripts/jest/matchers/reactTestMatchers.js b/scripts/jest/matchers/reactTestMatchers.js index a143e7211c4..fb3d01f549f 100644 --- a/scripts/jest/matchers/reactTestMatchers.js +++ b/scripts/jest/matchers/reactTestMatchers.js @@ -1,6 +1,7 @@ 'use strict'; const JestReact = require('jest-react'); +const SchedulerMatchers = require('./schedulerTestMatchers'); function captureAssertion(fn) { // Trick to use a Jest matcher inside another Jest matcher. `fn` contains an @@ -18,6 +19,10 @@ function captureAssertion(fn) { return {pass: true}; } +function isScheduler(obj) { + return typeof obj.unstable_scheduleCallback === 'function'; +} + function isReactNoop(obj) { return typeof obj.hasScheduledCallback === 'function'; } @@ -33,6 +38,9 @@ function assertYieldsWereCleared(ReactNoop) { } function toFlushAndYield(ReactNoop, expectedYields) { + if (isScheduler(ReactNoop)) { + return SchedulerMatchers.toFlushAndYield(ReactNoop, expectedYields); + } if (!isReactNoop(ReactNoop)) { return JestReact.unstable_toFlushAndYield(ReactNoop, expectedYields); } @@ -44,6 +52,9 @@ function toFlushAndYield(ReactNoop, expectedYields) { } function toFlushAndYieldThrough(ReactNoop, expectedYields) { + if (isScheduler(ReactNoop)) { + return SchedulerMatchers.toFlushAndYieldThrough(ReactNoop, expectedYields); + } if (!isReactNoop(ReactNoop)) { return JestReact.unstable_toFlushAndYieldThrough(ReactNoop, expectedYields); } @@ -57,6 +68,9 @@ function toFlushAndYieldThrough(ReactNoop, expectedYields) { } function toFlushWithoutYielding(ReactNoop) { + if (isScheduler(ReactNoop)) { + return SchedulerMatchers.toFlushWithoutYielding(ReactNoop); + } if (!isReactNoop(ReactNoop)) { return JestReact.unstable_toFlushWithoutYielding(ReactNoop); } @@ -64,6 +78,9 @@ function toFlushWithoutYielding(ReactNoop) { } function toHaveYielded(ReactNoop, expectedYields) { + if (isScheduler(ReactNoop)) { + return SchedulerMatchers.toHaveYielded(ReactNoop, expectedYields); + } if (!isReactNoop(ReactNoop)) { return JestReact.unstable_toHaveYielded(ReactNoop, expectedYields); } @@ -74,6 +91,9 @@ function toHaveYielded(ReactNoop, expectedYields) { } function toFlushAndThrow(ReactNoop, ...rest) { + if (isScheduler(ReactNoop)) { + return SchedulerMatchers.toFlushAndThrow(ReactNoop, ...rest); + } if (!isReactNoop(ReactNoop)) { return JestReact.unstable_toFlushAndThrow(ReactNoop, ...rest); } diff --git a/scripts/jest/matchers/schedulerTestMatchers.js b/scripts/jest/matchers/schedulerTestMatchers.js new file mode 100644 index 00000000000..4984ea42b50 --- /dev/null +++ b/scripts/jest/matchers/schedulerTestMatchers.js @@ -0,0 +1,73 @@ +'use strict'; + +function captureAssertion(fn) { + // Trick to use a Jest matcher inside another Jest matcher. `fn` contains an + // assertion; if it throws, we capture the error and return it, so the stack + // trace presented to the user points to the original assertion in the + // test file. + try { + fn(); + } catch (error) { + return { + pass: false, + message: () => error.message, + }; + } + return {pass: true}; +} + +function assertYieldsWereCleared(Scheduler) { + const actualYields = Scheduler.unstable_clearYields(); + if (actualYields.length !== 0) { + throw new Error( + 'Log of yielded values is not empty. ' + + 'Call expect(Scheduler).toHaveYielded(...) first.' + ); + } +} + +function toFlushAndYield(Scheduler, expectedYields) { + assertYieldsWereCleared(Scheduler); + Scheduler.unstable_flushWithoutYielding(); + const actualYields = Scheduler.unstable_clearYields(); + return captureAssertion(() => { + expect(actualYields).toEqual(expectedYields); + }); +} + +function toFlushAndYieldThrough(Scheduler, expectedYields) { + assertYieldsWereCleared(Scheduler); + Scheduler.unstable_flushNumberOfYields(expectedYields.length); + const actualYields = Scheduler.unstable_clearYields(); + return captureAssertion(() => { + expect(actualYields).toEqual(expectedYields); + }); +} + +function toFlushWithoutYielding(Scheduler) { + return toFlushAndYield(Scheduler, []); +} + +function toHaveYielded(Scheduler, expectedYields) { + return captureAssertion(() => { + const actualYields = Scheduler.unstable_clearYields(); + expect(actualYields).toEqual(expectedYields); + }); +} + +function toFlushAndThrow(Scheduler, ...rest) { + assertYieldsWereCleared(Scheduler); + return captureAssertion(() => { + expect(() => { + Scheduler.unstable_flushWithoutYielding(); + }).toThrow(...rest); + }); +} + +module.exports = { + toFlushAndYield, + toFlushAndYieldThrough, + toFlushWithoutYielding, + toHaveYielded, + toFlushAndThrow, +}; diff --git a/scripts/jest/setupHostConfigs.js b/scripts/jest/setupHostConfigs.js index d7f4aab3674..b4036f52c96 100644 --- a/scripts/jest/setupHostConfigs.js +++ b/scripts/jest/setupHostConfigs.js @@ -111,3 +111,8 @@ inlinedHostConfigs.forEach(rendererInfo => { jest.mock('shared/ReactSharedInternals', () => require.requireActual('react/src/ReactSharedInternals') ); + +jest.mock('scheduler', () => require.requireActual('scheduler/unstable_mock')); +jest.mock('scheduler/src/SchedulerHostConfig', () => + require.requireActual('scheduler/src/forks/SchedulerHostConfig.mock.js') +); diff --git a/scripts/jest/setupTests.js b/scripts/jest/setupTests.js index c6e467d96a1..838e0fd6f6c 100644 --- a/scripts/jest/setupTests.js +++ b/scripts/jest/setupTests.js @@ -49,8 +49,6 @@ if (process.env.REACT_CLASS_EQUIVALENCE_TEST) { ...require('./matchers/reactTestMatchers'), }); - require('jest-mock-scheduler'); - // We have a Babel transform that inserts guards against infinite loops. // If a loop runs for too many iterations, we throw an error and set this // global variable. The global lets us detect an infinite loop even if diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 3e0e501fa2f..4aad55e33dd 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -453,21 +453,21 @@ const bundles = [ externals: [], }, - /******* Jest React (experimental) *******/ + /******* React Scheduler Mock (experimental) *******/ { bundleTypes: [NODE_DEV, NODE_PROD, FB_WWW_DEV, FB_WWW_PROD], moduleType: ISOMORPHIC, - entry: 'jest-react', - global: 'JestReact', + entry: 'scheduler/unstable_mock', + global: 'SchedulerMock', externals: [], }, - /******* Jest Scheduler (experimental) *******/ + /******* Jest React (experimental) *******/ { bundleTypes: [NODE_DEV, NODE_PROD, FB_WWW_DEV, FB_WWW_PROD], moduleType: ISOMORPHIC, - entry: 'jest-mock-scheduler', - global: 'JestMockScheduler', + entry: 'jest-react', + global: 'JestReact', externals: [], }, diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index b5044a1f641..98c1b4a00f6 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -159,16 +159,22 @@ const forks = Object.freeze({ 'scheduler/src/SchedulerFeatureFlags': (bundleType, entry, dependencies) => { if ( - entry === 'scheduler' && - (bundleType === FB_WWW_DEV || - bundleType === FB_WWW_PROD || - bundleType === FB_WWW_PROFILING) + bundleType === FB_WWW_DEV || + bundleType === FB_WWW_PROD || + bundleType === FB_WWW_PROFILING ) { return 'scheduler/src/forks/SchedulerFeatureFlags.www.js'; } return 'scheduler/src/SchedulerFeatureFlags'; }, + 'scheduler/src/SchedulerHostConfig': (bundleType, entry, dependencies) => { + if (entry === 'scheduler/unstable_mock') { + return 'scheduler/src/forks/SchedulerHostConfig.mock'; + } + return 'scheduler/src/forks/SchedulerHostConfig.default'; + }, + // This logic is forked on www to fork the formatting function. 'shared/invariant': (bundleType, entry) => { switch (bundleType) { diff --git a/scripts/rollup/results.json b/scripts/rollup/results.json index c84299de789..6c23e333930 100644 --- a/scripts/rollup/results.json +++ b/scripts/rollup/results.json @@ -676,15 +676,15 @@ "filename": "scheduler.development.js", "bundleType": "NODE_DEV", "packageName": "scheduler", - "size": 23870, - "gzip": 6174 + "size": 23505, + "gzip": 6019 }, { "filename": "scheduler.production.min.js", "bundleType": "NODE_PROD", "packageName": "scheduler", - "size": 5000, - "gzip": 1883 + "size": 4888, + "gzip": 1819 }, { "filename": "SimpleCacheProvider-dev.js", @@ -739,15 +739,15 @@ "filename": "Scheduler-dev.js", "bundleType": "FB_WWW_DEV", "packageName": "scheduler", - "size": 24123, - "gzip": 6223 + "size": 23758, + "gzip": 6067 }, { "filename": "Scheduler-prod.js", "bundleType": "FB_WWW_PROD", "packageName": "scheduler", - "size": 14327, - "gzip": 2953 + "size": 14025, + "gzip": 2841 }, { "filename": "react.profiling.min.js", @@ -802,8 +802,8 @@ "filename": "scheduler-tracing.development.js", "bundleType": "NODE_DEV", "packageName": "scheduler", - "size": 10554, - "gzip": 2432 + "size": 10737, + "gzip": 2535 }, { "filename": "scheduler-tracing.production.min.js", @@ -823,8 +823,8 @@ "filename": "SchedulerTracing-dev.js", "bundleType": "FB_WWW_DEV", "packageName": "scheduler", - "size": 10121, - "gzip": 2117 + "size": 10207, + "gzip": 2134 }, { "filename": "SchedulerTracing-prod.js", @@ -1119,6 +1119,34 @@ "packageName": "jest-mock-scheduler", "size": 1085, "gzip": 532 + }, + { + "filename": "scheduler-unstable_mock.development.js", + "bundleType": "NODE_DEV", + "packageName": "scheduler", + "size": 17926, + "gzip": 4128 + }, + { + "filename": "scheduler-unstable_mock.production.min.js", + "bundleType": "NODE_PROD", + "packageName": "scheduler", + "size": 4173, + "gzip": 1606 + }, + { + "filename": "SchedulerMock-dev.js", + "bundleType": "FB_WWW_DEV", + "packageName": "scheduler", + "size": 18170, + "gzip": 4175 + }, + { + "filename": "SchedulerMock-prod.js", + "bundleType": "FB_WWW_PROD", + "packageName": "scheduler", + "size": 12088, + "gzip": 2473 } ] } \ No newline at end of file From e38c9de1ebc8888757190e578acef2fa20884cc8 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 26 Feb 2019 19:33:14 -0800 Subject: [PATCH 2/3] Mock Scheduler in bundle tests, too --- scripts/jest/config.build.js | 24 ++- scripts/jest/setupTests.build.js | 6 + scripts/rollup/results.json | 260 +++++++++++++++---------------- 3 files changed, 154 insertions(+), 136 deletions(-) create mode 100644 scripts/jest/setupTests.build.js diff --git a/scripts/jest/config.build.js b/scripts/jest/config.build.js index cdb37503570..26d8575a8b0 100644 --- a/scripts/jest/config.build.js +++ b/scripts/jest/config.build.js @@ -31,12 +31,20 @@ moduleNameMapper[ // Map packages to bundles packages.forEach(name => { - // Root entry point - moduleNameMapper[`^${name}$`] = `/build/node_modules/${name}`; - // Named entry points - moduleNameMapper[ - `^${name}/(.*)$` - ] = `/build/node_modules/${name}/$1`; + if (name === 'scheduler') { + // Scheduler is a special case because we mock it by default, but unmock + // for specific modules. + moduleNameMapper['^scheduler$'] = `/build/node_modules/scheduler`; + moduleNameMapper['^scheduler/tracing$'] = + '/build/node_modules/scheduler/tracing'; + } else { + // Root entry point + moduleNameMapper[`^${name}$`] = `/build/node_modules/${name}`; + // Named entry points + moduleNameMapper[ + `^${name}/(.*)$` + ] = `/build/node_modules/${name}/$1`; + } }); module.exports = Object.assign({}, baseConfig, { @@ -46,4 +54,8 @@ module.exports = Object.assign({}, baseConfig, { testPathIgnorePatterns: ['/node_modules/', '-test.internal.js$'], // Exclude the build output from transforms transformIgnorePatterns: ['/node_modules/', '/build/'], + setupFiles: [ + ...baseConfig.setupFiles, + require.resolve('./setupTests.build.js'), + ], }); diff --git a/scripts/jest/setupTests.build.js b/scripts/jest/setupTests.build.js new file mode 100644 index 00000000000..62db0fc007f --- /dev/null +++ b/scripts/jest/setupTests.build.js @@ -0,0 +1,6 @@ +'use strict'; + +jest.mock('scheduler', () => require.requireActual('scheduler/unstable_mock')); +jest.mock('scheduler/src/SchedulerHostConfig', () => + require.requireActual('scheduler/src/forks/SchedulerHostConfig.mock.js') +); diff --git a/scripts/rollup/results.json b/scripts/rollup/results.json index 6c23e333930..a75771086ee 100644 --- a/scripts/rollup/results.json +++ b/scripts/rollup/results.json @@ -4,22 +4,22 @@ "filename": "react.development.js", "bundleType": "UMD_DEV", "packageName": "react", - "size": 101989, - "gzip": 26428 + "size": 102032, + "gzip": 26457 }, { "filename": "react.production.min.js", "bundleType": "UMD_PROD", "packageName": "react", - "size": 12548, - "gzip": 4823 + "size": 12564, + "gzip": 4826 }, { "filename": "react.development.js", "bundleType": "NODE_DEV", "packageName": "react", - "size": 63522, - "gzip": 17094 + "size": 63704, + "gzip": 17181 }, { "filename": "react.production.min.js", @@ -46,29 +46,29 @@ "filename": "react-dom.development.js", "bundleType": "UMD_DEV", "packageName": "react-dom", - "size": 783692, - "gzip": 178609 + "size": 791682, + "gzip": 179962 }, { "filename": "react-dom.production.min.js", "bundleType": "UMD_PROD", "packageName": "react-dom", - "size": 107842, - "gzip": 34729 + "size": 107808, + "gzip": 34704 }, { "filename": "react-dom.development.js", "bundleType": "NODE_DEV", "packageName": "react-dom", - "size": 778167, - "gzip": 177083 + "size": 786187, + "gzip": 178415 }, { "filename": "react-dom.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-dom", - "size": 108009, - "gzip": 34209 + "size": 108031, + "gzip": 34186 }, { "filename": "ReactDOM-dev.js", @@ -165,29 +165,29 @@ "filename": "react-dom-server.browser.development.js", "bundleType": "UMD_DEV", "packageName": "react-dom", - "size": 129798, - "gzip": 34602 + "size": 130425, + "gzip": 34746 }, { "filename": "react-dom-server.browser.production.min.js", "bundleType": "UMD_PROD", "packageName": "react-dom", - "size": 19117, - "gzip": 7343 + "size": 19319, + "gzip": 7379 }, { "filename": "react-dom-server.browser.development.js", "bundleType": "NODE_DEV", "packageName": "react-dom", - "size": 125836, - "gzip": 33642 + "size": 126463, + "gzip": 33795 }, { "filename": "react-dom-server.browser.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-dom", - "size": 19037, - "gzip": 7325 + "size": 19239, + "gzip": 7368 }, { "filename": "ReactDOMServer-dev.js", @@ -207,43 +207,43 @@ "filename": "react-dom-server.node.development.js", "bundleType": "NODE_DEV", "packageName": "react-dom", - "size": 127943, - "gzip": 34197 + "size": 128570, + "gzip": 34348 }, { "filename": "react-dom-server.node.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-dom", - "size": 19930, - "gzip": 7641 + "size": 20132, + "gzip": 7685 }, { "filename": "react-art.development.js", "bundleType": "UMD_DEV", "packageName": "react-art", - "size": 554932, - "gzip": 120585 + "size": 562387, + "gzip": 121860 }, { "filename": "react-art.production.min.js", "bundleType": "UMD_PROD", "packageName": "react-art", - "size": 99655, - "gzip": 30575 + "size": 99616, + "gzip": 30538 }, { "filename": "react-art.development.js", "bundleType": "NODE_DEV", "packageName": "react-art", - "size": 484327, - "gzip": 102945 + "size": 491818, + "gzip": 104175 }, { "filename": "react-art.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-art", - "size": 63856, - "gzip": 19480 + "size": 63873, + "gzip": 19446 }, { "filename": "ReactART-dev.js", @@ -291,29 +291,29 @@ "filename": "react-test-renderer.development.js", "bundleType": "UMD_DEV", "packageName": "react-test-renderer", - "size": 496691, - "gzip": 105367 + "size": 503952, + "gzip": 106548 }, { "filename": "react-test-renderer.production.min.js", "bundleType": "UMD_PROD", "packageName": "react-test-renderer", - "size": 65252, - "gzip": 19980 + "size": 65222, + "gzip": 19962 }, { "filename": "react-test-renderer.development.js", "bundleType": "NODE_DEV", "packageName": "react-test-renderer", - "size": 490997, - "gzip": 104031 + "size": 498258, + "gzip": 105207 }, { "filename": "react-test-renderer.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-test-renderer", - "size": 64908, - "gzip": 19647 + "size": 64873, + "gzip": 19614 }, { "filename": "ReactTestRenderer-dev.js", @@ -361,43 +361,43 @@ "filename": "react-noop-renderer.development.js", "bundleType": "NODE_DEV", "packageName": "react-noop-renderer", - "size": 32858, - "gzip": 7514 + "size": 28240, + "gzip": 6740 }, { "filename": "react-noop-renderer.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-noop-renderer", - "size": 11486, - "gzip": 3766 + "size": 10081, + "gzip": 3367 }, { "filename": "react-reconciler.development.js", "bundleType": "NODE_DEV", "packageName": "react-reconciler", - "size": 481582, - "gzip": 101249 + "size": 489120, + "gzip": 102507 }, { "filename": "react-reconciler.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-reconciler", - "size": 65118, - "gzip": 19259 + "size": 65080, + "gzip": 19234 }, { "filename": "react-reconciler-persistent.development.js", "bundleType": "NODE_DEV", "packageName": "react-reconciler", - "size": 479920, - "gzip": 100594 + "size": 487276, + "gzip": 101780 }, { "filename": "react-reconciler-persistent.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-reconciler", - "size": 65129, - "gzip": 19264 + "size": 65091, + "gzip": 19239 }, { "filename": "react-reconciler-reflection.development.js", @@ -501,8 +501,8 @@ "filename": "React-dev.js", "bundleType": "FB_WWW_DEV", "packageName": "react", - "size": 60700, - "gzip": 16126 + "size": 60786, + "gzip": 16140 }, { "filename": "React-prod.js", @@ -515,15 +515,15 @@ "filename": "ReactDOM-dev.js", "bundleType": "FB_WWW_DEV", "packageName": "react-dom", - "size": 801709, - "gzip": 178348 + "size": 809740, + "gzip": 179616 }, { "filename": "ReactDOM-prod.js", "bundleType": "FB_WWW_PROD", "packageName": "react-dom", - "size": 329235, - "gzip": 60001 + "size": 329960, + "gzip": 60112 }, { "filename": "ReactTestUtils-dev.js", @@ -550,92 +550,92 @@ "filename": "ReactDOMServer-dev.js", "bundleType": "FB_WWW_DEV", "packageName": "react-dom", - "size": 126805, - "gzip": 33138 + "size": 127337, + "gzip": 33235 }, { "filename": "ReactDOMServer-prod.js", "bundleType": "FB_WWW_PROD", "packageName": "react-dom", - "size": 46040, - "gzip": 10601 + "size": 46343, + "gzip": 10662 }, { "filename": "ReactART-dev.js", "bundleType": "FB_WWW_DEV", "packageName": "react-art", - "size": 493866, - "gzip": 102238 + "size": 501297, + "gzip": 103384 }, { "filename": "ReactART-prod.js", "bundleType": "FB_WWW_PROD", "packageName": "react-art", - "size": 199704, - "gzip": 33787 + "size": 200132, + "gzip": 33817 }, { "filename": "ReactNativeRenderer-dev.js", "bundleType": "RN_FB_DEV", "packageName": "react-native-renderer", - "size": 621398, - "gzip": 133323 + "size": 631255, + "gzip": 134845 }, { "filename": "ReactNativeRenderer-prod.js", "bundleType": "RN_FB_PROD", "packageName": "react-native-renderer", - "size": 252107, - "gzip": 44030 + "size": 252735, + "gzip": 44084 }, { "filename": "ReactNativeRenderer-dev.js", "bundleType": "RN_OSS_DEV", "packageName": "react-native-renderer", - "size": 621309, - "gzip": 133286 + "size": 631168, + "gzip": 134810 }, { "filename": "ReactNativeRenderer-prod.js", "bundleType": "RN_OSS_PROD", "packageName": "react-native-renderer", - "size": 252121, - "gzip": 44026 + "size": 252749, + "gzip": 44079 }, { "filename": "ReactFabric-dev.js", "bundleType": "RN_FB_DEV", "packageName": "react-native-renderer", - "size": 612032, - "gzip": 130990 + "size": 621889, + "gzip": 132504 }, { "filename": "ReactFabric-prod.js", "bundleType": "RN_FB_PROD", "packageName": "react-native-renderer", - "size": 244266, - "gzip": 42532 + "size": 244896, + "gzip": 42571 }, { "filename": "ReactFabric-dev.js", "bundleType": "RN_OSS_DEV", "packageName": "react-native-renderer", - "size": 611935, - "gzip": 130940 + "size": 621794, + "gzip": 132455 }, { "filename": "ReactFabric-prod.js", "bundleType": "RN_OSS_PROD", "packageName": "react-native-renderer", - "size": 244272, - "gzip": 42522 + "size": 244902, + "gzip": 42561 }, { "filename": "ReactTestRenderer-dev.js", "bundleType": "FB_WWW_DEV", "packageName": "react-test-renderer", - "size": 501300, - "gzip": 103689 + "size": 508573, + "gzip": 104823 }, { "filename": "ReactShallowRenderer-dev.js", @@ -704,36 +704,36 @@ "filename": "react-noop-renderer-persistent.development.js", "bundleType": "NODE_DEV", "packageName": "react-noop-renderer", - "size": 32977, - "gzip": 7525 + "size": 28359, + "gzip": 6753 }, { "filename": "react-noop-renderer-persistent.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-noop-renderer", - "size": 11508, - "gzip": 3772 + "size": 10103, + "gzip": 3373 }, { "filename": "react-dom.profiling.min.js", "bundleType": "NODE_PROFILING", "packageName": "react-dom", - "size": 111156, - "gzip": 35048 + "size": 111178, + "gzip": 35023 }, { "filename": "ReactNativeRenderer-profiling.js", "bundleType": "RN_OSS_PROFILING", "packageName": "react-native-renderer", - "size": 258626, - "gzip": 45614 + "size": 259254, + "gzip": 45666 }, { "filename": "ReactFabric-profiling.js", "bundleType": "RN_OSS_PROFILING", "packageName": "react-native-renderer", - "size": 250654, - "gzip": 44086 + "size": 251284, + "gzip": 44137 }, { "filename": "Scheduler-dev.js", @@ -767,36 +767,36 @@ "filename": "ReactDOM-profiling.js", "bundleType": "FB_WWW_PROFILING", "packageName": "react-dom", - "size": 335969, - "gzip": 61519 + "size": 336694, + "gzip": 61627 }, { "filename": "ReactNativeRenderer-profiling.js", "bundleType": "RN_FB_PROFILING", "packageName": "react-native-renderer", - "size": 258607, - "gzip": 45621 + "size": 259235, + "gzip": 45675 }, { "filename": "ReactFabric-profiling.js", "bundleType": "RN_FB_PROFILING", "packageName": "react-native-renderer", - "size": 250643, - "gzip": 44092 + "size": 251273, + "gzip": 44141 }, { "filename": "react.profiling.min.js", "bundleType": "UMD_PROFILING", "packageName": "react", - "size": 14756, - "gzip": 5369 + "size": 14773, + "gzip": 5356 }, { "filename": "react-dom.profiling.min.js", "bundleType": "UMD_PROFILING", "packageName": "react-dom", - "size": 110830, - "gzip": 35633 + "size": 110796, + "gzip": 35607 }, { "filename": "scheduler-tracing.development.js", @@ -928,15 +928,15 @@ "filename": "eslint-plugin-react-hooks.development.js", "bundleType": "NODE_DEV", "packageName": "eslint-plugin-react-hooks", - "size": 26115, - "gzip": 6005 + "size": 50458, + "gzip": 11887 }, { "filename": "eslint-plugin-react-hooks.production.min.js", "bundleType": "NODE_PROD", "packageName": "eslint-plugin-react-hooks", - "size": 5080, - "gzip": 1872 + "size": 12598, + "gzip": 4566 }, { "filename": "ReactDOMFizzServer-dev.js", @@ -1026,71 +1026,71 @@ "filename": "ESLintPluginReactHooks-dev.js", "bundleType": "FB_WWW_DEV", "packageName": "eslint-plugin-react-hooks", - "size": 27788, - "gzip": 6145 + "size": 54073, + "gzip": 12239 }, { "filename": "react-dom-unstable-fire.development.js", "bundleType": "UMD_DEV", "packageName": "react-dom", - "size": 784046, - "gzip": 178758 + "size": 792036, + "gzip": 180102 }, { "filename": "react-dom-unstable-fire.production.min.js", "bundleType": "UMD_PROD", "packageName": "react-dom", - "size": 107857, - "gzip": 34738 + "size": 107823, + "gzip": 34713 }, { "filename": "react-dom-unstable-fire.profiling.min.js", "bundleType": "UMD_PROFILING", "packageName": "react-dom", - "size": 110845, - "gzip": 35642 + "size": 110811, + "gzip": 35616 }, { "filename": "react-dom-unstable-fire.development.js", "bundleType": "NODE_DEV", "packageName": "react-dom", - "size": 778520, - "gzip": 177227 + "size": 786540, + "gzip": 178557 }, { "filename": "react-dom-unstable-fire.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-dom", - "size": 108023, - "gzip": 34220 + "size": 108045, + "gzip": 34197 }, { "filename": "react-dom-unstable-fire.profiling.min.js", "bundleType": "NODE_PROFILING", "packageName": "react-dom", - "size": 111170, - "gzip": 35058 + "size": 111192, + "gzip": 35033 }, { "filename": "ReactFire-dev.js", "bundleType": "FB_WWW_DEV", "packageName": "react-dom", - "size": 800900, - "gzip": 178268 + "size": 808931, + "gzip": 179569 }, { "filename": "ReactFire-prod.js", "bundleType": "FB_WWW_PROD", "packageName": "react-dom", - "size": 317385, - "gzip": 57621 + "size": 318110, + "gzip": 57721 }, { "filename": "ReactFire-profiling.js", "bundleType": "FB_WWW_PROFILING", "packageName": "react-dom", - "size": 324156, - "gzip": 59066 + "size": 324881, + "gzip": 59172 }, { "filename": "jest-mock-scheduler.development.js", From 22d197d43ff61ca125e3b814d7bdefea1952839b Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 26 Feb 2019 20:35:43 -0800 Subject: [PATCH 3/3] Remove special case by making regex more restrictive --- scripts/jest/config.build.js | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/scripts/jest/config.build.js b/scripts/jest/config.build.js index 26d8575a8b0..1fcc1d314e7 100644 --- a/scripts/jest/config.build.js +++ b/scripts/jest/config.build.js @@ -31,20 +31,12 @@ moduleNameMapper[ // Map packages to bundles packages.forEach(name => { - if (name === 'scheduler') { - // Scheduler is a special case because we mock it by default, but unmock - // for specific modules. - moduleNameMapper['^scheduler$'] = `/build/node_modules/scheduler`; - moduleNameMapper['^scheduler/tracing$'] = - '/build/node_modules/scheduler/tracing'; - } else { - // Root entry point - moduleNameMapper[`^${name}$`] = `/build/node_modules/${name}`; - // Named entry points - moduleNameMapper[ - `^${name}/(.*)$` - ] = `/build/node_modules/${name}/$1`; - } + // Root entry point + moduleNameMapper[`^${name}$`] = `/build/node_modules/${name}`; + // Named entry points + moduleNameMapper[ + `^${name}\/([^\/]+)$` + ] = `/build/node_modules/${name}/$1`; }); module.exports = Object.assign({}, baseConfig, {