From c35e37aabd01c15b006334da7e32171948ea941e Mon Sep 17 00:00:00 2001 From: Sunil Pai Date: Wed, 3 Apr 2019 17:11:24 +0100 Subject: [PATCH 1/5] mark react-events as private so we publish script skips it for now --- packages/react-events/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-events/package.json b/packages/react-events/package.json index 0950a38579bcf..e293b88fed26b 100644 --- a/packages/react-events/package.json +++ b/packages/react-events/package.json @@ -1,5 +1,6 @@ { "name": "react-events", + "private": true, "description": "React is a JavaScript library for building user interfaces.", "keywords": [ "react" From 6aac3a21c4f4d80d6cda2c9863553e2c880688e6 Mon Sep 17 00:00:00 2001 From: Sunil Pai Date: Fri, 12 Apr 2019 14:40:30 +0100 Subject: [PATCH 2/5] warn when using the wrong act() around create/updates via https://github.com/facebook/react/issues/15319 This solves 2 specific problems - - using the 'wrong' act() doesn't silence the warning - using the wrong act logs a warning It does this by using an empty object on the reconciler as the identity of ReactShouldWarnActingUpdates.current. We also add this check when calling createContainer() to catch the common failure of this happening right in the beginning. --- fixtures/dom/.gitignore | 4 + fixtures/dom/package.json | 2 +- fixtures/dom/public/act-dom.html | 77 +++++++++++-------- packages/react-dom/src/client/ReactDOM.js | 2 + packages/react-dom/src/fire/ReactFire.js | 2 + .../src/test-utils/ReactTestUtils.js | 4 +- .../src/test-utils/ReactTestUtilsAct.js | 9 ++- .../src/createReactNoop.js | 16 ++-- .../src/ReactActingUpdatesSigil.js | 10 +++ .../react-reconciler/src/ReactFiberHooks.js | 2 + .../src/ReactFiberReconciler.js | 16 +++- .../src/ReactFiberScheduler.js | 5 ++ .../src/ReactFiberScheduler.new.js | 30 +++++++- .../src/ReactFiberScheduler.old.js | 27 ++++++- .../src/ReactTestRendererAct.js | 9 ++- packages/react/src/ReactSharedInternals.js | 4 +- .../react/src/ReactShouldWarnActingUpdates.js | 16 ++++ 17 files changed, 182 insertions(+), 53 deletions(-) create mode 100644 packages/react-reconciler/src/ReactActingUpdatesSigil.js create mode 100644 packages/react/src/ReactShouldWarnActingUpdates.js diff --git a/fixtures/dom/.gitignore b/fixtures/dom/.gitignore index 724d1459422c9..01a0d13deb479 100644 --- a/fixtures/dom/.gitignore +++ b/fixtures/dom/.gitignore @@ -16,6 +16,10 @@ public/react-dom-server.browser.development.js public/react-dom-server.browser.production.min.js public/react-dom-test-utils.development.js public/react-dom-test-utils.production.min.js +public/react-test-renderer.development.js +public/react-test-renderer.production.min.js +public/scheduler.development.js +public/scheduler.production.min.js # misc .DS_Store diff --git a/fixtures/dom/package.json b/fixtures/dom/package.json index f5f84257f7c82..d3f678042ca65 100644 --- a/fixtures/dom/package.json +++ b/fixtures/dom/package.json @@ -18,7 +18,7 @@ }, "scripts": { "start": "react-scripts start", - "prestart": "cp ../../build/node_modules/react/umd/react.development.js ../../build/node_modules/react-dom/umd/react-dom.development.js ../../build/node_modules/react/umd/react.production.min.js ../../build/node_modules/react-dom/umd/react-dom.production.min.js ../../build/node_modules/react-dom/umd/react-dom-server.browser.development.js ../../build/node_modules/react-dom/umd/react-dom-server.browser.production.min.js ../../build/node_modules/react-dom/umd/react-dom-test-utils.development.js ../../build/node_modules/react-dom/umd/react-dom-test-utils.production.min.js public/", + "prestart": "cp ../../build/node_modules/react/umd/react.development.js ../../build/node_modules/react-dom/umd/react-dom.development.js ../../build/node_modules/react/umd/react.production.min.js ../../build/node_modules/react-dom/umd/react-dom.production.min.js ../../build/node_modules/react-dom/umd/react-dom-server.browser.development.js ../../build/node_modules/react-dom/umd/react-dom-server.browser.production.min.js ../../build/node_modules/react-dom/umd/react-dom-test-utils.development.js ../../build/node_modules/react-dom/umd/react-dom-test-utils.production.min.js ../../build/node_modules/react-test-renderer/umd/react-test-renderer.development.js ../../build/node_modules/react-test-renderer/umd/react-test-renderer.production.min.js ../../build/node_modules/scheduler/umd/scheduler.development.js ../../build/node_modules/scheduler/umd/scheduler.production.min.js public/", "build": "react-scripts build && cp build/index.html build/200.html", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" diff --git a/fixtures/dom/public/act-dom.html b/fixtures/dom/public/act-dom.html index 2fb4a437721df..ba68b876950c5 100644 --- a/fixtures/dom/public/act-dom.html +++ b/fixtures/dom/public/act-dom.html @@ -1,41 +1,58 @@ - - sanity test for ReactTestUtils.act - - - this page tests whether act runs properly in a browser. -
- your console should say "5" - - - - + + + + + - + + function sleep(period){ + return new Promise(resolve => setTimeout(resolve, period)) + } + + async function testDOMAsynAct() { + // from ReactTestUtilsAct-test.js + + const el = document.createElement("div"); + await ReactTestUtils.act(async () => { + ReactDOM.render(React.createElement(App), el); + }); + // all 5 ticks present and accounted for + console.log(el.innerHTML); + } + + async function testMixRenderers() { + await ReactTestUtils.act(async () => { + ReactTestRenderer.create(React.createElement(App)); + }); + } + + async function run() { + await testDOMAsynAct(); + await testMixRenderers(); + } + + run(); + + diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 728f775adf5e2..06625071adf7e 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -36,6 +36,7 @@ import { findHostInstance, findHostInstanceWithWarning, flushPassiveEffects, + ReactActingUpdatesSigil, } from 'react-reconciler/inline.dom'; import {createPortal as createPortalImpl} from 'shared/ReactPortal'; import {canUseDOM} from 'shared/ExecutionEnvironment'; @@ -822,6 +823,7 @@ const ReactDOM: Object = { dispatchEvent, runEventsInBatch, flushPassiveEffects, + ReactActingUpdatesSigil, ], }, }; diff --git a/packages/react-dom/src/fire/ReactFire.js b/packages/react-dom/src/fire/ReactFire.js index e4f555ebd627d..c979cdacdfb4e 100644 --- a/packages/react-dom/src/fire/ReactFire.js +++ b/packages/react-dom/src/fire/ReactFire.js @@ -41,6 +41,7 @@ import { findHostInstance, findHostInstanceWithWarning, flushPassiveEffects, + ReactActingUpdatesSigil, } from 'react-reconciler/inline.fire'; import {createPortal as createPortalImpl} from 'shared/ReactPortal'; import {canUseDOM} from 'shared/ExecutionEnvironment'; @@ -828,6 +829,7 @@ const ReactDOM: Object = { dispatchEvent, runEventsInBatch, flushPassiveEffects, + ReactActingUpdatesSigil, ], }, }; diff --git a/packages/react-dom/src/test-utils/ReactTestUtils.js b/packages/react-dom/src/test-utils/ReactTestUtils.js index f463cc4aa6a32..68e14ba616499 100644 --- a/packages/react-dom/src/test-utils/ReactTestUtils.js +++ b/packages/react-dom/src/test-utils/ReactTestUtils.js @@ -42,8 +42,10 @@ const [ restoreStateIfNeeded, dispatchEvent, runEventsInBatch, - // eslint-disable-next-line no-unused-vars + /* eslint-disable no-unused-vars */ flushPassiveEffects, + ReactActingUpdatesSigil, + /* eslint-enable no-unused-vars */ ] = ReactDOM.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Events; function Event(suffix) {} diff --git a/packages/react-dom/src/test-utils/ReactTestUtilsAct.js b/packages/react-dom/src/test-utils/ReactTestUtilsAct.js index 99cb73ede7c10..cbc2fdc7b541d 100644 --- a/packages/react-dom/src/test-utils/ReactTestUtilsAct.js +++ b/packages/react-dom/src/test-utils/ReactTestUtilsAct.js @@ -31,6 +31,7 @@ const [ runEventsInBatch, /* eslint-enable no-unused-vars */ flushPassiveEffects, + ReactActingUpdatesSigil, ] = ReactDOM.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.Events; const batchedUpdates = ReactDOM.unstable_batchedUpdates; @@ -61,18 +62,18 @@ function flushEffectsAndMicroTasks(onDone: (err: ?Error) => void) { function act(callback: () => Thenable) { let previousActingUpdatesScopeDepth; + let previousActingUpdatesSigil; if (__DEV__) { previousActingUpdatesScopeDepth = actingUpdatesScopeDepth; + previousActingUpdatesSigil = ReactShouldWarnActingUpdates.current; actingUpdatesScopeDepth++; - ReactShouldWarnActingUpdates.current = true; + ReactShouldWarnActingUpdates.current = ReactActingUpdatesSigil; } function onDone() { if (__DEV__) { actingUpdatesScopeDepth--; - if (actingUpdatesScopeDepth === 0) { - ReactShouldWarnActingUpdates.current = false; - } + ReactShouldWarnActingUpdates.current = previousActingUpdatesSigil; if (actingUpdatesScopeDepth > previousActingUpdatesScopeDepth) { // if it's _less than_ previousActingUpdatesScopeDepth, then we can assume the 'other' one has warned warningWithoutStack( diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 55198b6ba43ad..4b0f4f5b95275 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -597,11 +597,17 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { const roots = new Map(); const DEFAULT_ROOT_ID = ''; - const {flushPassiveEffects, batchedUpdates} = NoopRenderer; + const { + flushPassiveEffects, + batchedUpdates, + ReactActingUpdatesSigil, + } = NoopRenderer; // this act() implementation should be exactly the same in // ReactTestUtilsAct.js, ReactTestRendererAct.js, createReactNoop.js + // we track the 'depth' of the act() calls with this counter, + // so we can tell if any async act() calls try to run in parallel. let actingUpdatesScopeDepth = 0; function flushEffectsAndMicroTasks(onDone: (err: ?Error) => void) { @@ -621,18 +627,18 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { function act(callback: () => Thenable) { let previousActingUpdatesScopeDepth; + let previousActingUpdatesSigil; if (__DEV__) { previousActingUpdatesScopeDepth = actingUpdatesScopeDepth; + previousActingUpdatesSigil = ReactShouldWarnActingUpdates.current; actingUpdatesScopeDepth++; - ReactShouldWarnActingUpdates.current = true; + ReactShouldWarnActingUpdates.current = ReactActingUpdatesSigil; } function onDone() { if (__DEV__) { actingUpdatesScopeDepth--; - if (actingUpdatesScopeDepth === 0) { - ReactShouldWarnActingUpdates.current = false; - } + ReactShouldWarnActingUpdates.current = previousActingUpdatesSigil; if (actingUpdatesScopeDepth > previousActingUpdatesScopeDepth) { // if it's _less than_ previousActingUpdatesScopeDepth, then we can assume the 'other' one has warned warningWithoutStack( diff --git a/packages/react-reconciler/src/ReactActingUpdatesSigil.js b/packages/react-reconciler/src/ReactActingUpdatesSigil.js new file mode 100644 index 0000000000000..a0532a16fd057 --- /dev/null +++ b/packages/react-reconciler/src/ReactActingUpdatesSigil.js @@ -0,0 +1,10 @@ +/** + * 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 + */ + +export default {}; diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 80707120e95fc..a2cc3f953bac3 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -34,6 +34,7 @@ import { flushPassiveEffects, requestCurrentTime, warnIfNotCurrentlyActingUpdatesInDev, + warnIfNotScopedWithMatchingAct, } from './ReactFiberScheduler'; import invariant from 'shared/invariant'; @@ -1169,6 +1170,7 @@ function dispatchAction( // further, this isn't a test file, so flow doesn't recognize the symbol. So... // $FlowExpectedError - because requirements don't give a damn about your type sigs. if ('undefined' !== typeof jest) { + warnIfNotScopedWithMatchingAct(fiber); warnIfNotCurrentlyActingUpdatesInDev(fiber); } } diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index b5e1182376523..bf8a0f5b15ee9 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -17,6 +17,7 @@ import type { } from './ReactFiberHostConfig'; import type {ReactNodeList} from 'shared/ReactTypes'; import type {ExpirationTime} from './ReactFiberExpirationTime'; +import ReactActingUpdatesSigil from './ReactActingUpdatesSigil'; import { findCurrentHostFiber, @@ -53,6 +54,7 @@ import { interactiveUpdates, flushInteractiveUpdates, flushPassiveEffects, + warnIfNotScopedWithMatchingAct, } from './ReactFiberScheduler'; import {createUpdate, enqueueUpdate} from './ReactUpdateQueue'; import ReactFiberInstrumentation from './ReactFiberInstrumentation'; @@ -275,8 +277,15 @@ export function createContainer( containerInfo: Container, isConcurrent: boolean, hydrate: boolean, -): OpaqueRoot { - return createFiberRoot(containerInfo, isConcurrent, hydrate); +): OpaqueRoot { + const fiberRoot = createFiberRoot(containerInfo, isConcurrent, hydrate); + // jest isn't a 'global', it's just exposed to tests via a wrapped function + // further, this isn't a test file, so flow doesn't recognize the symbol. So... + // $FlowExpectedError - because requirements don't give a damn about your type sigs. + if ('undefined' !== typeof jest) { + warnIfNotScopedWithMatchingAct(fiberRoot.current); + } + return fiberRoot; } export function updateContainer( @@ -309,6 +318,7 @@ export { flushControlled, flushSync, flushPassiveEffects, + warnIfNotScopedWithMatchingAct, }; export function getPublicRootInstance( @@ -436,3 +446,5 @@ export function injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean { }, }); } + +export {ReactActingUpdatesSigil}; diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index fb94b012ef904..cac6133c0e9f2 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -33,6 +33,7 @@ import { flushInteractiveUpdates as flushInteractiveUpdates_old, computeUniqueAsyncExpiration as computeUniqueAsyncExpiration_old, flushPassiveEffects as flushPassiveEffects_old, + warnIfNotScopedWithMatchingAct as warnIfNotScopedWithMatchingAct_old, warnIfNotCurrentlyActingUpdatesInDev as warnIfNotCurrentlyActingUpdatesInDev_old, inferStartTimeFromExpirationTime as inferStartTimeFromExpirationTime_old, } from './ReactFiberScheduler.old'; @@ -61,6 +62,7 @@ import { flushInteractiveUpdates as flushInteractiveUpdates_new, computeUniqueAsyncExpiration as computeUniqueAsyncExpiration_new, flushPassiveEffects as flushPassiveEffects_new, + warnIfNotScopedWithMatchingAct as warnIfNotScopedWithMatchingAct_new, warnIfNotCurrentlyActingUpdatesInDev as warnIfNotCurrentlyActingUpdatesInDev_new, inferStartTimeFromExpirationTime as inferStartTimeFromExpirationTime_new, } from './ReactFiberScheduler.new'; @@ -130,6 +132,9 @@ export const computeUniqueAsyncExpiration = enableNewScheduler export const flushPassiveEffects = enableNewScheduler ? flushPassiveEffects_new : flushPassiveEffects_old; +export const warnIfNotScopedWithMatchingAct = enableNewScheduler + ? warnIfNotScopedWithMatchingAct_new + : warnIfNotScopedWithMatchingAct_old; export const warnIfNotCurrentlyActingUpdatesInDev = enableNewScheduler ? warnIfNotCurrentlyActingUpdatesInDev_new : warnIfNotCurrentlyActingUpdatesInDev_old; diff --git a/packages/react-reconciler/src/ReactFiberScheduler.new.js b/packages/react-reconciler/src/ReactFiberScheduler.new.js index e2b4517de23fe..670bf220e651e 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.new.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.new.js @@ -159,6 +159,7 @@ import { clearCaughtError, } from 'shared/ReactErrorUtils'; import {onCommitRoot} from './ReactFiberDevToolsHook'; +import ReactActingUpdatesSigil from './ReactActingUpdatesSigil'; const { ReactCurrentDispatcher, @@ -2029,11 +2030,38 @@ function warnAboutInvalidUpdatesOnClassComponentsInDEV(fiber) { } } +export function warnIfNotScopedWithMatchingAct(fiber: Fiber): void { + if (__DEV__) { + if ( + ReactShouldWarnActingUpdates.current !== null && + ReactShouldWarnActingUpdates.current !== ReactActingUpdatesSigil + ) { + // it looks like we're using the wrong matching act(), so log a warning + warningWithoutStack( + false, + "It looks like you're using the wrong act() around your interactions.\n" + + 'Be sure to use the matching version of act() corresponding to your renderer. e.g. -\n' + + "for react-dom, import {act} from 'react-test-utils';\n" + + 'for react-test-renderer, const {act} = TestRenderer.' + + '%s', + getStackByFiberInDevAndProd(fiber), + ); + } + } +} + +// in a test-like environment, we want to warn if dispatchAction() is +// called outside of a TestUtils.act(...)/batchedUpdates/render call. +// so we have a a step counter for when we descend/ascend from +// act() calls, and test on it for when to warn +// It's a tuple with a single value. Look into ReactTestUtilsAct as an +// example of how we change the value + function warnIfNotCurrentlyActingUpdatesInDEV(fiber: Fiber): void { if (__DEV__) { if ( workPhase === NotWorking && - ReactShouldWarnActingUpdates.current === false + ReactShouldWarnActingUpdates.current !== ReactActingUpdatesSigil ) { warningWithoutStack( false, diff --git a/packages/react-reconciler/src/ReactFiberScheduler.old.js b/packages/react-reconciler/src/ReactFiberScheduler.old.js index 965eaa1cdc62c..ec1f5f46cd0a2 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.old.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.old.js @@ -166,6 +166,7 @@ import { commitPassiveHookEffects, } from './ReactFiberCommitWork'; import {ContextOnlyDispatcher} from './ReactFiberHooks'; +import ReactActingUpdatesSigil from './ReactActingUpdatesSigil'; // Intentionally not named imports because Rollup would use dynamic dispatch for // CommonJS interop named imports. @@ -1864,19 +1865,39 @@ function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null { return root; } +export function warnIfNotScopedWithMatchingAct(fiber: Fiber): void { + if (__DEV__) { + if ( + ReactShouldWarnActingUpdates.current !== null && + ReactShouldWarnActingUpdates.current !== ReactActingUpdatesSigil + ) { + // it looks like we're using the wrong matching act(), so log a warning + warningWithoutStack( + false, + "It looks like you're using the wrong act() around your interactions.\n" + + 'Be sure to use the matching version of act() corresponding to your renderer. e.g. -\n' + + "for react-dom, import {act} from 'react-dom/test-utils';\n" + + 'for react-test-renderer, const {act} = TestRenderer.' + + '%s', + getStackByFiberInDevAndProd(fiber), + ); + } + } +} + // in a test-like environment, we want to warn if dispatchAction() is // called outside of a TestUtils.act(...)/batchedUpdates/render call. // so we have a a step counter for when we descend/ascend from // act() calls, and test on it for when to warn -// It's a tuple with a single value. Look for shared/createAct to -// see how we change the value inside act() calls +// It's a tuple with a single value. Look into ReactTestUtilsAct as an +// example of how we change the value export function warnIfNotCurrentlyActingUpdatesInDev(fiber: Fiber): void { if (__DEV__) { if ( isBatchingUpdates === false && isRendering === false && - ReactShouldWarnActingUpdates.current === false + ReactShouldWarnActingUpdates.current !== ReactActingUpdatesSigil ) { warningWithoutStack( false, diff --git a/packages/react-test-renderer/src/ReactTestRendererAct.js b/packages/react-test-renderer/src/ReactTestRendererAct.js index 37ced3fb04c04..07035323f6386 100644 --- a/packages/react-test-renderer/src/ReactTestRendererAct.js +++ b/packages/react-test-renderer/src/ReactTestRendererAct.js @@ -11,6 +11,7 @@ import type {Thenable} from 'react-reconciler/src/ReactFiberScheduler'; import { batchedUpdates, flushPassiveEffects, + ReactActingUpdatesSigil, } from 'react-reconciler/inline.test'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import warningWithoutStack from 'shared/warningWithoutStack'; @@ -42,18 +43,18 @@ function flushEffectsAndMicroTasks(onDone: (err: ?Error) => void) { function act(callback: () => Thenable) { let previousActingUpdatesScopeDepth; + let previousActingUpdatesSigil; if (__DEV__) { previousActingUpdatesScopeDepth = actingUpdatesScopeDepth; + previousActingUpdatesSigil = ReactShouldWarnActingUpdates.current; actingUpdatesScopeDepth++; - ReactShouldWarnActingUpdates.current = true; + ReactShouldWarnActingUpdates.current = ReactActingUpdatesSigil; } function onDone() { if (__DEV__) { actingUpdatesScopeDepth--; - if (actingUpdatesScopeDepth === 0) { - ReactShouldWarnActingUpdates.current = false; - } + ReactShouldWarnActingUpdates.current = previousActingUpdatesSigil; if (actingUpdatesScopeDepth > previousActingUpdatesScopeDepth) { // if it's _less than_ previousActingUpdatesScopeDepth, then we can assume the 'other' one has warned warningWithoutStack( diff --git a/packages/react/src/ReactSharedInternals.js b/packages/react/src/ReactSharedInternals.js index c6d5010cb977d..632e6aa149600 100644 --- a/packages/react/src/ReactSharedInternals.js +++ b/packages/react/src/ReactSharedInternals.js @@ -11,12 +11,12 @@ import * as SchedulerTracing from 'scheduler/tracing'; import ReactCurrentDispatcher from './ReactCurrentDispatcher'; import ReactCurrentOwner from './ReactCurrentOwner'; import ReactDebugCurrentFrame from './ReactDebugCurrentFrame'; +import ReactShouldWarnActingUpdates from './ReactShouldWarnActingUpdates'; const ReactSharedInternals = { ReactCurrentDispatcher, ReactCurrentOwner, - // used by act() - ReactShouldWarnActingUpdates: {current: false}, + ReactShouldWarnActingUpdates, // Used by renderers to avoid bundling object-assign twice in UMD bundles: assign, }; diff --git a/packages/react/src/ReactShouldWarnActingUpdates.js b/packages/react/src/ReactShouldWarnActingUpdates.js new file mode 100644 index 0000000000000..8bcdb42878c57 --- /dev/null +++ b/packages/react/src/ReactShouldWarnActingUpdates.js @@ -0,0 +1,16 @@ +/** + * 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. + */ + +/** + * Used by act() to track whether you're outside an act() scope. + * This is an object so we can track identity of the renderer. + */ + +const ReactShouldWarnActingUpdates = { + current: (null: null | {}), // {} should probably be an opaque type +}; +export default ReactShouldWarnActingUpdates; From 5a0c7c60b3ab9fb62dcd067c7eabd3c6d62681b3 Mon Sep 17 00:00:00 2001 From: Sunil Pai Date: Fri, 12 Apr 2019 14:53:04 +0100 Subject: [PATCH 3/5] Merge remote-tracking branch 'upstream/master' into renderer-specific-react-should-warn-acting-updates --- .circleci/config.yml | 8 +- dangerfile.js | 67 +- package.json | 3 +- packages/events/EventTypes.js | 43 - packages/react-art/src/ReactARTHostConfig.js | 43 +- .../ReactDevToolsHooksIntegration-test.js | 117 + ...test.internal.js => ReactDOMHooks-test.js} | 37 +- .../ReactDOMSuspensePlaceholder-test.js | 44 + .../src/client/ReactDOMHostConfig.js | 167 +- .../src/events/DOMEventResponderSystem.js | 774 +++-- .../DOMEventResponderSystem-test.internal.js | 449 ++- .../src/server/ReactPartialRenderer.js | 15 + packages/react-dom/unstable-new-scheduler.js | 16 - packages/react-events/README.md | 256 +- packages/react-events/src/Drag.js | 45 +- packages/react-events/src/Focus.js | 126 +- packages/react-events/src/Hover.js | 331 +- packages/react-events/src/Press.js | 711 +++-- packages/react-events/src/ReactEvents.js | 12 + packages/react-events/src/Swipe.js | 49 +- .../src/__tests__/Focus-test.internal.js | 111 + .../src/__tests__/Hover-test.internal.js | 359 ++- .../src/__tests__/Press-test.internal.js | 911 +++++- .../__tests__/TouchHitTarget-test.internal.js | 307 +- .../src/NativeMethodsMixin.js | 121 +- .../src/ReactFabricHostConfig.js | 91 +- .../src/ReactNativeComponent.js | 121 +- .../src/ReactNativeHostConfig.js | 44 +- .../src/ReactNativeTypes.js | 4 +- .../src/__mocks__/FabricUIManager.js | 51 + .../src/__mocks__/UIManager.js | 35 +- .../__tests__/ReactFabric-test.internal.js | 163 +- .../ReactNativeMount-test.internal.js | 128 +- .../src/createReactNoop.js | 94 +- packages/react-reconciler/src/ReactFiber.js | 8 +- .../src/ReactFiberBeginWork.js | 43 +- .../src/ReactFiberCommitWork.js | 67 +- .../src/ReactFiberCompleteWork.js | 120 +- .../src/ReactFiberExpirationTime.js | 8 + .../react-reconciler/src/ReactFiberHooks.js | 13 +- .../src/ReactFiberPendingPriority.js | 282 -- .../src/ReactFiberReconciler.js | 21 +- .../react-reconciler/src/ReactFiberRoot.js | 61 +- .../src/ReactFiberScheduler.js | 2387 +++++++++++++- .../src/ReactFiberScheduler.new.js | 2224 -------------- .../src/ReactFiberScheduler.old.js | 2736 ----------------- .../src/ReactFiberSuspenseComponent.js | 2 +- .../src/ReactFiberTreeReflection.js | 21 +- .../src/ReactFiberUnwindWork.js | 75 +- .../react-reconciler/src/ReactUpdateQueue.js | 14 +- .../src/SchedulerWithReactIntegration.js | 21 +- .../ReactExpiration-test.internal.js | 27 + .../ReactFiberEvents-test-internal.js | 252 +- .../src/__tests__/ReactHooks-test.internal.js | 4 +- ...tIncrementalErrorHandling-test.internal.js | 28 +- .../ReactIncrementalPerf-test.internal.js | 972 +++--- .../src/__tests__/ReactLazy-test.internal.js | 24 +- ...ReactSchedulerIntegration-test.internal.js | 1 - .../__tests__/ReactSuspense-test.internal.js | 267 +- .../ReactSuspensePlaceholder-test.internal.js | 43 +- ...tSuspenseWithNoopRenderer-test.internal.js | 411 ++- ...ReactIncrementalPerf-test.internal.js.snap | 546 +--- .../src/forks/ReactFiberHostConfig.custom.js | 9 +- .../src/ReactTestHostConfig.js | 99 +- packages/react/src/React.js | 22 +- packages/react/src/ReactElement.js | 137 +- packages/react/src/ReactElementValidator.js | 136 +- .../ReactElementJSX-test.internal.js | 364 +++ .../__tests__/ReactProfiler-test.internal.js | 46 +- packages/shared/HostConfigWithNoHydration.js | 2 + .../shared/HostConfigWithNoPersistence.js | 1 + packages/shared/ReactFeatureFlags.js | 5 +- packages/shared/ReactSymbols.js | 6 + packages/shared/ReactTypes.js | 81 +- .../forks/ReactFeatureFlags.native-fb.js | 2 +- .../forks/ReactFeatureFlags.native-oss.js | 2 +- .../forks/ReactFeatureFlags.new-scheduler.js | 30 - .../forks/ReactFeatureFlags.persistent.js | 2 +- .../forks/ReactFeatureFlags.test-renderer.js | 2 +- .../ReactFeatureFlags.test-renderer.www.js | 2 +- .../ReactFeatureFlags.www-new-scheduler.js | 39 - .../shared/forks/ReactFeatureFlags.www.js | 7 +- packages/shared/getComponentName.js | 34 +- scripts/circleci/build.sh | 15 +- scripts/circleci/pack_and_store_artifact.sh | 10 +- scripts/circleci/test_entry_point.sh | 1 - scripts/error-codes/README.md | 2 +- ....snap => transform-error-messages.js.snap} | 11 + ...essages.js => transform-error-messages.js} | 18 +- ...essages.js => transform-error-messages.js} | 3 +- scripts/flow/react-native-host-hooks.js | 18 + scripts/jest/config.source-new-scheduler.js | 11 - scripts/jest/preprocessor.js | 2 +- scripts/jest/setupNewScheduler.js | 7 - scripts/rollup/build.js | 9 +- scripts/rollup/bundles.js | 18 +- scripts/rollup/forks.js | 19 - scripts/rollup/results.json | 48 +- scripts/shared/inlinedHostConfigs.js | 6 +- yarn.lock | 56 +- 100 files changed, 9216 insertions(+), 8597 deletions(-) delete mode 100644 packages/events/EventTypes.js rename packages/react-dom/src/__tests__/{ReactDOMHooks-test.internal.js => ReactDOMHooks-test.js} (79%) delete mode 100644 packages/react-dom/unstable-new-scheduler.js create mode 100644 packages/react-events/src/__tests__/Focus-test.internal.js delete mode 100644 packages/react-reconciler/src/ReactFiberPendingPriority.js delete mode 100644 packages/react-reconciler/src/ReactFiberScheduler.new.js delete mode 100644 packages/react-reconciler/src/ReactFiberScheduler.old.js create mode 100644 packages/react/src/__tests__/ReactElementJSX-test.internal.js delete mode 100644 packages/shared/forks/ReactFeatureFlags.new-scheduler.js delete mode 100644 packages/shared/forks/ReactFeatureFlags.www-new-scheduler.js rename scripts/error-codes/__tests__/__snapshots__/{minify-error-messages.js.snap => transform-error-messages.js.snap} (90%) rename scripts/error-codes/__tests__/{minify-error-messages.js => transform-error-messages.js} (82%) rename scripts/error-codes/{minify-error-messages.js => transform-error-messages.js} (97%) delete mode 100644 scripts/jest/config.source-new-scheduler.js delete mode 100644 scripts/jest/setupNewScheduler.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 7bebd1f37c533..c54ff75b6573b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -44,4 +44,10 @@ jobs: path: ./node_modules.tgz - store_artifacts: - path: ./scripts/error-codes/codes.json \ No newline at end of file + path: ./build.tgz + + - store_artifacts: + path: ./scripts/rollup/results.json + + - store_artifacts: + path: ./scripts/error-codes/codes.json diff --git a/dangerfile.js b/dangerfile.js index 1a8dd8c92f45c..bc09c29574d5e 100644 --- a/dangerfile.js +++ b/dangerfile.js @@ -25,7 +25,7 @@ // // `DANGER_GITHUB_API_TOKEN=[ENV_ABOVE] yarn danger pr https://github.com/facebook/react/pull/11865 -const {markdown, danger} = require('danger'); +const {markdown, danger, warn} = require('danger'); const fetch = require('node-fetch'); const {generateResultsArray} = require('./scripts/rollup/stats'); @@ -108,18 +108,69 @@ function git(args) { // Use git locally to grab the commit which represents the place // where the branches differ const upstreamRepo = danger.github.pr.base.repo.full_name; + if (upstreamRepo !== 'facebook/react') { + // Exit unless we're running in the main repo + return; + } + const upstreamRef = danger.github.pr.base.ref; - await git(`remote add upstream https://github.com/${upstreamRepo}.git`); + await git(`remote add upstream https://github.com/facebook/react.git`); await git('fetch upstream'); - const mergeBaseCommit = await git(`merge-base HEAD upstream/${upstreamRef}`); + const baseCommit = await git(`merge-base HEAD upstream/${upstreamRef}`); + + let resultsResponse = null; + try { + let baseCIBuildId = null; + const statusesResponse = await fetch( + `https://api.github.com/repos/facebook/react/commits/${baseCommit}/statuses` + ); + const statuses = await statusesResponse.json(); + for (let i = 0; i < statuses.length; i++) { + const status = statuses[i]; + if (status.context === 'ci/circleci') { + if (status.state === 'success') { + baseCIBuildId = /\/facebook\/react\/([0-9]+)/.exec( + status.target_url + )[1]; + break; + } + if (status.state === 'failure') { + warn(`Base commit is broken: ${baseCommit}`); + return; + } + } + } + + if (baseCIBuildId === null) { + warn(`Could not find build artifacts for base commit: ${baseCommit}`); + return; + } - const commitURL = sha => - `http://react.zpao.com/builds/master/_commits/${sha}/results.json`; - const response = await fetch(commitURL(mergeBaseCommit)); + const baseArtifactsInfoResponse = await fetch( + `https://circleci.com/api/v1.1/project/github/facebook/react/${baseCIBuildId}/artifacts` + ); + const baseArtifactsInfo = await baseArtifactsInfoResponse.json(); + + for (let i = 0; i < baseArtifactsInfo.length; i++) { + const info = baseArtifactsInfo[i]; + if (info.path === 'home/circleci/project/scripts/rollup/results.json') { + resultsResponse = await fetch(info.url); + break; + } + } + } catch (error) { + warn(`Failed to fetch build artifacts for base commit: ${baseCommit}`); + return; + } + + if (resultsResponse === null) { + warn(`Could not find build artifacts for base commit: ${baseCommit}`); + return; + } // Take the JSON of the build response and // make an array comparing the results for printing - const previousBuildResults = await response.json(); + const previousBuildResults = await resultsResponse.json(); const results = generateResultsArray( currentBuildResults, previousBuildResults @@ -212,7 +263,7 @@ function git(args) {
Details of bundled changes. -

Comparing: ${mergeBaseCommit}...${danger.github.pr.head.sha}

+

Comparing: ${baseCommit}...${danger.github.pr.head.sha}

${allTables.join('\n')} diff --git a/package.json b/package.json index 1699d6847ef41..5824c7298a9de 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "flow-bin": "^0.72.0", "glob": "^6.0.4", "glob-stream": "^6.1.0", - "google-closure-compiler": "20190106.0.0", + "google-closure-compiler": "20190301.0.0", "gzip-size": "^3.0.0", "jasmine-check": "^1.0.0-rc.0", "jest": "^23.1.0", @@ -102,7 +102,6 @@ "test": "cross-env NODE_ENV=development jest --config ./scripts/jest/config.source.js", "test-persistent": "cross-env NODE_ENV=development jest --config ./scripts/jest/config.source-persistent.js", "test-fire": "cross-env NODE_ENV=development jest --config ./scripts/jest/config.source-fire.js", - "test-new-scheduler": "cross-env NODE_ENV=development jest --config ./scripts/jest/config.source-new-scheduler.js", "test-prod": "cross-env NODE_ENV=production jest --config ./scripts/jest/config.source.js", "test-fire-prod": "cross-env NODE_ENV=production jest --config ./scripts/jest/config.source-fire.js", "test-prod-build": "yarn test-build-prod", diff --git a/packages/events/EventTypes.js b/packages/events/EventTypes.js deleted file mode 100644 index 169b34aa07713..0000000000000 --- a/packages/events/EventTypes.js +++ /dev/null @@ -1,43 +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. - * - * @flow - */ - -import type {AnyNativeEvent} from 'events/PluginModuleType'; -import type {ReactEventResponderEventType} from 'shared/ReactTypes'; - -export type EventResponderContext = { - event: AnyNativeEvent, - eventTarget: Element | Document, - eventType: string, - isPassive: () => boolean, - isPassiveSupported: () => boolean, - dispatchEvent: ( - eventObject: E, - { - capture?: boolean, - discrete?: boolean, - stopPropagation?: boolean, - }, - ) => void, - isTargetWithinElement: ( - childTarget: Element | Document, - parentTarget: Element | Document, - ) => boolean, - isTargetOwned: (Element | Document) => boolean, - isTargetWithinEventComponent: (Element | Document) => boolean, - isPositionWithinTouchHitTarget: (x: number, y: number) => boolean, - addRootEventTypes: ( - rootEventTypes: Array, - ) => void, - removeRootEventTypes: ( - rootEventTypes: Array, - ) => void, - requestOwnership: (target: Element | Document | null) => boolean, - releaseOwnership: (target: Element | Document | null) => boolean, - withAsyncDispatching: (func: () => void) => void, -}; diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index 347693416591f..458c3f0f8260e 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -11,6 +11,7 @@ import * as Scheduler from 'scheduler'; import invariant from 'shared/invariant'; import {TYPES, EVENT_TYPES, childrenAsString} from './ReactARTInternals'; +import type {ReactEventComponentInstance} from 'shared/ReactTypes'; // Intentionally not named imports because Rollup would // use dynamic dispatch for CommonJS interop named imports. @@ -439,19 +440,45 @@ export function unhideTextInstance(textInstance, text): void { // Noop } -export function handleEventComponent( - eventResponder: ReactEventResponder, - rootContainerInstance: Container, - internalInstanceHandle: Object, +export function mountEventComponent( + eventComponentInstance: ReactEventComponentInstance, +) { + throw new Error('Not yet implemented.'); +} + +export function updateEventComponent( + eventComponentInstance: ReactEventComponentInstance, ) { - // TODO: add handleEventComponent implementation + throw new Error('Not yet implemented.'); +} + +export function unmountEventComponent( + eventComponentInstance: ReactEventComponentInstance, +): void { + throw new Error('Not yet implemented.'); +} + +export function getEventTargetChildElement( + type: Symbol | number, + props: Props, +): null { + throw new Error('Not yet implemented.'); } export function handleEventTarget( type: Symbol | number, props: Props, - parentInstance: Container, + rootContainerInstance: Container, internalInstanceHandle: Object, -) { - // TODO: add handleEventTarget implementation +): boolean { + throw new Error('Not yet implemented.'); +} + +export function commitEventTarget( + type: Symbol | number, + props: Props, + instance: Instance, + parentInstance: Instance, +): void { + throw new Error('Not yet implemented.'); } diff --git a/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js index 113524f832af5..81d91e7455aff 100644 --- a/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js @@ -14,13 +14,18 @@ describe('React hooks DevTools integration', () => { let React; let ReactDebugTools; let ReactTestRenderer; + let Scheduler; let act; let overrideHookState; + let scheduleUpdate; + let setSuspenseHandler; beforeEach(() => { global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { inject: injected => { overrideHookState = injected.overrideHookState; + scheduleUpdate = injected.scheduleUpdate; + setSuspenseHandler = injected.setSuspenseHandler; }, supportsFiber: true, onCommitFiberRoot: () => {}, @@ -32,6 +37,7 @@ describe('React hooks DevTools integration', () => { React = require('react'); ReactDebugTools = require('react-debug-tools'); ReactTestRenderer = require('react-test-renderer'); + Scheduler = require('scheduler'); act = ReactTestRenderer.act; }); @@ -173,4 +179,115 @@ describe('React hooks DevTools integration', () => { }); } }); + + it('should support overriding suspense in sync mode', () => { + if (__DEV__) { + // Lock the first render + setSuspenseHandler(() => true); + } + + function MyComponent() { + return 'Done'; + } + + const renderer = ReactTestRenderer.create( +
+ + + +
, + ); + const fiber = renderer.root._currentFiber().child; + if (__DEV__) { + // First render was locked + expect(renderer.toJSON().children).toEqual(['Loading']); + scheduleUpdate(fiber); // Re-render + expect(renderer.toJSON().children).toEqual(['Loading']); + + // Release the lock + setSuspenseHandler(() => false); + scheduleUpdate(fiber); // Re-render + expect(renderer.toJSON().children).toEqual(['Done']); + scheduleUpdate(fiber); // Re-render + expect(renderer.toJSON().children).toEqual(['Done']); + + // Lock again + setSuspenseHandler(() => true); + scheduleUpdate(fiber); // Re-render + expect(renderer.toJSON().children).toEqual(['Loading']); + + // Release the lock again + setSuspenseHandler(() => false); + scheduleUpdate(fiber); // Re-render + expect(renderer.toJSON().children).toEqual(['Done']); + + // Ensure it checks specific fibers. + setSuspenseHandler(f => f === fiber || f === fiber.alternate); + scheduleUpdate(fiber); // Re-render + expect(renderer.toJSON().children).toEqual(['Loading']); + setSuspenseHandler(f => f !== fiber && f !== fiber.alternate); + scheduleUpdate(fiber); // Re-render + expect(renderer.toJSON().children).toEqual(['Done']); + } else { + expect(renderer.toJSON().children).toEqual(['Done']); + } + }); + + it('should support overriding suspense in concurrent mode', () => { + if (__DEV__) { + // Lock the first render + setSuspenseHandler(() => true); + } + + function MyComponent() { + return 'Done'; + } + + const renderer = ReactTestRenderer.create( +
+ + + +
, + {unstable_isConcurrent: true}, + ); + + expect(Scheduler).toFlushAndYield([]); + // Ensure we timeout any suspense time. + jest.advanceTimersByTime(1000); + const fiber = renderer.root._currentFiber().child; + if (__DEV__) { + // First render was locked + expect(renderer.toJSON().children).toEqual(['Loading']); + scheduleUpdate(fiber); // Re-render + expect(renderer.toJSON().children).toEqual(['Loading']); + + // Release the lock + setSuspenseHandler(() => false); + scheduleUpdate(fiber); // Re-render + expect(renderer.toJSON().children).toEqual(['Done']); + scheduleUpdate(fiber); // Re-render + expect(renderer.toJSON().children).toEqual(['Done']); + + // Lock again + setSuspenseHandler(() => true); + scheduleUpdate(fiber); // Re-render + expect(renderer.toJSON().children).toEqual(['Loading']); + + // Release the lock again + setSuspenseHandler(() => false); + scheduleUpdate(fiber); // Re-render + expect(renderer.toJSON().children).toEqual(['Done']); + + // Ensure it checks specific fibers. + setSuspenseHandler(f => f === fiber || f === fiber.alternate); + scheduleUpdate(fiber); // Re-render + expect(renderer.toJSON().children).toEqual(['Loading']); + setSuspenseHandler(f => f !== fiber && f !== fiber.alternate); + scheduleUpdate(fiber); // Re-render + expect(renderer.toJSON().children).toEqual(['Done']); + } else { + expect(renderer.toJSON().children).toEqual(['Done']); + } + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMHooks-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMHooks-test.js similarity index 79% rename from packages/react-dom/src/__tests__/ReactDOMHooks-test.internal.js rename to packages/react-dom/src/__tests__/ReactDOMHooks-test.js index 7d58d22f41bac..360cfa9f9a392 100644 --- a/packages/react-dom/src/__tests__/ReactDOMHooks-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMHooks-test.js @@ -9,8 +9,6 @@ 'use strict'; -let ReactFeatureFlags; -let enableNewScheduler; let React; let ReactDOM; let Scheduler; @@ -21,8 +19,6 @@ describe('ReactDOMHooks', () => { beforeEach(() => { jest.resetModules(); - ReactFeatureFlags = require('shared/ReactFeatureFlags'); - enableNewScheduler = ReactFeatureFlags.enableNewScheduler; React = require('react'); ReactDOM = require('react-dom'); Scheduler = require('scheduler'); @@ -101,30 +97,15 @@ describe('ReactDOMHooks', () => { } ReactDOM.render(, container); - - if (enableNewScheduler) { - // The old behavior was accidental; in the new scheduler, flushing passive - // effects also flushes synchronous work, even inside batchedUpdates. - ReactDOM.unstable_batchedUpdates(() => { - _set(0); // Forces the effect to be flushed - expect(otherContainer.textContent).toBe('A'); - ReactDOM.render(, otherContainer); - expect(otherContainer.textContent).toBe('A'); - }); - expect(otherContainer.textContent).toBe('B'); - expect(calledA).toBe(true); - expect(calledB).toBe(true); - } else { - ReactDOM.unstable_batchedUpdates(() => { - _set(0); // Forces the effect to be flushed - expect(otherContainer.textContent).toBe(''); - ReactDOM.render(, otherContainer); - expect(otherContainer.textContent).toBe(''); - }); - expect(otherContainer.textContent).toBe('B'); - expect(calledA).toBe(false); // It was in a batch - expect(calledB).toBe(true); - } + ReactDOM.unstable_batchedUpdates(() => { + _set(0); // Forces the effect to be flushed + expect(otherContainer.textContent).toBe('A'); + ReactDOM.render(, otherContainer); + expect(otherContainer.textContent).toBe('A'); + }); + expect(otherContainer.textContent).toBe('B'); + expect(calledA).toBe(true); + expect(calledB).toBe(true); }); it('should not bail out when an update is scheduled from within an event handler', () => { diff --git a/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js b/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js index de43fd44274c3..45fec880410c4 100644 --- a/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js @@ -233,4 +233,48 @@ describe('ReactDOMSuspensePlaceholder', () => { await Lazy; expect(log).toEqual(['cDU first', 'cDU second']); }); + + // Regression test for https://github.com/facebook/react/issues/14188 + it('can call findDOMNode() in a suspended component commit phase (#2)', () => { + let suspendOnce = Promise.resolve(); + function Suspend() { + if (suspendOnce) { + let promise = suspendOnce; + suspendOnce = null; + throw promise; + } + return null; + } + + const log = []; + class Child extends React.Component { + componentDidMount() { + log.push('cDM'); + ReactDOM.findDOMNode(this); + } + + componentDidUpdate() { + log.push('cDU'); + ReactDOM.findDOMNode(this); + } + + render() { + return null; + } + } + + function App() { + return ( + + + + + ); + } + + ReactDOM.render(, container); + expect(log).toEqual(['cDM']); + ReactDOM.render(, container); + expect(log).toEqual(['cDM', 'cDU']); + }); }); diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index ef96ee8f944bc..91657fbe67046 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -33,7 +33,7 @@ import { isEnabled as ReactBrowserEventEmitterIsEnabled, setEnabled as ReactBrowserEventEmitterSetEnabled, } from '../events/ReactBrowserEventEmitter'; -import {getChildNamespace} from '../shared/DOMNamespaces'; +import {Namespaces, getChildNamespace} from '../shared/DOMNamespaces'; import { ELEMENT_NODE, TEXT_NODE, @@ -44,8 +44,13 @@ import { import dangerousStyleValue from '../shared/dangerousStyleValue'; import type {DOMContainer} from './ReactDOM'; -import type {ReactEventResponder} from 'shared/ReactTypes'; +import type {ReactEventComponentInstance} from 'shared/ReactTypes'; +import { + mountEventResponder, + unmountEventResponder, +} from '../events/DOMEventResponderSystem'; import {REACT_EVENT_TARGET_TOUCH_HIT} from 'shared/ReactSymbols'; +import {canUseDOM} from 'shared/ExecutionEnvironment'; export type Type = string; export type Props = { @@ -57,6 +62,23 @@ export type Props = { style?: { display?: string, }, + bottom?: null | number, + left?: null | number, + right?: null | number, + top?: null | number, +}; +export type EventTargetChildElement = { + type: string, + props: null | { + style?: { + position?: string, + zIndex?: number, + bottom?: string, + left?: string, + right?: string, + top?: string, + }, + }, }; export type Container = Element | Document; export type Instance = Element; @@ -70,7 +92,6 @@ type HostContextDev = { eventData: null | {| isEventComponent?: boolean, isEventTarget?: boolean, - eventTargetType?: null | Symbol | number, |}, }; type HostContextProd = string; @@ -86,6 +107,8 @@ import { } from 'shared/ReactFeatureFlags'; import warning from 'shared/warning'; +const {html: HTML_NAMESPACE} = Namespaces; + // Intentionally not named imports because Rollup would // use dynamic dispatch for CommonJS interop named imports. const { @@ -190,7 +213,6 @@ export function getChildHostContextForEventComponent( const eventData = { isEventComponent: true, isEventTarget: false, - eventTargetType: null, }; return {namespace, ancestorInfo, eventData}; } @@ -204,17 +226,24 @@ export function getChildHostContextForEventTarget( if (__DEV__) { const parentHostContextDev = ((parentHostContext: any): HostContextDev); const {namespace, ancestorInfo} = parentHostContextDev; - warning( - parentHostContextDev.eventData === null || - !parentHostContextDev.eventData.isEventComponent || - type !== REACT_EVENT_TARGET_TOUCH_HIT, - 'validateDOMNesting: cannot not be a direct child of an event component. ' + - 'Ensure is a direct child of a DOM element.', - ); + if (type === REACT_EVENT_TARGET_TOUCH_HIT) { + warning( + parentHostContextDev.eventData === null || + !parentHostContextDev.eventData.isEventComponent, + 'validateDOMNesting: cannot not be a direct child of an event component. ' + + 'Ensure is a direct child of a DOM element.', + ); + const parentNamespace = parentHostContextDev.namespace; + if (parentNamespace !== HTML_NAMESPACE) { + throw new Error( + ' was used in an unsupported DOM namespace. ' + + 'Ensure the is used in an HTML namespace.', + ); + } + } const eventData = { isEventComponent: false, isEventTarget: true, - eventTargetType: type, }; return {namespace, ancestorInfo, eventData}; } @@ -249,16 +278,6 @@ export function createInstance( if (__DEV__) { // TODO: take namespace into account when validating. const hostContextDev = ((hostContext: any): HostContextDev); - if (enableEventAPI) { - const eventData = hostContextDev.eventData; - if (eventData !== null) { - warning( - !eventData.isEventTarget || - eventData.eventTargetType !== REACT_EVENT_TARGET_TOUCH_HIT, - 'Warning: validateDOMNesting: must not have any children.', - ); - } - } validateDOMNesting(type, null, hostContextDev.ancestorInfo); if ( typeof props.children === 'string' || @@ -365,25 +384,12 @@ export function createTextInstance( if (enableEventAPI) { const eventData = hostContextDev.eventData; if (eventData !== null) { - warning( - eventData === null || - !eventData.isEventTarget || - eventData.eventTargetType !== REACT_EVENT_TARGET_TOUCH_HIT, - 'Warning: validateDOMNesting: must not have any children.', - ); warning( !eventData.isEventComponent, 'validateDOMNesting: React event components cannot have text DOM nodes as children. ' + 'Wrap the child text "%s" in an element.', text, ); - warning( - !eventData.isEventTarget || - eventData.eventTargetType === REACT_EVENT_TARGET_TOUCH_HIT, - 'validateDOMNesting: React event targets cannot have text DOM nodes as children. ' + - 'Wrap the child text "%s" in an element.', - text, - ); } } } @@ -885,30 +891,105 @@ export function didNotFindHydratableSuspenseInstance( } } -export function handleEventComponent( - eventResponder: ReactEventResponder, - rootContainerInstance: Container, - internalInstanceHandle: Object, +export function mountEventComponent( + eventComponentInstance: ReactEventComponentInstance, ): void { if (enableEventAPI) { + mountEventResponder(eventComponentInstance); + updateEventComponent(eventComponentInstance); + } +} + +export function updateEventComponent( + eventComponentInstance: ReactEventComponentInstance, +): void { + if (enableEventAPI) { + const rootContainerInstance = ((eventComponentInstance.rootInstance: any): Container); const rootElement = rootContainerInstance.ownerDocument; listenToEventResponderEventTypes( - eventResponder.targetEventTypes, + eventComponentInstance.responder.targetEventTypes, rootElement, ); } } +export function unmountEventComponent( + eventComponentInstance: ReactEventComponentInstance, +): void { + if (enableEventAPI) { + // TODO stop listening to targetEventTypes + unmountEventResponder(eventComponentInstance); + } +} + +export function getEventTargetChildElement( + type: Symbol | number, + props: Props, +): null | EventTargetChildElement { + if (enableEventAPI) { + if (type === REACT_EVENT_TARGET_TOUCH_HIT) { + const {bottom, left, right, top} = props; + + if (!bottom && !left && !right && !top) { + return null; + } + return { + type: 'div', + props: { + style: { + position: 'absolute', + zIndex: -1, + bottom: bottom ? `-${bottom}px` : '0px', + left: left ? `-${left}px` : '0px', + right: right ? `-${right}px` : '0px', + top: top ? `-${top}px` : '0px', + }, + }, + }; + } + } + return null; +} + export function handleEventTarget( type: Symbol | number, props: Props, - parentInstance: Container, + rootContainerInstance: Container, internalInstanceHandle: Object, +): boolean { + return false; +} + +export function commitEventTarget( + type: Symbol | number, + props: Props, + instance: Instance, + parentInstance: Instance, ): void { if (enableEventAPI) { - // Touch target hit slop handling if (type === REACT_EVENT_TARGET_TOUCH_HIT) { - // TODO + if (__DEV__ && canUseDOM) { + // This is done at DEV time because getComputedStyle will + // typically force a style recalculation and force a layout, + // reflow -– both of which are sync are expensive. + const computedStyles = window.getComputedStyle(parentInstance); + const position = computedStyles.getPropertyValue('position'); + warning( + position !== '' && position !== 'static', + ' inserts an empty absolutely positioned
. ' + + 'This requires its parent DOM node to be positioned too, but the ' + + 'parent DOM node was found to have the style "position" set to ' + + 'either no value, or a value of "static". Try using a "position" ' + + 'value of "relative".', + ); + warning( + computedStyles.getPropertyValue('zIndex') !== '', + ' inserts an empty
with "z-index" of "-1". ' + + 'This requires its parent DOM node to have a "z-index" great than "-1",' + + 'but the parent DOM node was found to no "z-index" value set.' + + ' Try using a "z-index" value of "0" or greater.', + ); + } } } } diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 45e6c95e463b6..f0e98569278a5 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -12,21 +12,29 @@ import { PASSIVE_NOT_SUPPORTED, } from 'events/EventSystemFlags'; import type {AnyNativeEvent} from 'events/PluginModuleType'; -import {EventComponent} from 'shared/ReactWorkTags'; +import { + EventComponent, + EventTarget as EventTargetWorkTag, + HostComponent, +} from 'shared/ReactWorkTags'; import type { - ReactEventResponder, ReactEventResponderEventType, + ReactEventComponentInstance, + ReactResponderContext, + ReactResponderEvent, + ReactResponderDispatchEventOptions, } from 'shared/ReactTypes'; import type {DOMTopLevelEventType} from 'events/TopLevelEventTypes'; import {batchedUpdates, interactiveUpdates} from 'events/ReactGenericBatching'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; -import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; - +import warning from 'shared/warning'; import {enableEventAPI} from 'shared/ReactFeatureFlags'; import {invokeGuardedCallbackAndCatchFirstError} from 'shared/ReactErrorUtils'; -import warning from 'shared/warning'; +import invariant from 'shared/invariant'; + +import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; -let listenToResponderEventTypesImpl; +export let listenToResponderEventTypesImpl; export function setListenToResponderEventTypes( _listenToResponderEventTypesImpl: Function, @@ -34,32 +42,381 @@ export function setListenToResponderEventTypes( listenToResponderEventTypesImpl = _listenToResponderEventTypesImpl; } -const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set; +type EventObjectTypes = {|stopPropagation: true|} | $Shape; + +type EventQueue = { + bubble: null | Array, + capture: null | Array, + discrete: boolean, +}; + +type PartialEventObject = { + target: Element | Document, + type: string, +}; -const rootEventTypesToEventComponents: Map< +type ResponderTimeout = {| + id: TimeoutID, + timers: Map, +|}; + +type ResponderTimer = {| + instance: ReactEventComponentInstance, + func: () => void, + id: Symbol, +|}; + +const activeTimeouts: Map = new Map(); +const rootEventTypesToEventComponentInstances: Map< DOMTopLevelEventType | string, - Set, + Set, > = new Map(); const targetEventTypeCached: Map< Array, Set, > = new Map(); -const targetOwnership: Map = new Map(); -const eventsWithStopPropagation: - | WeakSet - | Set<$Shape> = new PossiblyWeakSet(); - -type PartialEventObject = { - listener: ($Shape) => void, - target: Element | Document, - type: string, -}; -type EventQueue = { - bubble: null | Array<$Shape>, - capture: null | Array<$Shape>, - discrete: boolean, +const ownershipChangeListeners: Set = new Set(); +const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; +const eventListeners: + | WeakMap + | Map< + $Shape, + ($Shape) => void, + > = new PossiblyWeakMap(); + +let currentTimers = new Map(); +let currentOwner = null; +let currentInstance: null | ReactEventComponentInstance = null; +let currentEventQueue: null | EventQueue = null; + +const eventResponderContext: ReactResponderContext = { + dispatchEvent( + possibleEventObject: Object, + listener: ($Shape) => void, + {capture, discrete}: ReactResponderDispatchEventOptions, + ): void { + validateResponderContext(); + const {target, type} = possibleEventObject; + + if (target == null || type == null) { + throw new Error( + 'context.dispatchEvent: "target" and "type" fields on event object are required.', + ); + } + if (__DEV__) { + possibleEventObject.preventDefault = () => { + // Update this warning when we have a story around dealing with preventDefault + warning( + false, + 'preventDefault() is no longer available on event objects created from event responder modules.', + ); + }; + possibleEventObject.stopPropagation = () => { + // Update this warning when we have a story around dealing with stopPropgation + warning( + false, + 'stopPropagation() is no longer available on event objects created from event responder modules.', + ); + }; + } + const eventObject = ((possibleEventObject: any): $Shape< + PartialEventObject, + >); + const events = getEventsFromEventQueue(capture); + if (discrete) { + ((currentEventQueue: any): EventQueue).discrete = true; + } + eventListeners.set(eventObject, listener); + events.push(eventObject); + }, + dispatchStopPropagation(capture?: boolean) { + validateResponderContext(); + const events = getEventsFromEventQueue(); + events.push({stopPropagation: true}); + }, + isPositionWithinTouchHitTarget(doc: Document, x: number, y: number): boolean { + validateResponderContext(); + // This isn't available in some environments (JSDOM) + if (typeof doc.elementFromPoint !== 'function') { + return false; + } + const target = doc.elementFromPoint(x, y); + if (target === null) { + return false; + } + const childFiber = getClosestInstanceFromNode(target); + if (childFiber === null) { + return false; + } + const parentFiber = childFiber.return; + if (parentFiber !== null && parentFiber.tag === EventTargetWorkTag) { + const parentNode = ((target.parentNode: any): Element); + // TODO find another way to do this without using the + // expensive getBoundingClientRect. + const {left, top, right, bottom} = parentNode.getBoundingClientRect(); + // Check if the co-ords intersect with the target element's rect. + if (x > left && y > top && x < right && y < bottom) { + return false; + } + return true; + } + return false; + }, + isTargetWithinEventComponent(target: Element | Document): boolean { + validateResponderContext(); + if (target != null) { + let fiber = getClosestInstanceFromNode(target); + while (fiber !== null) { + if (fiber.stateNode === currentInstance) { + return true; + } + fiber = fiber.return; + } + } + return false; + }, + isTargetWithinElement( + childTarget: Element | Document, + parentTarget: Element | Document, + ): boolean { + const childFiber = getClosestInstanceFromNode(childTarget); + const parentFiber = getClosestInstanceFromNode(parentTarget); + + let node = childFiber; + while (node !== null) { + if (node === parentFiber) { + return true; + } + node = node.return; + } + return false; + }, + addRootEventTypes( + doc: Document, + rootEventTypes: Array, + ): void { + validateResponderContext(); + listenToResponderEventTypesImpl(rootEventTypes, doc); + for (let i = 0; i < rootEventTypes.length; i++) { + const rootEventType = rootEventTypes[i]; + const topLevelEventType = + typeof rootEventType === 'string' ? rootEventType : rootEventType.name; + let rootEventComponentInstances = rootEventTypesToEventComponentInstances.get( + topLevelEventType, + ); + if (rootEventComponentInstances === undefined) { + rootEventComponentInstances = new Set(); + rootEventTypesToEventComponentInstances.set( + topLevelEventType, + rootEventComponentInstances, + ); + } + rootEventComponentInstances.add( + ((currentInstance: any): ReactEventComponentInstance), + ); + } + }, + removeRootEventTypes( + rootEventTypes: Array, + ): void { + validateResponderContext(); + for (let i = 0; i < rootEventTypes.length; i++) { + const rootEventType = rootEventTypes[i]; + const topLevelEventType = + typeof rootEventType === 'string' ? rootEventType : rootEventType.name; + let rootEventComponents = rootEventTypesToEventComponentInstances.get( + topLevelEventType, + ); + if (rootEventComponents !== undefined) { + rootEventComponents.delete( + ((currentInstance: any): ReactEventComponentInstance), + ); + } + } + }, + hasOwnership(): boolean { + validateResponderContext(); + return currentOwner === currentInstance; + }, + requestOwnership(): boolean { + validateResponderContext(); + if (currentOwner !== null) { + return false; + } + currentOwner = currentInstance; + triggerOwnershipListeners(); + return true; + }, + releaseOwnership(): boolean { + validateResponderContext(); + if (currentOwner !== currentInstance) { + return false; + } + currentOwner = null; + triggerOwnershipListeners(); + return false; + }, + setTimeout(func: () => void, delay): Symbol { + validateResponderContext(); + if (currentTimers === null) { + currentTimers = new Map(); + } + let timeout = currentTimers.get(delay); + + const timerId = Symbol(); + if (timeout === undefined) { + const timers = new Map(); + const id = setTimeout(() => { + processTimers(timers); + }, delay); + timeout = { + id, + timers, + }; + currentTimers.set(delay, timeout); + } + timeout.timers.set(timerId, { + instance: ((currentInstance: any): ReactEventComponentInstance), + func, + id: timerId, + }); + activeTimeouts.set(timerId, timeout); + return timerId; + }, + clearTimeout(timerId: Symbol): void { + validateResponderContext(); + const timeout = activeTimeouts.get(timerId); + + if (timeout !== undefined) { + const timers = timeout.timers; + timers.delete(timerId); + if (timers.size === 0) { + clearTimeout(timeout.id); + } + } + }, + getEventTargetsFromTarget( + target: Element | Document, + queryType?: Symbol | number, + queryKey?: string, + ): Array<{ + node: Element, + props: null | Object, + }> { + validateResponderContext(); + const eventTargetHostComponents = []; + let node = getClosestInstanceFromNode(target); + // We traverse up the fiber tree from the target fiber, to the + // current event component fiber. Along the way, we check if + // the fiber has any children that are event targets. If there + // are, we query them (optionally) to ensure they match the + // specified type and key. We then push the event target props + // along with the associated parent host component of that event + // target. + while (node !== null) { + if (node.stateNode === currentInstance) { + break; + } + let child = node.child; + + while (child !== null) { + if ( + child.tag === EventTargetWorkTag && + queryEventTarget(child, queryType, queryKey) + ) { + const props = child.stateNode.props; + let parent = child.return; + + if (parent !== null) { + if (parent.stateNode === currentInstance) { + break; + } + if (parent.tag === HostComponent) { + eventTargetHostComponents.push({ + node: parent.stateNode, + props, + }); + break; + } + parent = parent.return; + } + break; + } + child = child.sibling; + } + node = node.return; + } + return eventTargetHostComponents; + }, }; +function getEventsFromEventQueue(capture?: boolean): Array { + const eventQueue = ((currentEventQueue: any): EventQueue); + let events; + if (capture) { + events = eventQueue.capture; + if (events === null) { + events = eventQueue.capture = []; + } + } else { + events = eventQueue.bubble; + if (events === null) { + events = eventQueue.bubble = []; + } + } + return events; +} + +function processTimers(timers: Map): void { + const timersArr = Array.from(timers.values()); + currentEventQueue = createEventQueue(); + try { + for (let i = 0; i < timersArr.length; i++) { + const {instance, func, id} = timersArr[i]; + currentInstance = instance; + try { + func(); + } finally { + activeTimeouts.delete(id); + } + } + batchedUpdates(processEventQueue, currentEventQueue); + } finally { + currentTimers = null; + currentInstance = null; + currentEventQueue = null; + } +} + +function queryEventTarget( + child: Fiber, + queryType: void | Symbol | number, + queryKey: void | string, +): boolean { + if (queryType !== undefined && child.type.type !== queryType) { + return false; + } + if (queryKey !== undefined && child.key !== queryKey) { + return false; + } + return true; +} + +function createResponderEvent( + topLevelType: string, + nativeEvent: AnyNativeEvent, + nativeEventTarget: Element | Document, + eventSystemFlags: EventSystemFlags, +): ReactResponderEvent { + return { + nativeEvent: nativeEvent, + target: nativeEventTarget, + type: topLevelType, + passive: (eventSystemFlags & IS_PASSIVE) !== 0, + passiveSupported: (eventSystemFlags & PASSIVE_NOT_SUPPORTED) === 0, + }; +} + function createEventQueue(): EventQueue { return { bubble: null, @@ -70,38 +427,40 @@ function createEventQueue(): EventQueue { function processEvent(event: $Shape): void { const type = event.type; - const listener = event.listener; + const listener = ((eventListeners.get(event): any): ( + $Shape, + ) => void); invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event); } function processEvents( - bubble: null | Array<$Shape>, - capture: null | Array<$Shape>, + bubble: null | Array, + capture: null | Array, ): void { let i, length; if (capture !== null) { for (i = capture.length; i-- > 0; ) { const event = capture[i]; - processEvent(capture[i]); - if (eventsWithStopPropagation.has(event)) { + if (event.stopPropagation === true) { return; } + processEvent(((event: any): $Shape)); } } if (bubble !== null) { for (i = 0, length = bubble.length; i < length; ++i) { const event = bubble[i]; - processEvent(event); - if (eventsWithStopPropagation.has(event)) { + if (event.stopPropagation === true) { return; } + processEvent(((event: any): $Shape)); } } } -function processEventQueue(eventQueue: EventQueue): void { - const {bubble, capture, discrete} = eventQueue; +export function processEventQueue(): void { + const {bubble, capture, discrete} = ((currentEventQueue: any): EventQueue); if (discrete) { interactiveUpdates(() => { @@ -112,218 +471,6 @@ function processEventQueue(eventQueue: EventQueue): void { } } -// TODO add context methods for dispatching events -function DOMEventResponderContext( - topLevelType: DOMTopLevelEventType, - nativeEvent: AnyNativeEvent, - nativeEventTarget: EventTarget, - eventSystemFlags: EventSystemFlags, -) { - this.event = nativeEvent; - this.eventTarget = nativeEventTarget; - this.eventType = topLevelType; - this._flags = eventSystemFlags; - this._fiber = null; - this._responder = null; - this._discreteEvents = null; - this._nonDiscreteEvents = null; - this._isBatching = true; - this._eventQueue = createEventQueue(); -} - -DOMEventResponderContext.prototype.isPassive = function(): boolean { - return (this._flags & IS_PASSIVE) !== 0; -}; - -DOMEventResponderContext.prototype.isPassiveSupported = function(): boolean { - return (this._flags & PASSIVE_NOT_SUPPORTED) === 0; -}; - -DOMEventResponderContext.prototype.dispatchEvent = function( - possibleEventObject: Object, - { - capture, - discrete, - stopPropagation, - }: { - capture?: boolean, - discrete?: boolean, - stopPropagation?: boolean, - }, -): void { - const eventQueue = this._eventQueue; - const {listener, target, type} = possibleEventObject; - - if (listener == null || target == null || type == null) { - throw new Error( - 'context.dispatchEvent: "listener", "target" and "type" fields on event object are required.', - ); - } - if (__DEV__) { - possibleEventObject.preventDefault = () => { - // Update this warning when we have a story around dealing with preventDefault - warning( - false, - 'preventDefault() is no longer available on event objects created from event responder modules.', - ); - }; - possibleEventObject.stopPropagation = () => { - // Update this warning when we have a story around dealing with stopPropgation - warning( - false, - 'stopPropagation() is no longer available on event objects created from event responder modules.', - ); - }; - } - const eventObject = ((possibleEventObject: any): $Shape); - let events; - - if (capture) { - events = eventQueue.capture; - if (events === null) { - events = eventQueue.capture = []; - } - } else { - events = eventQueue.bubble; - if (events === null) { - events = eventQueue.bubble = []; - } - } - if (discrete) { - eventQueue.discrete = true; - } - events.push(eventObject); - - if (stopPropagation) { - eventsWithStopPropagation.add(eventObject); - } -}; - -DOMEventResponderContext.prototype.isTargetWithinEventComponent = function( - target: AnyNativeEvent, -): boolean { - const eventFiber = this._fiber; - - if (target != null) { - let fiber = getClosestInstanceFromNode(target); - while (fiber !== null) { - if (fiber === eventFiber || fiber === eventFiber.alternate) { - return true; - } - fiber = fiber.return; - } - } - return false; -}; - -DOMEventResponderContext.prototype.isTargetWithinElement = function( - childTarget: EventTarget, - parentTarget: EventTarget, -): boolean { - const childFiber = getClosestInstanceFromNode(childTarget); - const parentFiber = getClosestInstanceFromNode(parentTarget); - - let currentFiber = childFiber; - while (currentFiber !== null) { - if (currentFiber === parentFiber) { - return true; - } - currentFiber = currentFiber.return; - } - return false; -}; - -DOMEventResponderContext.prototype.addRootEventTypes = function( - rootEventTypes: Array, -) { - const element = this.eventTarget.ownerDocument; - listenToResponderEventTypesImpl(rootEventTypes, element); - const eventComponent = this._fiber; - for (let i = 0; i < rootEventTypes.length; i++) { - const rootEventType = rootEventTypes[i]; - const topLevelEventType = - typeof rootEventType === 'string' ? rootEventType : rootEventType.name; - let rootEventComponents = rootEventTypesToEventComponents.get( - topLevelEventType, - ); - if (rootEventComponents === undefined) { - rootEventComponents = new Set(); - rootEventTypesToEventComponents.set( - topLevelEventType, - rootEventComponents, - ); - } - rootEventComponents.add(eventComponent); - } -}; - -DOMEventResponderContext.prototype.removeRootEventTypes = function( - rootEventTypes: Array, -): void { - const eventComponent = this._fiber; - for (let i = 0; i < rootEventTypes.length; i++) { - const rootEventType = rootEventTypes[i]; - const topLevelEventType = - typeof rootEventType === 'string' ? rootEventType : rootEventType.name; - let rootEventComponents = rootEventTypesToEventComponents.get( - topLevelEventType, - ); - if (rootEventComponents !== undefined) { - rootEventComponents.delete(eventComponent); - } - } -}; - -DOMEventResponderContext.prototype.isPositionWithinTouchHitTarget = function() { - // TODO -}; - -DOMEventResponderContext.prototype.isTargetOwned = function( - targetElement: Element | Node, -): boolean { - const targetDoc = targetElement.ownerDocument; - return targetOwnership.has(targetDoc); -}; - -DOMEventResponderContext.prototype.requestOwnership = function( - targetElement: Element | Node, -): boolean { - const targetDoc = targetElement.ownerDocument; - if (targetOwnership.has(targetDoc)) { - return false; - } - targetOwnership.set(targetDoc, this._fiber); - return true; -}; - -DOMEventResponderContext.prototype.releaseOwnership = function( - targetElement: Element | Node, -): boolean { - const targetDoc = targetElement.ownerDocument; - if (!targetOwnership.has(targetDoc)) { - return false; - } - const owner = targetOwnership.get(targetDoc); - if (owner === this._fiber || owner === this._fiber.alternate) { - targetOwnership.delete(targetDoc); - return true; - } - return false; -}; - -DOMEventResponderContext.prototype.withAsyncDispatching = function( - func: () => void, -) { - const previousEventQueue = this._eventQueue; - this._eventQueue = createEventQueue(); - try { - func(); - batchedUpdates(processEventQueue, this._eventQueue); - } finally { - this._eventQueue = previousEventQueue; - } -}; - function getTargetEventTypes( eventTypes: Array, ): Set { @@ -344,11 +491,11 @@ function getTargetEventTypes( function handleTopLevelType( topLevelType: DOMTopLevelEventType, - fiber: Fiber, - context: Object, + responderEvent: ReactResponderEvent, + eventComponentInstance: ReactEventComponentInstance, isRootLevelEvent: boolean, ): void { - const responder: ReactEventResponder = fiber.type.responder; + let {props, responder, state} = eventComponentInstance; if (!isRootLevelEvent) { // Validate the target event type exists on the responder const targetEventTypes = getTargetEventTypes(responder.targetEventTypes); @@ -356,13 +503,8 @@ function handleTopLevelType( return; } } - let {props, state} = fiber.stateNode; - if (state === null && responder.createInitialState !== undefined) { - state = fiber.stateNode.state = responder.createInitialState(props); - } - context._fiber = fiber; - context._responder = responder; - responder.handleEvent(context, props, state); + currentInstance = eventComponentInstance; + responder.onEvent(responderEvent, eventResponderContext, props, state); } export function runResponderEventsInBatch( @@ -373,37 +515,109 @@ export function runResponderEventsInBatch( eventSystemFlags: EventSystemFlags, ): void { if (enableEventAPI) { - const context = new DOMEventResponderContext( - topLevelType, + currentEventQueue = createEventQueue(); + const responderEvent = createResponderEvent( + ((topLevelType: any): string), nativeEvent, - nativeEventTarget, + ((nativeEventTarget: any): Element | Document), eventSystemFlags, ); - let node = targetFiber; - // Traverse up the fiber tree till we find event component fibers. - while (node !== null) { - if (node.tag === EventComponent) { - handleTopLevelType(topLevelType, node, context, false); + + try { + let node = targetFiber; + // Traverse up the fiber tree till we find event component fibers. + while (node !== null) { + if (node.tag === EventComponent) { + const eventComponentInstance = node.stateNode; + handleTopLevelType( + topLevelType, + responderEvent, + eventComponentInstance, + false, + ); + } + node = node.return; } - node = node.return; - } - // Handle root level events - const rootEventComponents = rootEventTypesToEventComponents.get( - topLevelType, - ); - if (rootEventComponents !== undefined) { - const rootEventComponentFibers = Array.from(rootEventComponents); - - for (let i = 0; i < rootEventComponentFibers.length; i++) { - const rootEventComponentFiber = rootEventComponentFibers[i]; - handleTopLevelType( - topLevelType, - rootEventComponentFiber, - context, - true, - ); + // Handle root level events + const rootEventInstances = rootEventTypesToEventComponentInstances.get( + topLevelType, + ); + if (rootEventInstances !== undefined) { + const rootEventComponentInstances = Array.from(rootEventInstances); + + for (let i = 0; i < rootEventComponentInstances.length; i++) { + const rootEventComponentInstance = rootEventComponentInstances[i]; + handleTopLevelType( + topLevelType, + responderEvent, + rootEventComponentInstance, + true, + ); + } } + processEventQueue(); + } finally { + currentTimers = null; + currentInstance = null; + currentEventQueue = null; + } + } +} + +function triggerOwnershipListeners(): void { + const listeningInstances = Array.from(ownershipChangeListeners); + const previousInstance = currentInstance; + try { + for (let i = 0; i < listeningInstances.length; i++) { + const instance = listeningInstances[i]; + const {props, responder, state} = instance; + currentInstance = instance; + responder.onOwnershipChange(eventResponderContext, props, state); } - processEventQueue(context._eventQueue); + } finally { + currentInstance = previousInstance; + } +} + +export function mountEventResponder( + eventComponentInstance: ReactEventComponentInstance, +) { + const responder = eventComponentInstance.responder; + if (responder.onOwnershipChange !== undefined) { + ownershipChangeListeners.add(eventComponentInstance); } } + +export function unmountEventResponder( + eventComponentInstance: ReactEventComponentInstance, +): void { + const responder = eventComponentInstance.responder; + const onUnmount = responder.onUnmount; + if (onUnmount !== undefined) { + let {props, state} = eventComponentInstance; + currentEventQueue = createEventQueue(); + currentInstance = eventComponentInstance; + try { + onUnmount(eventResponderContext, props, state); + } finally { + currentEventQueue = null; + currentInstance = null; + currentTimers = null; + } + } + if (currentOwner === eventComponentInstance) { + currentOwner = null; + triggerOwnershipListeners(); + } + if (responder.onOwnershipChange !== undefined) { + ownershipChangeListeners.delete(eventComponentInstance); + } +} + +function validateResponderContext(): void { + invariant( + currentEventQueue && currentInstance, + 'An event responder context was used outside of an event cycle. ' + + 'Use context.setTimeout() to use asynchronous responder context outside of event cycle .', + ); +} diff --git a/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js index af0111e4eb9c8..38ca51d5414d4 100644 --- a/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js @@ -12,11 +12,21 @@ let React; let ReactFeatureFlags; let ReactDOM; - -function createReactEventComponent(targetEventTypes, handleEvent) { +let ReactSymbols; + +function createReactEventComponent( + targetEventTypes, + createInitialState, + onEvent, + onUnmount, + onOwnershipChange, +) { const testEventResponder = { targetEventTypes, - handleEvent, + createInitialState, + onEvent, + onUnmount, + onOwnershipChange, }; return { @@ -33,6 +43,14 @@ function dispatchClickEvent(element) { element.dispatchEvent(clickEvent); } +function createReactEventTarget(type) { + return { + $$typeof: ReactSymbols.REACT_EVENT_TARGET_TYPE, + displayName: 'TestEventTarget', + type, + }; +} + // This is a new feature in Fiber so I put it in its own test file. It could // probably move to one of the other test files once it is official. describe('DOMEventResponderSystem', () => { @@ -46,6 +64,7 @@ describe('DOMEventResponderSystem', () => { ReactDOM = require('react-dom'); container = document.createElement('div'); document.body.appendChild(container); + ReactSymbols = require('shared/ReactSymbols'); }); afterEach(() => { @@ -53,19 +72,20 @@ describe('DOMEventResponderSystem', () => { container = null; }); - it('the event responder handleEvent() function should fire on click event', () => { + it('the event responder onEvent() function should fire on click event', () => { let eventResponderFiredCount = 0; let eventLog = []; const buttonRef = React.createRef(); const ClickEventComponent = createReactEventComponent( ['click'], - (context, props) => { + undefined, + (event, context, props) => { eventResponderFiredCount++; eventLog.push({ - name: context.eventType, - passive: context.isPassive(), - passiveSupported: context.isPassiveSupported(), + name: event.type, + passive: event.passive, + passiveSupported: event.passiveSupported, }); }, ); @@ -79,7 +99,7 @@ describe('DOMEventResponderSystem', () => { ReactDOM.render(, container); expect(container.innerHTML).toBe(''); - // Clicking the button should trigger the event responder handleEvent() + // Clicking the button should trigger the event responder onEvent() let buttonElement = buttonRef.current; dispatchClickEvent(buttonElement); expect(eventResponderFiredCount).toBe(1); @@ -103,7 +123,7 @@ describe('DOMEventResponderSystem', () => { expect(eventResponderFiredCount).toBe(2); }); - it('the event responder handleEvent() function should fire on click event (passive events forced)', () => { + it('the event responder onEvent() function should fire on click event (passive events forced)', () => { // JSDOM does not support passive events, so this manually overrides the value to be true const checkPassiveEvents = require('react-dom/src/events/checkPassiveEvents'); checkPassiveEvents.passiveBrowserEventsSupported = true; @@ -113,11 +133,12 @@ describe('DOMEventResponderSystem', () => { const ClickEventComponent = createReactEventComponent( ['click'], - (context, props) => { + undefined, + (event, context, props) => { eventLog.push({ - name: context.eventType, - passive: context.isPassive(), - passiveSupported: context.isPassiveSupported(), + name: event.type, + passive: event.passive, + passiveSupported: event.passiveSupported, }); }, ); @@ -130,7 +151,7 @@ describe('DOMEventResponderSystem', () => { ReactDOM.render(, container); - // Clicking the button should trigger the event responder handleEvent() + // Clicking the button should trigger the event responder onEvent() let buttonElement = buttonRef.current; dispatchClickEvent(buttonElement); expect(eventLog.length).toBe(1); @@ -141,19 +162,20 @@ describe('DOMEventResponderSystem', () => { }); }); - it('nested event responders and their handleEvent() function should fire multiple times', () => { + it('nested event responders and their onEvent() function should fire multiple times', () => { let eventResponderFiredCount = 0; let eventLog = []; const buttonRef = React.createRef(); const ClickEventComponent = createReactEventComponent( ['click'], - (context, props) => { + undefined, + (event, context, props) => { eventResponderFiredCount++; eventLog.push({ - name: context.eventType, - passive: context.isPassive(), - passiveSupported: context.isPassiveSupported(), + name: event.type, + passive: event.passive, + passiveSupported: event.passiveSupported, }); }, ); @@ -168,7 +190,7 @@ describe('DOMEventResponderSystem', () => { ReactDOM.render(, container); - // Clicking the button should trigger the event responder handleEvent() + // Clicking the button should trigger the event responder onEvent() let buttonElement = buttonRef.current; dispatchClickEvent(buttonElement); expect(eventResponderFiredCount).toBe(2); @@ -186,12 +208,13 @@ describe('DOMEventResponderSystem', () => { }); }); - it('nested event responders and their handleEvent() should fire in the correct order', () => { + it('nested event responders and their onEvent() should fire in the correct order', () => { let eventLog = []; const buttonRef = React.createRef(); const ClickEventComponentA = createReactEventComponent( ['click'], + undefined, (context, props) => { eventLog.push('A'); }, @@ -199,6 +222,7 @@ describe('DOMEventResponderSystem', () => { const ClickEventComponentB = createReactEventComponent( ['click'], + undefined, (context, props) => { eventLog.push('B'); }, @@ -214,7 +238,7 @@ describe('DOMEventResponderSystem', () => { ReactDOM.render(, container); - // Clicking the button should trigger the event responder handleEvent() + // Clicking the button should trigger the event responder onEvent() let buttonElement = buttonRef.current; dispatchClickEvent(buttonElement); @@ -227,14 +251,16 @@ describe('DOMEventResponderSystem', () => { const ClickEventComponent = createReactEventComponent( ['click'], - (context, props) => { + undefined, + (event, context, props) => { if (props.onMagicClick) { - const event = { - listener: props.onMagicClick, - target: context.eventTarget, + const syntheticEvent = { + target: event.target, type: 'magicclick', }; - context.dispatchEvent(event, {discrete: true}); + context.dispatchEvent(syntheticEvent, props.onMagicClick, { + discrete: true, + }); } }, ); @@ -251,7 +277,7 @@ describe('DOMEventResponderSystem', () => { ReactDOM.render(, container); - // Clicking the button should trigger the event responder handleEvent() + // Clicking the button should trigger the event responder onEvent() let buttonElement = buttonRef.current; dispatchClickEvent(buttonElement); @@ -264,37 +290,37 @@ describe('DOMEventResponderSystem', () => { const LongPressEventComponent = createReactEventComponent( ['click'], - (context, props) => { + undefined, + (event, context, props) => { const pressEvent = { - listener: props.onPress, - target: context.eventTarget, + target: event.target, type: 'press', }; - context.dispatchEvent(pressEvent, {discrete: true}); - - setTimeout( - () => - context.withAsyncDispatching(() => { - if (props.onLongPress) { - const longPressEvent = { - listener: props.onLongPress, - target: context.eventTarget, - type: 'longpress', - }; - context.dispatchEvent(longPressEvent, {discrete: true}); - } - - if (props.onLongPressChange) { - const longPressChangeEvent = { - listener: props.onLongPressChange, - target: context.eventTarget, - type: 'longpresschange', - }; - context.dispatchEvent(longPressChangeEvent, {discrete: true}); - } - }), - 500, - ); + context.dispatchEvent(pressEvent, props.onPress, {discrete: true}); + + context.setTimeout(() => { + if (props.onLongPress) { + const longPressEvent = { + target: event.target, + type: 'longpress', + }; + context.dispatchEvent(longPressEvent, props.onLongPress, { + discrete: true, + }); + } + + if (props.onLongPressChange) { + const longPressChangeEvent = { + target: event.target, + type: 'longpresschange', + }; + context.dispatchEvent( + longPressChangeEvent, + props.onLongPressChange, + {discrete: true}, + ); + } + }, 500); }, ); @@ -313,11 +339,318 @@ describe('DOMEventResponderSystem', () => { ReactDOM.render(, container); - // Clicking the button should trigger the event responder handleEvent() + // Clicking the button should trigger the event responder onEvent() let buttonElement = buttonRef.current; dispatchClickEvent(buttonElement); jest.runAllTimers(); expect(eventLog).toEqual(['press', 'longpress', 'longpresschange']); }); + + it('the event responder onUnmount() function should fire', () => { + let onUnmountFired = 0; + + const EventComponent = createReactEventComponent( + [], + undefined, + (event, context, props, state) => {}, + () => { + onUnmountFired++; + }, + ); + + const Test = () => ( + + +
+ + ); + + ReactDOM.render(, container); + + let buttonElement = buttonRef.current; + let divElement = divRef.current; + dispatchClickEvent(buttonElement); + jest.runAllTimers(); + + expect(queryResult).toEqual([ + { + node: buttonElement, + props: { + foo: 2, + }, + }, + { + node: divElement, + props: { + foo: 1, + }, + }, + ]); + }); + + it('should be possible to query event targets by type', () => { + let queryResult = null; + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const eventTargetType = Symbol.for('react.event_target.test'); + const EventTarget = createReactEventTarget(eventTargetType); + + const eventTargetType2 = Symbol.for('react.event_target.test2'); + const EventTarget2 = createReactEventTarget(eventTargetType2); + + const EventComponent = createReactEventComponent( + ['click'], + undefined, + (event, context, props, state) => { + queryResult = context.getEventTargetsFromTarget( + event.target, + eventTargetType2, + ); + }, + ); + + const Test = () => ( + +
+ + +
+
+ ); + + ReactDOM.render(, container); + + let buttonElement = buttonRef.current; + let divElement = divRef.current; + dispatchClickEvent(buttonElement); + jest.runAllTimers(); + + expect(queryResult).toEqual([ + { + node: divElement, + props: { + foo: 1, + }, + }, + ]); + }); + + it('should be possible to query event targets by key', () => { + let queryResult = null; + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const eventTargetType = Symbol.for('react.event_target.test'); + const EventTarget = createReactEventTarget(eventTargetType); + + const EventComponent = createReactEventComponent( + ['click'], + undefined, + (event, context, props, state) => { + queryResult = context.getEventTargetsFromTarget( + event.target, + undefined, + 'a', + ); + }, + ); + + const Test = () => ( + +
+ + +
+
+ ); + + ReactDOM.render(, container); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + jest.runAllTimers(); + + expect(queryResult).toEqual([ + { + node: buttonElement, + props: { + foo: 2, + }, + }, + ]); + }); + + it('should be possible to query event targets by type and key', () => { + let queryResult = null; + let queryResult2 = null; + let queryResult3 = null; + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const eventTargetType = Symbol.for('react.event_target.test'); + const EventTarget = createReactEventTarget(eventTargetType); + + const eventTargetType2 = Symbol.for('react.event_target.test2'); + const EventTarget2 = createReactEventTarget(eventTargetType2); + + const EventComponent = createReactEventComponent( + ['click'], + undefined, + (event, context, props, state) => { + queryResult = context.getEventTargetsFromTarget( + event.target, + eventTargetType2, + 'a', + ); + + queryResult2 = context.getEventTargetsFromTarget( + event.target, + eventTargetType, + 'c', + ); + + // Should return an empty array as this doesn't exist + queryResult3 = context.getEventTargetsFromTarget( + event.target, + eventTargetType, + 'd', + ); + }, + ); + + const Test = () => ( + +
+ + + +
+
+ ); + + ReactDOM.render(, container); + + let buttonElement = buttonRef.current; + let divElement = divRef.current; + dispatchClickEvent(buttonElement); + jest.runAllTimers(); + + expect(queryResult).toEqual([ + { + node: divElement, + props: { + foo: 1, + }, + }, + ]); + expect(queryResult2).toEqual([ + { + node: buttonElement, + props: { + foo: 3, + }, + }, + ]); + expect(queryResult3).toEqual([]); + }); }); diff --git a/packages/react-dom/src/server/ReactPartialRenderer.js b/packages/react-dom/src/server/ReactPartialRenderer.js index 7eecd8dd41b03..ce078dcb71779 100644 --- a/packages/react-dom/src/server/ReactPartialRenderer.js +++ b/packages/react-dom/src/server/ReactPartialRenderer.js @@ -39,6 +39,7 @@ import { REACT_MEMO_TYPE, REACT_EVENT_COMPONENT_TYPE, REACT_EVENT_TARGET_TYPE, + REACT_EVENT_TARGET_TOUCH_HIT, } from 'shared/ReactSymbols'; import { @@ -1168,6 +1169,20 @@ class ReactDOMServerRenderer { case REACT_EVENT_COMPONENT_TYPE: case REACT_EVENT_TARGET_TYPE: { if (enableEventAPI) { + if ( + elementType.$$typeof === REACT_EVENT_TARGET_TYPE && + elementType.type === REACT_EVENT_TARGET_TOUCH_HIT + ) { + // We do not render a hit slop element anymore. Instead we rely + // on hydration adding in the hit slop element. The previous + // logic had a bug where rendering a hit slop at SSR meant that + // mouse events incorrectly registered events on the hit slop + // even though it designed to be used for touch events only. + // The logic that filters out mouse events from the hit slop + // is handled in event responder modules, which only get + // initialized upon hydration. + return ''; + } const nextChildren = toArray( ((nextChild: any): ReactElement).props.children, ); diff --git a/packages/react-dom/unstable-new-scheduler.js b/packages/react-dom/unstable-new-scheduler.js deleted file mode 100644 index 2a016ba16e9db..0000000000000 --- a/packages/react-dom/unstable-new-scheduler.js +++ /dev/null @@ -1,16 +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. - * - * @flow - */ - -'use strict'; - -const ReactDOM = require('./src/client/ReactDOM'); - -// TODO: decide on the top-level export form. -// This is hacky but makes it work with both Rollup and Jest. -module.exports = ReactDOM.default || ReactDOM; diff --git a/packages/react-events/README.md b/packages/react-events/README.md index 14d0eeafa7886..57157f720dfaf 100644 --- a/packages/react-events/README.md +++ b/packages/react-events/README.md @@ -1,3 +1,257 @@ # `react-events` -This is package is intended for use with the experimental React events API. \ No newline at end of file +*This package is experimental. It is intended for use with the experimental React +events API that is not available in open source builds.* + +Event components do not render a host node. They listen to native browser events +dispatched on the host node of their child and transform those events into +high-level events for applications. + + +## Focus + +The `Focus` module responds to focus and blur events on the element it wraps. +Focus events are dispatched for `mouse`, `pen`, `touch`, and `keyboard` +pointer types. + +```js +// Example +const TextField = (props) => ( + + + +); +``` + +```js +// Types +type FocusEvent = { + type: 'blur' | 'focus' | 'focuschange' +} +``` + +### disabled: boolean + +Disables all `Focus` events. + +### onBlur: (e: FocusEvent) => void + +Called when the element loses focus. + +### onFocus: (e: FocusEvent) => void + +Called when the element gains focus. + +### onFocusChange: boolean => void + +Called when the element changes hover state (i.e., after `onBlur` and +`onFocus`). + + +## Hover + +The `Hover` module responds to hover events on the element it wraps. Hover +events are only dispatched for `mouse` pointer types. Hover begins when the +pointer enters the element's bounds and ends when the pointer leaves. + +```js +// Example +const Link = (props) => ( + const [ hovered, setHovered ] = useState(false); + return ( + + + + ); +); +``` + +```js +// Types +type HoverEvent = { + pointerType: 'mouse', + type: 'hoverstart' | 'hoverend' | 'hovermove' | 'hoverchange' +} +``` + +### delayHoverEnd: number + +The duration of the delay between when hover ends and when `onHoverEnd` is +called. + +### delayHoverStart: number + +The duration of the delay between when hover starts and when `onHoverStart` is +called. + +### disabled: boolean + +Disables all `Hover` events. + +### onHoverChange: boolean => void + +Called when the element changes hover state (i.e., after `onHoverStart` and +`onHoverEnd`). + +### onHoverEnd: (e: HoverEvent) => void + +Called once the element is no longer hovered. It will be cancelled if the +pointer leaves the element before the `delayHoverStart` threshold is exceeded. + +### onHoverMove: (e: HoverEvent) => void + +Called when the pointer moves within the hit bounds of the element. `onHoverMove` is +called immediately and doesn't wait for delayed `onHoverStart`. + +### onHoverStart: (e: HoverEvent) => void + +Called once the element is hovered. It will not be called if the pointer leaves +the element before the `delayHoverStart` threshold is exceeded. And it will not +be called more than once before `onHoverEnd` is called. + +### preventDefault: boolean = true + +Whether to `preventDefault()` native events. + +### stopPropagation: boolean = true + +Whether to `stopPropagation()` native events. + + +## Press + +The `Press` module responds to press events on the element it wraps. Press +events are dispatched for `mouse`, `pen`, `touch`, and `keyboard` pointer types. +Press events are only dispatched for keyboards when pressing the Enter or +Spacebar keys. If neither `onPress` nor `onLongPress` are called, this signifies +that the press ended outside of the element hit bounds (i.e., the user aborted +the press). + +```js +// Example +const Button = (props) => ( + const [ pressed, setPressed ] = useState(false); + return ( + +
+ + ); +); +``` + +```js +// Types +type PressEvent = { + pointerType: 'mouse' | 'touch' | 'pen' | 'keyboard', + type: 'press' | 'pressstart' | 'pressend' | 'presschange' | 'pressmove' | 'longpress' | 'longpresschange' +} + +type PressOffset = { + top: number, + right: number, + bottom: number, + right: number +}; +``` + +### delayLongPress: number = 500ms + +The duration of a press before `onLongPress` and `onLongPressChange` are called. + +### delayPressEnd: number + +The duration of the delay between when the press ends and when `onPressEnd` is +called. + +### delayPressStart: number + +The duration of a delay between when the press starts and when `onPressStart` is +called. This delay is cut short (and `onPressStart` is called) if the press is +released before the threshold is exceeded. + +### disabled: boolean + +Disables all `Press` events. + +### onLongPress: (e: PressEvent) => void + +Called once the element has been pressed for the length of `delayLongPress`. If +the press point moves more than 10px `onLongPress` is cancelled. + +### onLongPressChange: boolean => void + +Called when the element changes long-press state. + +### onLongPressShouldCancelPress: () => boolean + +Determines whether calling `onPress` should be cancelled if `onLongPress` or +`onLongPressChange` have already been called. Default is `false`. + +### onPress: (e: PressEvent) => void + +Called immediately after a press is released, unless either 1) the press is +released outside the hit bounds of the element (accounting for +`pressRetentionOffset` and `TouchHitTarget`), or 2) the press was a long press, +and `onLongPress` or `onLongPressChange` props are provided, and +`onLongPressCancelsPress()` is `true`. + +### onPressChange: boolean => void + +Called when the element changes press state (i.e., after `onPressStart` and +`onPressEnd`). + +### onPressEnd: (e: PressEvent) => void + +Called once the element is no longer pressed (because it was released, or moved +beyond the hit bounds). If the press starts again before the `delayPressEnd` +threshold is exceeded then the delay is reset to prevent `onPressEnd` being +called during a press. + +### onPressMove: (e: PressEvent) => void + +Called when a press moves within the hit bounds of the element. `onPressMove` is +called immediately and doesn't wait for delayed `onPressStart`. Never called for +keyboard-initiated press events. + +### onPressStart: (e: PressEvent) => void + +Called once the element is pressed down. If the press is released before the +`delayPressStart` threshold is exceeded then the delay is cut short and +`onPressStart` is called immediately. + +### pressRetentionOffset: PressOffset + +Defines how far the pointer (while held down) may move outside the bounds of the +element before it is deactivated. Once deactivated, the pointer (still held +down) can be moved back within the bounds of the element to reactivate it. +Ensure you pass in a constant to reduce memory allocations. + +### preventDefault: boolean = true + +Whether to `preventDefault()` native events. + +### stopPropagation: boolean = true + +Whether to `stopPropagation()` native events. diff --git a/packages/react-events/src/Drag.js b/packages/react-events/src/Drag.js index ed11c4260c270..b06831851a30f 100644 --- a/packages/react-events/src/Drag.js +++ b/packages/react-events/src/Drag.js @@ -7,7 +7,10 @@ * @flow */ -import type {EventResponderContext} from 'events/EventTypes'; +import type { + ReactResponderEvent, + ReactResponderContext, +} from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; const targetEventTypes = ['pointerdown', 'pointercancel']; @@ -40,7 +43,6 @@ type EventData = { type DragEventType = 'dragstart' | 'dragend' | 'dragchange' | 'dragmove'; type DragEvent = {| - listener: DragEvent => void, target: Element | Document, type: DragEventType, diffX?: number, @@ -50,11 +52,9 @@ type DragEvent = {| function createDragEvent( type: DragEventType, target: Element | Document, - listener: DragEvent => void, eventData?: EventData, ): DragEvent { return { - listener, target, type, ...eventData, @@ -62,7 +62,7 @@ function createDragEvent( } function dispatchDragEvent( - context: EventResponderContext, + context: ReactResponderContext, name: DragEventType, listener: DragEvent => void, state: DragState, @@ -70,8 +70,8 @@ function dispatchDragEvent( eventData?: EventData, ): void { const target = ((state.dragTarget: any): Element | Document); - const syntheticEvent = createDragEvent(name, target, listener, eventData); - context.dispatchEvent(syntheticEvent, {discrete}); + const syntheticEvent = createDragEvent(name, target, eventData); + context.dispatchEvent(syntheticEvent, listener, {discrete}); } const DragResponder = { @@ -87,28 +87,31 @@ const DragResponder = { y: 0, }; }, - handleEvent( - context: EventResponderContext, + onEvent( + event: ReactResponderEvent, + context: ReactResponderContext, props: Object, state: DragState, ): void { - const {eventTarget, eventType, event} = context; + const {target, type, nativeEvent} = event; - switch (eventType) { + switch (type) { case 'touchstart': case 'mousedown': case 'pointerdown': { if (!state.isDragging) { if (props.onShouldClaimOwnership) { - context.releaseOwnership(state.dragTarget); + context.releaseOwnership(); } const obj = - eventType === 'touchstart' ? (event: any).changedTouches[0] : event; + type === 'touchstart' + ? (nativeEvent: any).changedTouches[0] + : nativeEvent; const x = (state.startX = (obj: any).screenX); const y = (state.startY = (obj: any).screenY); state.x = x; state.y = y; - state.dragTarget = eventTarget; + state.dragTarget = target; state.isPointerDown = true; if (props.onDragStart) { @@ -121,19 +124,21 @@ const DragResponder = { ); } - context.addRootEventTypes(rootEventTypes); + context.addRootEventTypes(target.ownerDocument, rootEventTypes); } break; } case 'touchmove': case 'mousemove': case 'pointermove': { - if (context.isPassive()) { + if (event.passive) { return; } if (state.isPointerDown) { const obj = - eventType === 'touchmove' ? (event: any).changedTouches[0] : event; + type === 'touchmove' + ? (nativeEvent: any).changedTouches[0] + : nativeEvent; const x = (obj: any).screenX; const y = (obj: any).screenY; state.x = x; @@ -145,7 +150,7 @@ const DragResponder = { props.onShouldClaimOwnership && props.onShouldClaimOwnership() ) { - shouldEnableDragging = context.requestOwnership(state.dragTarget); + shouldEnableDragging = context.requestOwnership(); } if (shouldEnableDragging) { state.isDragging = true; @@ -181,7 +186,7 @@ const DragResponder = { eventData, ); } - (event: any).preventDefault(); + (nativeEvent: any).preventDefault(); } } break; @@ -193,7 +198,7 @@ const DragResponder = { case 'pointerup': { if (state.isDragging) { if (props.onShouldClaimOwnership) { - context.releaseOwnership(state.dragTarget); + context.releaseOwnership(); } if (props.onDragEnd) { dispatchDragEvent(context, 'dragend', props.onDragEnd, state, true); diff --git a/packages/react-events/src/Focus.js b/packages/react-events/src/Focus.js index e56379f8871dd..a710fbe3bc942 100644 --- a/packages/react-events/src/Focus.js +++ b/packages/react-events/src/Focus.js @@ -7,83 +7,119 @@ * @flow */ -import type {EventResponderContext} from 'events/EventTypes'; +import type { + ReactResponderEvent, + ReactResponderContext, +} from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; -const targetEventTypes = [ - {name: 'focus', passive: true, capture: true}, - {name: 'blur', passive: true, capture: true}, -]; +type FocusProps = { + disabled: boolean, + onBlur: (e: FocusEvent) => void, + onFocus: (e: FocusEvent) => void, + onFocusChange: boolean => void, +}; type FocusState = { isFocused: boolean, + focusTarget: null | Element | Document, }; type FocusEventType = 'focus' | 'blur' | 'focuschange'; type FocusEvent = {| - listener: FocusEvent => void, target: Element | Document, type: FocusEventType, |}; +const targetEventTypes = [ + {name: 'focus', passive: true, capture: true}, + {name: 'blur', passive: true, capture: true}, +]; + function createFocusEvent( type: FocusEventType, target: Element | Document, - listener: FocusEvent => void, ): FocusEvent { return { - listener, target, type, }; } -function dispatchFocusInEvents(context: EventResponderContext, props: Object) { - const {event, eventTarget} = context; - if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { - return; +function dispatchFocusInEvents( + event: null | ReactResponderEvent, + context: ReactResponderContext, + props: FocusProps, + state: FocusState, +) { + if (event != null) { + const {nativeEvent} = event; + if ( + context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget) + ) { + return; + } } if (props.onFocus) { const syntheticEvent = createFocusEvent( 'focus', - eventTarget, - props.onFocus, + ((state.focusTarget: any): Element | Document), ); - context.dispatchEvent(syntheticEvent, {discrete: true}); + context.dispatchEvent(syntheticEvent, props.onFocus, {discrete: true}); } if (props.onFocusChange) { - const focusChangeEventListener = () => { + const listener = () => { props.onFocusChange(true); }; const syntheticEvent = createFocusEvent( 'focuschange', - eventTarget, - focusChangeEventListener, + ((state.focusTarget: any): Element | Document), ); - context.dispatchEvent(syntheticEvent, {discrete: true}); + context.dispatchEvent(syntheticEvent, listener, {discrete: true}); } } -function dispatchFocusOutEvents(context: EventResponderContext, props: Object) { - const {event, eventTarget} = context; - if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { - return; +function dispatchFocusOutEvents( + event: null | ReactResponderEvent, + context: ReactResponderContext, + props: FocusProps, + state: FocusState, +) { + if (event != null) { + const {nativeEvent} = event; + if ( + context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget) + ) { + return; + } } if (props.onBlur) { - const syntheticEvent = createFocusEvent('blur', eventTarget, props.onBlur); - context.dispatchEvent(syntheticEvent, {discrete: true}); + const syntheticEvent = createFocusEvent( + 'blur', + ((state.focusTarget: any): Element | Document), + ); + context.dispatchEvent(syntheticEvent, props.onBlur, {discrete: true}); } if (props.onFocusChange) { - const focusChangeEventListener = () => { + const listener = () => { props.onFocusChange(false); }; const syntheticEvent = createFocusEvent( 'focuschange', - eventTarget, - focusChangeEventListener, + ((state.focusTarget: any): Element | Document), ); - context.dispatchEvent(syntheticEvent, {discrete: true}); + context.dispatchEvent(syntheticEvent, listener, {discrete: true}); + } +} + +function unmountResponder( + context: ReactResponderContext, + props: FocusProps, + state: FocusState, +): void { + if (state.isFocused) { + dispatchFocusOutEvents(null, context, props, state); } } @@ -92,32 +128,50 @@ const FocusResponder = { createInitialState(): FocusState { return { isFocused: false, + focusTarget: null, }; }, - handleEvent( - context: EventResponderContext, + onEvent( + event: ReactResponderEvent, + context: ReactResponderContext, props: Object, state: FocusState, ): void { - const {eventTarget, eventType} = context; + const {type, target} = event; - switch (eventType) { + switch (type) { case 'focus': { - if (!state.isFocused && !context.isTargetOwned(eventTarget)) { - dispatchFocusInEvents(context, props); + if (!state.isFocused && !context.hasOwnership()) { + state.focusTarget = target; + dispatchFocusInEvents(event, context, props, state); state.isFocused = true; } break; } case 'blur': { if (state.isFocused) { - dispatchFocusOutEvents(context, props); + dispatchFocusOutEvents(event, context, props, state); state.isFocused = false; + state.focusTarget = null; } break; } } }, + onUnmount( + context: ReactResponderContext, + props: FocusProps, + state: FocusState, + ) { + unmountResponder(context, props, state); + }, + onOwnershipChange( + context: ReactResponderContext, + props: FocusProps, + state: FocusState, + ) { + unmountResponder(context, props, state); + }, }; export default { diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index 226430b838d09..dbb90f45380f1 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -7,101 +7,204 @@ * @flow */ -import type {EventResponderContext} from 'events/EventTypes'; +import type { + ReactResponderEvent, + ReactResponderContext, +} from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; -const targetEventTypes = [ - 'pointerover', - 'pointermove', - 'pointerout', - 'pointercancel', -]; +type HoverProps = { + disabled: boolean, + delayHoverEnd: number, + delayHoverStart: number, + onHoverChange: boolean => void, + onHoverEnd: (e: HoverEvent) => void, + onHoverMove: (e: HoverEvent) => void, + onHoverStart: (e: HoverEvent) => void, +}; type HoverState = { + hoverTarget: null | Element | Document, + isActiveHovered: boolean, isHovered: boolean, isInHitSlop: boolean, isTouched: boolean, + hoverStartTimeout: null | Symbol, + hoverEndTimeout: null | Symbol, + skipMouseAfterPointer: boolean, }; -type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange'; +type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange' | 'hovermove'; type HoverEvent = {| - listener: HoverEvent => void, target: Element | Document, type: HoverEventType, |}; +const DEFAULT_HOVER_END_DELAY_MS = 0; +const DEFAULT_HOVER_START_DELAY_MS = 0; + +const targetEventTypes = [ + 'pointerover', + 'pointermove', + 'pointerout', + 'pointercancel', +]; + +// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events. +if (typeof window !== 'undefined' && window.PointerEvent === undefined) { + targetEventTypes.push('touchstart', 'mouseover', 'mousemove', 'mouseout'); +} + function createHoverEvent( type: HoverEventType, target: Element | Document, - listener: HoverEvent => void, ): HoverEvent { return { - listener, target, type, }; } -// In the case we don't have PointerEvents (Safari), we listen to touch events -// too -if (typeof window !== 'undefined' && window.PointerEvent === undefined) { - targetEventTypes.push('touchstart', 'mouseover', 'mouseout'); +function dispatchHoverChangeEvent( + context: ReactResponderContext, + props: HoverProps, + state: HoverState, +): void { + const bool = state.isActiveHovered; + const listener = () => { + props.onHoverChange(bool); + }; + const syntheticEvent = createHoverEvent( + 'hoverchange', + ((state.hoverTarget: any): Element | Document), + ); + context.dispatchEvent(syntheticEvent, listener, {discrete: true}); } function dispatchHoverStartEvents( - context: EventResponderContext, - props: Object, + event: ReactResponderEvent, + context: ReactResponderContext, + props: HoverProps, state: HoverState, ): void { - const {event, eventTarget} = context; - if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { - return; + const target = state.hoverTarget; + if (event !== null) { + const {nativeEvent} = event; + if ( + context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget) + ) { + return; + } } - if (props.onHoverStart) { - const syntheticEvent = createHoverEvent( - 'hoverstart', - eventTarget, - props.onHoverStart, - ); - context.dispatchEvent(syntheticEvent, {discrete: true}); + + state.isHovered = true; + + if (state.hoverEndTimeout !== null) { + context.clearTimeout(state.hoverEndTimeout); + state.hoverEndTimeout = null; } - if (props.onHoverChange) { - const hoverChangeEventListener = () => { - props.onHoverChange(true); - }; - const syntheticEvent = createHoverEvent( - 'hoverchange', - eventTarget, - hoverChangeEventListener, + + const activate = () => { + state.isActiveHovered = true; + + if (props.onHoverStart) { + const syntheticEvent = createHoverEvent( + 'hoverstart', + ((target: any): Element | Document), + ); + context.dispatchEvent(syntheticEvent, props.onHoverStart, { + discrete: true, + }); + } + if (props.onHoverChange) { + dispatchHoverChangeEvent(context, props, state); + } + }; + + if (!state.isActiveHovered) { + const delayHoverStart = calculateDelayMS( + props.delayHoverStart, + 0, + DEFAULT_HOVER_START_DELAY_MS, ); - context.dispatchEvent(syntheticEvent, {discrete: true}); + if (delayHoverStart > 0) { + state.hoverStartTimeout = context.setTimeout(() => { + state.hoverStartTimeout = null; + activate(); + }, delayHoverStart); + } else { + activate(); + } } } -function dispatchHoverEndEvents(context: EventResponderContext, props: Object) { - const {event, eventTarget} = context; - if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { - return; +function dispatchHoverEndEvents( + event: null | ReactResponderEvent, + context: ReactResponderContext, + props: HoverProps, + state: HoverState, +) { + const target = state.hoverTarget; + if (event !== null) { + const {nativeEvent} = event; + if ( + context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget) + ) { + return; + } } - if (props.onHoverEnd) { - const syntheticEvent = createHoverEvent( - 'hoverend', - eventTarget, - props.onHoverEnd, - ); - context.dispatchEvent(syntheticEvent, {discrete: true}); + + state.isHovered = false; + + if (state.hoverStartTimeout !== null) { + context.clearTimeout(state.hoverStartTimeout); + state.hoverStartTimeout = null; } - if (props.onHoverChange) { - const hoverChangeEventListener = () => { - props.onHoverChange(false); - }; - const syntheticEvent = createHoverEvent( - 'hoverchange', - eventTarget, - hoverChangeEventListener, + + const deactivate = () => { + state.isActiveHovered = false; + + if (props.onHoverEnd) { + const syntheticEvent = createHoverEvent( + 'hoverend', + ((target: any): Element | Document), + ); + context.dispatchEvent(syntheticEvent, props.onHoverEnd, {discrete: true}); + } + if (props.onHoverChange) { + dispatchHoverChangeEvent(context, props, state); + } + }; + + if (state.isActiveHovered) { + const delayHoverEnd = calculateDelayMS( + props.delayHoverEnd, + 0, + DEFAULT_HOVER_END_DELAY_MS, ); - context.dispatchEvent(syntheticEvent, {discrete: true}); + if (delayHoverEnd > 0) { + state.hoverEndTimeout = context.setTimeout(() => { + deactivate(); + }, delayHoverEnd); + } else { + deactivate(); + } + } +} + +function calculateDelayMS(delay: ?number, min = 0, fallback = 0) { + const maybeNumber = delay == null ? null : delay; + return Math.max(min, maybeNumber != null ? maybeNumber : fallback); +} + +function unmountResponder( + context: ReactResponderContext, + props: HoverProps, + state: HoverState, +): void { + if (state.isHovered) { + dispatchHoverEndEvents(null, context, props, state); } } @@ -109,97 +212,139 @@ const HoverResponder = { targetEventTypes, createInitialState() { return { + isActiveHovered: false, isHovered: false, isInHitSlop: false, isTouched: false, + hoverStartTimeout: null, + hoverEndTimeout: null, + skipMouseAfterPointer: false, }; }, - handleEvent( - context: EventResponderContext, - props: Object, + onEvent( + event: ReactResponderEvent, + context: ReactResponderContext, + props: HoverProps, state: HoverState, ): void { - const {eventType, eventTarget, event} = context; + const {type, target, nativeEvent} = event; - switch (eventType) { - case 'touchstart': - // Touch devices don't have hover support + switch (type) { + /** + * Prevent hover events when touch is being used. + */ + case 'touchstart': { if (!state.isTouched) { state.isTouched = true; } break; + } + case 'pointerover': case 'mouseover': { - if ( - !state.isHovered && - !state.isTouched && - !context.isTargetOwned(eventTarget) - ) { - if ((event: any).pointerType === 'touch') { + if (!state.isHovered && !state.isTouched && !context.hasOwnership()) { + if ((nativeEvent: any).pointerType === 'touch') { state.isTouched = true; return; } + if (type === 'pointerover') { + state.skipMouseAfterPointer = true; + } if ( context.isPositionWithinTouchHitTarget( - (event: any).x, - (event: any).y, + target.ownerDocument, + (nativeEvent: any).x, + (nativeEvent: any).y, ) ) { state.isInHitSlop = true; return; } - dispatchHoverStartEvents(context, props, state); - state.isHovered = true; + state.hoverTarget = target; + dispatchHoverStartEvents(event, context, props, state); } break; } case 'pointerout': case 'mouseout': { if (state.isHovered && !state.isTouched) { - dispatchHoverEndEvents(context, props); - state.isHovered = false; + dispatchHoverEndEvents(event, context, props, state); } state.isInHitSlop = false; + state.hoverTarget = null; state.isTouched = false; + state.skipMouseAfterPointer = false; break; } - case 'pointermove': { - if (!state.isTouched) { + + case 'pointermove': + case 'mousemove': { + if (type === 'mousemove' && state.skipMouseAfterPointer === true) { + return; + } + + if (state.isHovered && !state.isTouched) { if (state.isInHitSlop) { if ( !context.isPositionWithinTouchHitTarget( - (event: any).x, - (event: any).y, + target.ownerDocument, + (nativeEvent: any).x, + (nativeEvent: any).y, ) ) { - dispatchHoverStartEvents(context, props, state); - state.isHovered = true; + dispatchHoverStartEvents(event, context, props, state); state.isInHitSlop = false; } - } else if ( - state.isHovered && - context.isPositionWithinTouchHitTarget( - (event: any).x, - (event: any).y, - ) - ) { - dispatchHoverEndEvents(context, props); - state.isHovered = false; - state.isInHitSlop = true; + } else if (state.isHovered) { + if ( + context.isPositionWithinTouchHitTarget( + target.ownerDocument, + (nativeEvent: any).x, + (nativeEvent: any).y, + ) + ) { + dispatchHoverEndEvents(event, context, props, state); + state.isInHitSlop = true; + } else { + if (props.onHoverMove) { + const syntheticEvent = createHoverEvent( + 'hovermove', + event.target, + ); + context.dispatchEvent(syntheticEvent, props.onHoverMove, { + discrete: false, + }); + } + } } } break; } + case 'pointercancel': { if (state.isHovered && !state.isTouched) { - dispatchHoverEndEvents(context, props); - state.isHovered = false; + dispatchHoverEndEvents(event, context, props, state); + state.hoverTarget = null; state.isTouched = false; } break; } } }, + onUnmount( + context: ReactResponderContext, + props: HoverProps, + state: HoverState, + ) { + unmountResponder(context, props, state); + }, + onOwnershipChange( + context: ReactResponderContext, + props: HoverProps, + state: HoverState, + ) { + unmountResponder(context, props, state); + }, }; export default { diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index b79c38758b9a8..f41638f02bb7b 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -7,30 +7,13 @@ * @flow */ -import type {EventResponderContext} from 'events/EventTypes'; +import type { + ReactResponderEvent, + ReactResponderContext, + ReactResponderDispatchEventOptions, +} from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; -// const DEFAULT_PRESS_DELAY_MS = 0; -// const DEFAULT_PRESS_END_DELAY_MS = 0; -// const DEFAULT_PRESS_START_DELAY_MS = 0; -const DEFAULT_LONG_PRESS_DELAY_MS = 1000; - -const targetEventTypes = [ - {name: 'click', passive: false}, - {name: 'keydown', passive: false}, - 'pointerdown', - 'pointercancel', - 'contextmenu', -]; -const rootEventTypes = [{name: 'pointerup', passive: false}, 'scroll']; - -// In the case we don't have PointerEvents (Safari), we listen to touch events -// too -if (typeof window !== 'undefined' && window.PointerEvent === undefined) { - targetEventTypes.push('touchstart', 'touchend', 'mousedown', 'touchcancel'); - rootEventTypes.push({name: 'mouseup', passive: false}); -} - type PressProps = { disabled: boolean, delayLongPress: number, @@ -42,22 +25,45 @@ type PressProps = { onPress: (e: PressEvent) => void, onPressChange: boolean => void, onPressEnd: (e: PressEvent) => void, + onPressMove: (e: PressEvent) => void, onPressStart: (e: PressEvent) => void, - pressRententionOffset: Object, + pressRetentionOffset: { + top: number, + right: number, + bottom: number, + left: number, + }, + preventDefault: boolean, + stopPropagation: boolean, }; +type PointerType = '' | 'mouse' | 'keyboard' | 'pen' | 'touch'; + type PressState = { - defaultPrevented: boolean, + didDispatchEvent: boolean, + isActivePressed: boolean, + isActivePressStart: boolean, isAnchorTouched: boolean, isLongPressed: boolean, isPressed: boolean, - longPressTimeout: null | TimeoutID, + isPressWithinResponderRegion: boolean, + longPressTimeout: null | Symbol, + pointerType: PointerType, pressTarget: null | Element | Document, + pressEndTimeout: null | Symbol, + pressStartTimeout: null | Symbol, + responderRegion: null | $ReadOnly<{| + bottom: number, + left: number, + right: number, + top: number, + |}>, shouldSkipMouseAfterTouch: boolean, }; type PressEventType = | 'press' + | 'pressmove' | 'pressstart' | 'pressend' | 'presschange' @@ -65,126 +71,237 @@ type PressEventType = | 'longpresschange'; type PressEvent = {| - listener: PressEvent => void, target: Element | Document, type: PressEventType, + pointerType: PointerType, |}; +const DEFAULT_PRESS_END_DELAY_MS = 0; +const DEFAULT_PRESS_START_DELAY_MS = 0; +const DEFAULT_LONG_PRESS_DELAY_MS = 500; +const DEFAULT_PRESS_RETENTION_OFFSET = { + bottom: 20, + top: 20, + left: 20, + right: 20, +}; + +const targetEventTypes = [ + {name: 'click', passive: false}, + {name: 'keydown', passive: false}, + {name: 'keypress', passive: false}, + {name: 'contextmenu', passive: false}, + 'pointerdown', + 'pointercancel', +]; +const rootEventTypes = [ + {name: 'keyup', passive: false}, + {name: 'pointerup', passive: false}, + 'pointermove', + 'scroll', +]; + +// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events. +if (typeof window !== 'undefined' && window.PointerEvent === undefined) { + targetEventTypes.push('touchstart', 'touchend', 'touchcancel', 'mousedown'); + rootEventTypes.push( + {name: 'mouseup', passive: false}, + 'touchmove', + 'mousemove', + ); +} + function createPressEvent( type: PressEventType, target: Element | Document, - listener: PressEvent => void, + pointerType: PointerType, ): PressEvent { return { - listener, target, type, + pointerType, }; } -function dispatchPressEvent( - context: EventResponderContext, +function dispatchEvent( + context: ReactResponderContext, state: PressState, name: PressEventType, listener: (e: Object) => void, + options?: ReactResponderDispatchEventOptions, ): void { const target = ((state.pressTarget: any): Element | Document); - const syntheticEvent = createPressEvent(name, target, listener); - context.dispatchEvent(syntheticEvent, {discrete: true}); + const pointerType = state.pointerType; + const syntheticEvent = createPressEvent(name, target, pointerType); + context.dispatchEvent( + syntheticEvent, + listener, + options || { + discrete: true, + }, + ); + state.didDispatchEvent = true; } -function dispatchPressStartEvents( - context: EventResponderContext, +function dispatchPressChangeEvent( + context: ReactResponderContext, props: PressProps, state: PressState, ): void { - function dispatchPressChangeEvent(bool) { - const pressChangeEventListener = () => { - props.onPressChange(bool); - }; - dispatchPressEvent(context, state, 'presschange', pressChangeEventListener); - } + const bool = state.isActivePressed; + const listener = () => { + props.onPressChange(bool); + }; + dispatchEvent(context, state, 'presschange', listener); +} + +function dispatchLongPressChangeEvent( + context: ReactResponderContext, + props: PressProps, + state: PressState, +): void { + const bool = state.isLongPressed; + const listener = () => { + props.onLongPressChange(bool); + }; + dispatchEvent(context, state, 'longpresschange', listener); +} + +function activate(context, props, state) { + const wasActivePressed = state.isActivePressed; + state.isActivePressed = true; if (props.onPressStart) { - dispatchPressEvent(context, state, 'pressstart', props.onPressStart); + dispatchEvent(context, state, 'pressstart', props.onPressStart); + } + if (!wasActivePressed && props.onPressChange) { + dispatchPressChangeEvent(context, props, state); + } +} + +function deactivate(context, props, state) { + const wasLongPressed = state.isLongPressed; + state.isActivePressed = false; + state.isLongPressed = false; + + if (props.onPressEnd) { + dispatchEvent(context, state, 'pressend', props.onPressEnd); } if (props.onPressChange) { - dispatchPressChangeEvent(true); + dispatchPressChangeEvent(context, props, state); } - if ((props.onLongPress || props.onLongPressChange) && !state.isLongPressed) { - const delayLongPress = calculateDelayMS( - props.delayLongPress, - 10, - DEFAULT_LONG_PRESS_DELAY_MS, - ); + if (wasLongPressed && props.onLongPressChange) { + dispatchLongPressChangeEvent(context, props, state); + } +} - state.longPressTimeout = setTimeout( - () => - context.withAsyncDispatching(() => { - state.isLongPressed = true; - state.longPressTimeout = null; - - if (props.onLongPress) { - const longPressEventListener = e => { - props.onLongPress(e); - // TODO address this again at some point - // if (e.nativeEvent.defaultPrevented) { - // state.defaultPrevented = true; - // } - }; - dispatchPressEvent( - context, - state, - 'longpress', - longPressEventListener, - ); - } +function dispatchPressStartEvents( + context: ReactResponderContext, + props: PressProps, + state: PressState, +): void { + state.isPressed = true; - if (props.onLongPressChange) { - const longPressChangeEventListener = () => { - props.onLongPressChange(true); - }; - dispatchPressEvent( - context, - state, - 'longpresschange', - longPressChangeEventListener, - ); + if (state.pressEndTimeout !== null) { + context.clearTimeout(state.pressEndTimeout); + state.pressEndTimeout = null; + } + + const dispatch = () => { + state.isActivePressStart = true; + activate(context, props, state); + + if ( + (props.onLongPress || props.onLongPressChange) && + !state.isLongPressed + ) { + const delayLongPress = calculateDelayMS( + props.delayLongPress, + 10, + DEFAULT_LONG_PRESS_DELAY_MS, + ); + state.longPressTimeout = context.setTimeout(() => { + state.isLongPressed = true; + state.longPressTimeout = null; + if (props.onLongPress) { + dispatchEvent(context, state, 'longpress', props.onLongPress); + } + if (props.onLongPressChange) { + dispatchLongPressChangeEvent(context, props, state); + } + if (state.didDispatchEvent) { + const shouldStopPropagation = + props.stopPropagation === undefined ? true : props.stopPropagation; + if (shouldStopPropagation) { + context.dispatchStopPropagation(); } - }), - delayLongPress, + state.didDispatchEvent = false; + } + }, delayLongPress); + } + }; + + if (!state.isActivePressStart) { + const delayPressStart = calculateDelayMS( + props.delayPressStart, + 0, + DEFAULT_PRESS_START_DELAY_MS, ); + if (delayPressStart > 0) { + state.pressStartTimeout = context.setTimeout(() => { + state.pressStartTimeout = null; + dispatch(); + }, delayPressStart); + } else { + dispatch(); + } } } function dispatchPressEndEvents( - context: EventResponderContext, + context: ReactResponderContext, props: PressProps, state: PressState, ): void { + const wasActivePressStart = state.isActivePressStart; + let activationWasForced = false; + + state.isActivePressStart = false; + state.isPressed = false; + if (state.longPressTimeout !== null) { - clearTimeout(state.longPressTimeout); + context.clearTimeout(state.longPressTimeout); state.longPressTimeout = null; } - if (props.onPressEnd) { - dispatchPressEvent(context, state, 'pressend', props.onPressEnd); - } - if (props.onPressChange) { - const pressChangeEventListener = () => { - props.onPressChange(false); - }; - dispatchPressEvent(context, state, 'presschange', pressChangeEventListener); + + if (!wasActivePressStart && state.pressStartTimeout !== null) { + context.clearTimeout(state.pressStartTimeout); + state.pressStartTimeout = null; + // don't activate if a press has moved beyond the responder region + if (state.isPressWithinResponderRegion) { + // if we haven't yet activated (due to delays), activate now + activate(context, props, state); + activationWasForced = true; + } } - if (props.onLongPressChange && state.isLongPressed) { - const longPressChangeEventListener = () => { - props.onLongPressChange(false); - }; - dispatchPressEvent( - context, - state, - 'longpresschange', - longPressChangeEventListener, + + if (state.isActivePressed) { + const delayPressEnd = calculateDelayMS( + props.delayPressEnd, + // if activation and deactivation occur during the same event there's no + // time for visual user feedback therefore a small delay is added before + // deactivating. + activationWasForced ? 10 : 0, + DEFAULT_PRESS_END_DELAY_MS, ); + if (delayPressEnd > 0) { + state.pressEndTimeout = context.setTimeout(() => { + state.pressEndTimeout = null; + deactivate(context, props, state); + }, delayPressEnd); + } else { + deactivate(context, props, state); + } } } @@ -202,36 +319,222 @@ function calculateDelayMS(delay: ?number, min = 0, fallback = 0) { return Math.max(min, maybeNumber != null ? maybeNumber : fallback); } +// TODO: account for touch hit slop +function calculateResponderRegion(target, props) { + const pressRetentionOffset = { + ...DEFAULT_PRESS_RETENTION_OFFSET, + ...props.pressRetentionOffset, + }; + + const clientRect = target.getBoundingClientRect(); + + let bottom = clientRect.bottom; + let left = clientRect.left; + let right = clientRect.right; + let top = clientRect.top; + + if (pressRetentionOffset) { + if (pressRetentionOffset.bottom != null) { + bottom += pressRetentionOffset.bottom; + } + if (pressRetentionOffset.left != null) { + left -= pressRetentionOffset.left; + } + if (pressRetentionOffset.right != null) { + right += pressRetentionOffset.right; + } + if (pressRetentionOffset.top != null) { + top -= pressRetentionOffset.top; + } + } + + return { + bottom, + top, + left, + right, + }; +} + +function getPointerType(nativeEvent: any) { + const {type, pointerType} = nativeEvent; + if (pointerType != null) { + return pointerType; + } + if (type.indexOf('mouse') > -1) { + return 'mouse'; + } + if (type.indexOf('touch') > -1) { + return 'touch'; + } + if (type.indexOf('key') > -1) { + return 'keyboard'; + } + return ''; +} + +function isPressWithinResponderRegion( + nativeEvent: $PropertyType, + state: PressState, +): boolean { + const {responderRegion} = state; + const event = (nativeEvent: any); + + return ( + responderRegion != null && + (event.pageX >= responderRegion.left && + event.pageX <= responderRegion.right && + event.pageY >= responderRegion.top && + event.pageY <= responderRegion.bottom) + ); +} + +function unmountResponder( + context: ReactResponderContext, + props: PressProps, + state: PressState, +): void { + if (state.isPressed) { + dispatchPressEndEvents(context, props, state); + context.removeRootEventTypes(rootEventTypes); + } +} + const PressResponder = { targetEventTypes, createInitialState(): PressState { return { - defaultPrevented: false, + didDispatchEvent: false, + isActivePressed: false, + isActivePressStart: false, isAnchorTouched: false, isLongPressed: false, isPressed: false, + isPressWithinResponderRegion: true, longPressTimeout: null, + pointerType: '', + pressEndTimeout: null, + pressStartTimeout: null, pressTarget: null, + responderRegion: null, shouldSkipMouseAfterTouch: false, }; }, - handleEvent( - context: EventResponderContext, + onEvent( + event: ReactResponderEvent, + context: ReactResponderContext, props: PressProps, state: PressState, ): void { - const {eventTarget, eventType, event} = context; + const {target, type, nativeEvent} = event; - switch (eventType) { - case 'keydown': { + switch (type) { + /** + * Respond to pointer events and fall back to mouse. + */ + case 'pointerdown': + case 'mousedown': { if ( - !props.onPress || - context.isTargetOwned(eventTarget) || - !isValidKeyPress((event: any).key) + !state.isPressed && + !context.hasOwnership() && + !state.shouldSkipMouseAfterTouch ) { - return; + const pointerType = getPointerType(nativeEvent); + state.pointerType = pointerType; + + if (pointerType === 'mouse' || type === 'mousedown') { + if ( + // Ignore right- and middle-clicks + nativeEvent.button === 1 || + nativeEvent.button === 2 || + // Ignore pressing on hit slop area with mouse + context.isPositionWithinTouchHitTarget( + target.ownerDocument, + (nativeEvent: any).x, + (nativeEvent: any).y, + ) + ) { + return; + } + } + state.pressTarget = target; + state.isPressWithinResponderRegion = true; + dispatchPressStartEvents(context, props, state); + context.addRootEventTypes(target.ownerDocument, rootEventTypes); } - dispatchPressEvent(context, state, 'press', props.onPress); + break; + } + case 'pointermove': + case 'mousemove': + case 'touchmove': { + if (state.isPressed) { + if (state.shouldSkipMouseAfterTouch) { + return; + } + + const pointerType = getPointerType(nativeEvent); + state.pointerType = pointerType; + + if (state.responderRegion == null) { + let currentTarget = (target: any); + while ( + currentTarget.parentNode && + context.isTargetWithinEventComponent(currentTarget.parentNode) + ) { + currentTarget = currentTarget.parentNode; + } + state.responderRegion = calculateResponderRegion( + currentTarget, + props, + ); + } + + if (isPressWithinResponderRegion(nativeEvent, state)) { + state.isPressWithinResponderRegion = true; + if (props.onPressMove) { + dispatchEvent(context, state, 'pressmove', props.onPressMove, { + discrete: false, + }); + } + } else { + state.isPressWithinResponderRegion = false; + dispatchPressEndEvents(context, props, state); + } + } + break; + } + case 'pointerup': + case 'mouseup': { + if (state.isPressed) { + if (state.shouldSkipMouseAfterTouch) { + state.shouldSkipMouseAfterTouch = false; + return; + } + + const pointerType = getPointerType(nativeEvent); + state.pointerType = pointerType; + + const wasLongPressed = state.isLongPressed; + + dispatchPressEndEvents(context, props, state); + + if (state.pressTarget !== null && props.onPress) { + if (context.isTargetWithinElement(target, state.pressTarget)) { + if ( + !( + wasLongPressed && + props.onLongPressShouldCancelPress && + props.onLongPressShouldCancelPress() + ) + ) { + dispatchEvent(context, state, 'press', props.onPress); + } + } + } + context.removeRootEventTypes(rootEventTypes); + } + state.isAnchorTouched = false; + state.shouldSkipMouseAfterTouch = false; break; } @@ -239,56 +542,59 @@ const PressResponder = { * Touch event implementations are only needed for Safari, which lacks * support for pointer events. */ - case 'touchstart': - if (!state.isPressed && !context.isTargetOwned(eventTarget)) { + case 'touchstart': { + if (!state.isPressed && !context.hasOwnership()) { // We bail out of polyfilling anchor tags, given the same heuristics // explained above in regards to needing to use click events. - if (isAnchorTagElement(eventTarget)) { + if (isAnchorTagElement(target)) { state.isAnchorTouched = true; return; } - state.pressTarget = eventTarget; + const pointerType = getPointerType(nativeEvent); + state.pointerType = pointerType; + state.pressTarget = target; + state.isPressWithinResponderRegion = true; dispatchPressStartEvents(context, props, state); - state.isPressed = true; - context.addRootEventTypes(rootEventTypes); + context.addRootEventTypes(target.ownerDocument, rootEventTypes); } - break; + } case 'touchend': { if (state.isAnchorTouched) { + state.isAnchorTouched = false; return; } if (state.isPressed) { + const pointerType = getPointerType(nativeEvent); + state.pointerType = pointerType; + + const wasLongPressed = state.isLongPressed; + dispatchPressEndEvents(context, props, state); - if ( - eventType !== 'touchcancel' && - (props.onPress || props.onLongPress) - ) { + + if (type !== 'touchcancel' && props.onPress) { // Find if the X/Y of the end touch is still that of the original target - const changedTouch = (event: any).changedTouches[0]; - const doc = (eventTarget: any).ownerDocument; - const target = doc.elementFromPoint( + const changedTouch = (nativeEvent: any).changedTouches[0]; + const doc = (target: any).ownerDocument; + const fromTarget = doc.elementFromPoint( changedTouch.screenX, changedTouch.screenY, ); if ( - target !== null && - context.isTargetWithinEventComponent(target) + fromTarget !== null && + context.isTargetWithinEventComponent(fromTarget) ) { if ( - props.onPress && !( - state.isLongPressed && + wasLongPressed && props.onLongPressShouldCancelPress && props.onLongPressShouldCancelPress() ) ) { - dispatchPressEvent(context, state, 'press', props.onPress); + dispatchEvent(context, state, 'press', props.onPress); } } } - state.isPressed = false; - state.isLongPressed = false; state.shouldSkipMouseAfterTouch = true; context.removeRootEventTypes(rootEventTypes); } @@ -296,101 +602,110 @@ const PressResponder = { } /** - * Respond to pointer events and fall back to mouse. + * Keyboard interaction support + * TODO: determine UX for metaKey + validKeyPress interactions */ - case 'pointerdown': - case 'mousedown': { + case 'keydown': + case 'keypress': { if ( - !state.isPressed && - !context.isTargetOwned(eventTarget) && - !state.shouldSkipMouseAfterTouch + !context.hasOwnership() && + isValidKeyPress((nativeEvent: any).key) ) { - if ( - (event: any).pointerType === 'mouse' || - eventType === 'mousedown' - ) { - // Ignore if we are pressing on hit slop area with mouse - if ( - context.isPositionWithinTouchHitTarget( - (event: any).x, - (event: any).y, - ) - ) { - return; - } - // Ignore middle- and right-clicks - if (event.button === 2 || event.button === 1) { - return; + if (state.isPressed) { + // Prevent spacebar press from scrolling the window + if ((nativeEvent: any).key === ' ') { + (nativeEvent: any).preventDefault(); } + } else { + const pointerType = getPointerType(nativeEvent); + state.pointerType = pointerType; + state.pressTarget = target; + dispatchPressStartEvents(context, props, state); + context.addRootEventTypes(target.ownerDocument, rootEventTypes); } - state.pressTarget = eventTarget; - dispatchPressStartEvents(context, props, state); - state.isPressed = true; - context.addRootEventTypes(rootEventTypes); } break; } - case 'pointerup': - case 'mouseup': { - if (state.isPressed) { - if (state.shouldSkipMouseAfterTouch) { - state.shouldSkipMouseAfterTouch = false; - return; - } + case 'keyup': { + if (state.isPressed && isValidKeyPress((nativeEvent: any).key)) { + const wasLongPressed = state.isLongPressed; dispatchPressEndEvents(context, props, state); - if ( - state.pressTarget !== null && - (props.onPress || props.onLongPress) - ) { - if (context.isTargetWithinElement(eventTarget, state.pressTarget)) { - if ( - props.onPress && - !( - state.isLongPressed && - props.onLongPressShouldCancelPress && - props.onLongPressShouldCancelPress() - ) - ) { - const pressEventListener = e => { - props.onPress(e); - // TODO address this again at some point - // if (e.nativeEvent.defaultPrevented) { - // state.defaultPrevented = true; - // } - }; - dispatchPressEvent(context, state, 'press', pressEventListener); - } + if (state.pressTarget !== null && props.onPress) { + if ( + !( + wasLongPressed && + props.onLongPressShouldCancelPress && + props.onLongPressShouldCancelPress() + ) + ) { + dispatchEvent(context, state, 'press', props.onPress); } } - state.isPressed = false; - state.isLongPressed = false; context.removeRootEventTypes(rootEventTypes); } - state.isAnchorTouched = false; break; } + case 'pointercancel': case 'scroll': - case 'touchcancel': - case 'contextmenu': - case 'pointercancel': { + case 'touchcancel': { if (state.isPressed) { state.shouldSkipMouseAfterTouch = false; dispatchPressEndEvents(context, props, state); - state.isPressed = false; - state.isLongPressed = false; context.removeRootEventTypes(rootEventTypes); } break; } + case 'click': { - if (state.defaultPrevented) { - (event: any).preventDefault(); - state.defaultPrevented = false; + if (isAnchorTagElement(target)) { + const {ctrlKey, metaKey, shiftKey} = ((nativeEvent: any): MouseEvent); + // Check "open in new window/tab" and "open context menu" key modifiers + const preventDefault = props.preventDefault; + if (preventDefault !== false && !shiftKey && !metaKey && !ctrlKey) { + (nativeEvent: any).preventDefault(); + } } + break; } + + case 'contextmenu': { + if (state.isPressed) { + if (props.preventDefault !== false) { + (nativeEvent: any).preventDefault(); + } else { + state.shouldSkipMouseAfterTouch = false; + dispatchPressEndEvents(context, props, state); + context.removeRootEventTypes(rootEventTypes); + } + } + break; + } + } + + if (state.didDispatchEvent) { + const shouldStopPropagation = + props.stopPropagation === undefined ? true : props.stopPropagation; + if (shouldStopPropagation) { + context.dispatchStopPropagation(); + } + state.didDispatchEvent = false; } }, + onUnmount( + context: ReactResponderContext, + props: PressProps, + state: PressState, + ) { + unmountResponder(context, props, state); + }, + onOwnershipChange( + context: ReactResponderContext, + props: PressProps, + state: PressState, + ) { + unmountResponder(context, props, state); + }, }; export default { diff --git a/packages/react-events/src/ReactEvents.js b/packages/react-events/src/ReactEvents.js index af5c08b0b8e95..0230b9cdb90ca 100644 --- a/packages/react-events/src/ReactEvents.js +++ b/packages/react-events/src/ReactEvents.js @@ -10,6 +10,8 @@ import { REACT_EVENT_TARGET_TYPE, REACT_EVENT_TARGET_TOUCH_HIT, + REACT_EVENT_FOCUS_TARGET, + REACT_EVENT_PRESS_TARGET, } from 'shared/ReactSymbols'; import type {ReactEventTarget} from 'shared/ReactTypes'; @@ -17,3 +19,13 @@ export const TouchHitTarget: ReactEventTarget = { $$typeof: REACT_EVENT_TARGET_TYPE, type: REACT_EVENT_TARGET_TOUCH_HIT, }; + +export const FocusTarget: ReactEventTarget = { + $$typeof: REACT_EVENT_TARGET_TYPE, + type: REACT_EVENT_FOCUS_TARGET, +}; + +export const PressTarget: ReactEventTarget = { + $$typeof: REACT_EVENT_TARGET_TYPE, + type: REACT_EVENT_PRESS_TARGET, +}; diff --git a/packages/react-events/src/Swipe.js b/packages/react-events/src/Swipe.js index 85df2cca3f10e..eea03c6d22f9a 100644 --- a/packages/react-events/src/Swipe.js +++ b/packages/react-events/src/Swipe.js @@ -7,7 +7,10 @@ * @flow */ -import type {EventResponderContext} from 'events/EventTypes'; +import type { + ReactResponderEvent, + ReactResponderContext, +} from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; const targetEventTypes = ['pointerdown', 'pointercancel']; @@ -30,7 +33,6 @@ type EventData = { type SwipeEventType = 'swipeleft' | 'swiperight' | 'swipeend' | 'swipemove'; type SwipeEvent = {| - listener: SwipeEvent => void, target: Element | Document, type: SwipeEventType, diffX?: number, @@ -40,11 +42,9 @@ type SwipeEvent = {| function createSwipeEvent( type: SwipeEventType, target: Element | Document, - listener: SwipeEvent => void, eventData?: EventData, ): SwipeEvent { return { - listener, target, type, ...eventData, @@ -52,7 +52,7 @@ function createSwipeEvent( } function dispatchSwipeEvent( - context: EventResponderContext, + context: ReactResponderContext, name: SwipeEventType, listener: SwipeEvent => void, state: SwipeState, @@ -60,8 +60,8 @@ function dispatchSwipeEvent( eventData?: EventData, ) { const target = ((state.swipeTarget: any): Element | Document); - const syntheticEvent = createSwipeEvent(name, target, listener, eventData); - context.dispatchEvent(syntheticEvent, {discrete}); + const syntheticEvent = createSwipeEvent(name, target, eventData); + context.dispatchEvent(syntheticEvent, listener, {discrete}); } type SwipeState = { @@ -91,21 +91,22 @@ const SwipeResponder = { y: 0, }; }, - handleEvent( - context: EventResponderContext, + onEvent( + event: ReactResponderEvent, + context: ReactResponderContext, props: Object, state: SwipeState, ): void { - const {eventTarget, eventType, event} = context; + const {target, type, nativeEvent} = event; - switch (eventType) { + switch (type) { case 'touchstart': case 'mousedown': case 'pointerdown': { - if (!state.isSwiping && !context.isTargetOwned(eventTarget)) { - let obj = event; - if (eventType === 'touchstart') { - obj = (event: any).targetTouches[0]; + if (!state.isSwiping && !context.hasOwnership()) { + let obj = nativeEvent; + if (type === 'touchstart') { + obj = (nativeEvent: any).targetTouches[0]; state.touchId = obj.identifier; } const x = (obj: any).screenX; @@ -114,7 +115,7 @@ const SwipeResponder = { let shouldEnableSwiping = true; if (props.onShouldClaimOwnership && props.onShouldClaimOwnership()) { - shouldEnableSwiping = context.requestOwnership(eventTarget); + shouldEnableSwiping = context.requestOwnership(); } if (shouldEnableSwiping) { state.isSwiping = true; @@ -122,8 +123,8 @@ const SwipeResponder = { state.startY = y; state.x = x; state.y = y; - state.swipeTarget = eventTarget; - context.addRootEventTypes(rootEventTypes); + state.swipeTarget = target; + context.addRootEventTypes(target.ownerDocument, rootEventTypes); } else { state.touchId = null; } @@ -133,13 +134,13 @@ const SwipeResponder = { case 'touchmove': case 'mousemove': case 'pointermove': { - if (context.isPassive()) { + if (event.passive) { return; } if (state.isSwiping) { let obj = null; - if (eventType === 'touchmove') { - const targetTouches = (event: any).targetTouches; + if (type === 'touchmove') { + const targetTouches = (nativeEvent: any).targetTouches; for (let i = 0; i < targetTouches.length; i++) { if (state.touchId === targetTouches[i].identifier) { obj = targetTouches[i]; @@ -147,7 +148,7 @@ const SwipeResponder = { } } } else { - obj = event; + obj = nativeEvent; } if (obj === null) { state.isSwiping = false; @@ -178,7 +179,7 @@ const SwipeResponder = { false, eventData, ); - (event: any).preventDefault(); + (nativeEvent: any).preventDefault(); } } break; @@ -193,7 +194,7 @@ const SwipeResponder = { return; } if (props.onShouldClaimOwnership) { - context.releaseOwnership(state.swipeTarget); + context.releaseOwnership(); } const direction = state.direction; const lastDirection = state.lastDirection; diff --git a/packages/react-events/src/__tests__/Focus-test.internal.js b/packages/react-events/src/__tests__/Focus-test.internal.js new file mode 100644 index 0000000000000..0b1287773e7ab --- /dev/null +++ b/packages/react-events/src/__tests__/Focus-test.internal.js @@ -0,0 +1,111 @@ +/** + * 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. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactFeatureFlags; +let ReactDOM; +let Focus; + +const createFocusEvent = type => { + const event = document.createEvent('Event'); + event.initEvent(type, true, true); + return event; +}; + +describe('Focus event responder', () => { + let container; + + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableEventAPI = true; + React = require('react'); + ReactDOM = require('react-dom'); + Focus = require('react-events/focus'); + + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + container = null; + }); + + describe('onBlur', () => { + let onBlur, ref; + + beforeEach(() => { + onBlur = jest.fn(); + ref = React.createRef(); + const element = ( + +
+ + ); + ReactDOM.render(element, container); + }); + + it('is called after "blur" event', () => { + ref.current.dispatchEvent(createFocusEvent('focus')); + ref.current.dispatchEvent(createFocusEvent('blur')); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + }); + + describe('onFocus', () => { + let onFocus, ref; + + beforeEach(() => { + onFocus = jest.fn(); + ref = React.createRef(); + const element = ( + +
+ + ); + ReactDOM.render(element, container); + }); + + it('is called after "focus" event', () => { + ref.current.dispatchEvent(createFocusEvent('focus')); + expect(onFocus).toHaveBeenCalledTimes(1); + }); + }); + + describe('onFocusChange', () => { + let onFocusChange, ref; + + beforeEach(() => { + onFocusChange = jest.fn(); + ref = React.createRef(); + const element = ( + +
+ + ); + ReactDOM.render(element, container); + }); + + it('is called after "blur" and "focus" events', () => { + ref.current.dispatchEvent(createFocusEvent('focus')); + expect(onFocusChange).toHaveBeenCalledTimes(1); + expect(onFocusChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createFocusEvent('blur')); + expect(onFocusChange).toHaveBeenCalledTimes(2); + expect(onFocusChange).toHaveBeenCalledWith(false); + }); + }); + + it('expect displayName to show up for event component', () => { + expect(Focus.displayName).toBe('Focus'); + }); +}); diff --git a/packages/react-events/src/__tests__/Hover-test.internal.js b/packages/react-events/src/__tests__/Hover-test.internal.js index 1250a49917ab3..151631eaa1f97 100644 --- a/packages/react-events/src/__tests__/Hover-test.internal.js +++ b/packages/react-events/src/__tests__/Hover-test.internal.js @@ -14,6 +14,12 @@ let ReactFeatureFlags; let ReactDOM; let Hover; +const createPointerEvent = type => { + const event = document.createEvent('Event'); + event.initEvent(type, true, true); + return event; +}; + describe('Hover event responder', () => { let container; @@ -34,69 +40,328 @@ describe('Hover event responder', () => { container = null; }); - it('should support onHover', () => { - let divRef = React.createRef(); - let events = []; - - function handleOnHover(e) { - if (e) { - events.push('hover in'); - } else { - events.push('hover out'); - } - } - - function Component() { - return ( - -
Hover me!
+ describe('onHoverStart', () => { + let onHoverStart, ref; + + beforeEach(() => { + onHoverStart = jest.fn(); + ref = React.createRef(); + const element = ( + +
); - } + ReactDOM.render(element, container); + }); + + it('is called after "pointerover" event', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); + + it('is not called if "pointerover" pointerType is touch', () => { + const event = createPointerEvent('pointerover'); + event.pointerType = 'touch'; + ref.current.dispatchEvent(event); + expect(onHoverStart).not.toBeCalled(); + }); + + it('ignores browser emulated "mouseover" event', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('mouseover')); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); + + // No PointerEvent fallbacks + it('is called after "mouseover" event', () => { + ref.current.dispatchEvent(createPointerEvent('mouseover')); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); + it('is not called after "touchstart"', () => { + ref.current.dispatchEvent(createPointerEvent('touchstart')); + ref.current.dispatchEvent(createPointerEvent('touchend')); + ref.current.dispatchEvent(createPointerEvent('mouseover')); + expect(onHoverStart).not.toBeCalled(); + }); + + describe('delayHoverStart', () => { + it('can be configured', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + jest.advanceTimersByTime(1999); + expect(onHoverStart).not.toBeCalled(); + jest.advanceTimersByTime(1); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); + + it('is reset if "pointerout" is dispatched during a delay', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); - ReactDOM.render(, container); + ref.current.dispatchEvent(createPointerEvent('pointerover')); + jest.advanceTimersByTime(499); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.advanceTimersByTime(1); + expect(onHoverStart).not.toBeCalled(); + ref.current.dispatchEvent(createPointerEvent('pointerover')); + jest.runAllTimers(); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); - const mouseOverEvent = document.createEvent('Event'); - mouseOverEvent.initEvent('mouseover', true, true); - divRef.current.dispatchEvent(mouseOverEvent); + it('onHoverStart is called synchronously if delay is 0ms', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); - const mouseOutEvent = document.createEvent('Event'); - mouseOutEvent.initEvent('mouseout', true, true); - divRef.current.dispatchEvent(mouseOutEvent); + ref.current.dispatchEvent(createPointerEvent('pointerover')); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); - expect(events).toEqual(['hover in', 'hover out']); + it('onHoverStart is only called once per active hover', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + jest.advanceTimersByTime(500); + expect(onHoverStart).toHaveBeenCalledTimes(1); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.advanceTimersByTime(10); + ref.current.dispatchEvent(createPointerEvent('pointerover')); + jest.runAllTimers(); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); + }); }); - it('should support onHoverStart and onHoverEnd', () => { - let divRef = React.createRef(); - let events = []; + describe('onHoverChange', () => { + let onHoverChange, ref; - function handleOnHoverStart() { - events.push('onHoverStart'); - } + beforeEach(() => { + onHoverChange = jest.fn(); + ref = React.createRef(); + const element = ( + +
+ + ); + ReactDOM.render(element, container); + }); - function handleOnHoverEnd() { - events.push('onHoverEnd'); - } + it('is called after "pointerover" and "pointerout" events', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + expect(onHoverChange).toHaveBeenCalledTimes(1); + expect(onHoverChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + expect(onHoverChange).toHaveBeenCalledTimes(2); + expect(onHoverChange).toHaveBeenCalledWith(false); + }); + + // No PointerEvent fallbacks + it('is called after "mouseover" and "mouseout" events', () => { + ref.current.dispatchEvent(createPointerEvent('mouseover')); + expect(onHoverChange).toHaveBeenCalledTimes(1); + expect(onHoverChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createPointerEvent('mouseout')); + expect(onHoverChange).toHaveBeenCalledTimes(2); + expect(onHoverChange).toHaveBeenCalledWith(false); + }); + }); - function Component() { - return ( - -
Hover me!
+ describe('onHoverEnd', () => { + let onHoverEnd, ref; + + beforeEach(() => { + onHoverEnd = jest.fn(); + ref = React.createRef(); + const element = ( + +
); - } + ReactDOM.render(element, container); + }); + + it('is called after "pointerout" event', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + + it('is not called if "pointerover" pointerType is touch', () => { + const event = createPointerEvent('pointerover'); + event.pointerType = 'touch'; + ref.current.dispatchEvent(event); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + expect(onHoverEnd).not.toBeCalled(); + }); + + it('ignores browser emulated "mouseout" event', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + ref.current.dispatchEvent(createPointerEvent('mouseout')); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); - ReactDOM.render(, container); + it('is called after "pointercancel" event', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointercancel')); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); - const mouseOverEvent = document.createEvent('Event'); - mouseOverEvent.initEvent('mouseover', true, true); - divRef.current.dispatchEvent(mouseOverEvent); + it('is not called again after "pointercancel" event if it follows "pointerout"', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + ref.current.dispatchEvent(createPointerEvent('pointercancel')); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); - const mouseOutEvent = document.createEvent('Event'); - mouseOutEvent.initEvent('mouseout', true, true); - divRef.current.dispatchEvent(mouseOutEvent); + // No PointerEvent fallbacks + it('is called after "mouseout" event', () => { + ref.current.dispatchEvent(createPointerEvent('mouseover')); + ref.current.dispatchEvent(createPointerEvent('mouseout')); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + it('is not called after "touchend"', () => { + ref.current.dispatchEvent(createPointerEvent('touchstart')); + ref.current.dispatchEvent(createPointerEvent('touchend')); + ref.current.dispatchEvent(createPointerEvent('mouseout')); + expect(onHoverEnd).not.toBeCalled(); + }); + + describe('delayHoverEnd', () => { + it('can be configured', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.advanceTimersByTime(1999); + expect(onHoverEnd).not.toBeCalled(); + jest.advanceTimersByTime(1); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + + it('delayHoverEnd is called synchronously if delay is 0ms', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + + it('onHoverEnd is only called once per active hover', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.advanceTimersByTime(499); + ref.current.dispatchEvent(createPointerEvent('pointerover')); + jest.advanceTimersByTime(100); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.runAllTimers(); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + + it('onHoverEnd is not called if "pointerover" is dispatched during a delay', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.advanceTimersByTime(499); + ref.current.dispatchEvent(createPointerEvent('pointerover')); + jest.advanceTimersByTime(1); + expect(onHoverEnd).not.toBeCalled(); + }); + + it('onHoverEnd is not called if there was no active hover', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.runAllTimers(); + expect(onHoverEnd).not.toBeCalled(); + }); + }); + }); + + describe('onHoverMove', () => { + it('is called after "pointermove"', () => { + const onHoverMove = jest.fn(); + const ref = React.createRef(); + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.getBoundingClientRect = () => ({ + top: 50, + left: 50, + bottom: 500, + right: 500, + }); + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent( + createPointerEvent('pointermove', {pointerType: 'mouse'}), + ); + ref.current.dispatchEvent(createPointerEvent('touchmove')); + ref.current.dispatchEvent(createPointerEvent('mousemove')); + expect(onHoverMove).toHaveBeenCalledTimes(1); + expect(onHoverMove).toHaveBeenCalledWith( + expect.objectContaining({type: 'hovermove'}), + ); + }); + }); - expect(events).toEqual(['onHoverStart', 'onHoverEnd']); + it('expect displayName to show up for event component', () => { + expect(Hover.displayName).toBe('Hover'); }); }); diff --git a/packages/react-events/src/__tests__/Press-test.internal.js b/packages/react-events/src/__tests__/Press-test.internal.js index af3b93239e480..f82ecb287ac93 100644 --- a/packages/react-events/src/__tests__/Press-test.internal.js +++ b/packages/react-events/src/__tests__/Press-test.internal.js @@ -14,11 +14,16 @@ let ReactFeatureFlags; let ReactDOM; let Press; -const DEFAULT_LONG_PRESS_DELAY = 1000; +const DEFAULT_LONG_PRESS_DELAY = 500; -const createPointerEvent = type => { - const event = document.createEvent('Event'); - event.initEvent(type, true, true); +const createPointerEvent = (type, data) => { + const event = document.createEvent('CustomEvent'); + event.initCustomEvent(type, true, true); + if (data != null) { + Object.entries(data).forEach(([key, value]) => { + event[key] = value; + }); + } return event; }; @@ -65,28 +70,127 @@ describe('Event responder: Press', () => { }); it('is called after "pointerdown" event', () => { - ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent( + createPointerEvent('pointerdown', {pointerType: 'pen'}), + ); expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressStart).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'pen', type: 'pressstart'}), + ); }); - it('ignores emulated "mousedown" event', () => { + it('ignores browser emulated "mousedown" event', () => { ref.current.dispatchEvent(createPointerEvent('pointerdown')); ref.current.dispatchEvent(createPointerEvent('mousedown')); expect(onPressStart).toHaveBeenCalledTimes(1); }); + it('is called once after "keydown" events for Enter', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressStart).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'keyboard', type: 'pressstart'}), + ); + }); + + it('is called once after "keydown" events for Spacebar', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '})); + ref.current.dispatchEvent(createKeyboardEvent('keypress', {key: ' '})); + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '})); + ref.current.dispatchEvent(createKeyboardEvent('keypress', {key: ' '})); + expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressStart).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'keyboard', type: 'pressstart'}), + ); + }); + + it('is not called after "keydown" for other keys', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'a'})); + expect(onPressStart).not.toBeCalled(); + }); + // No PointerEvent fallbacks it('is called after "mousedown" event', () => { ref.current.dispatchEvent(createPointerEvent('mousedown')); expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressStart).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'mouse', type: 'pressstart'}), + ); }); it('is called after "touchstart" event', () => { ref.current.dispatchEvent(createPointerEvent('touchstart')); expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressStart).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'touch', type: 'pressstart'}), + ); }); - // TODO: complete delayPressStart tests - // describe('delayPressStart', () => {}); + describe('delayPressStart', () => { + it('can be configured', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + jest.advanceTimersByTime(1999); + expect(onPressStart).not.toBeCalled(); + jest.advanceTimersByTime(1); + expect(onPressStart).toHaveBeenCalledTimes(1); + }); + + it('is cut short if the press is released during a delay', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + jest.advanceTimersByTime(499); + expect(onPressStart).toHaveBeenCalledTimes(0); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + expect(onPressStart).toHaveBeenCalledTimes(1); + jest.runAllTimers(); + expect(onPressStart).toHaveBeenCalledTimes(1); + }); + + it('onPressStart is called synchronously if delay is 0ms', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + expect(onPressStart).toHaveBeenCalledTimes(1); + }); + }); + + describe('delayPressEnd', () => { + it('onPressStart called each time a press is initiated', () => { + // This test makes sure that onPressStart is called each time a press + // starts, even if a delayPressEnd is delaying the deactivation of the + // previous press. + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + expect(onPressStart).toHaveBeenCalledTimes(2); + }); + }); }); describe('onPressEnd', () => { @@ -105,15 +209,47 @@ describe('Event responder: Press', () => { it('is called after "pointerup" event', () => { ref.current.dispatchEvent(createPointerEvent('pointerdown')); - ref.current.dispatchEvent(createPointerEvent('pointerup')); + ref.current.dispatchEvent( + createPointerEvent('pointerup', {pointerType: 'pen'}), + ); expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'pen', type: 'pressend'}), + ); }); - it('ignores emulated "mouseup" event', () => { + it('ignores browser emulated "mouseup" event', () => { ref.current.dispatchEvent(createPointerEvent('touchstart')); ref.current.dispatchEvent(createPointerEvent('touchend')); ref.current.dispatchEvent(createPointerEvent('mouseup')); expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'touch', type: 'pressend'}), + ); + }); + + it('is called after "keyup" event for Enter', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: 'Enter'})); + expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'keyboard', type: 'pressend'}), + ); + }); + + it('is called after "keyup" event for Spacebar', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '})); + ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: ' '})); + expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'keyboard', type: 'pressend'}), + ); + }); + + it('is not called after "keyup" event for other keys', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: 'a'})); + expect(onPressEnd).not.toBeCalled(); }); // No PointerEvent fallbacks @@ -121,16 +257,68 @@ describe('Event responder: Press', () => { ref.current.dispatchEvent(createPointerEvent('mousedown')); ref.current.dispatchEvent(createPointerEvent('mouseup')); expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'mouse', type: 'pressend'}), + ); }); - it('is called after "touchend" event', () => { ref.current.dispatchEvent(createPointerEvent('touchstart')); ref.current.dispatchEvent(createPointerEvent('touchend')); expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'touch', type: 'pressend'}), + ); }); - // TODO: complete delayPressStart tests - // describe('delayPressStart', () => {}); + describe('delayPressEnd', () => { + it('can be configured', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + jest.advanceTimersByTime(1999); + expect(onPressEnd).not.toBeCalled(); + jest.advanceTimersByTime(1); + expect(onPressEnd).toHaveBeenCalledTimes(1); + }); + + it('is reset if "pointerdown" is dispatched during a delay', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + jest.advanceTimersByTime(499); + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + jest.advanceTimersByTime(1); + expect(onPressEnd).not.toBeCalled(); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + jest.runAllTimers(); + expect(onPressEnd).toHaveBeenCalledTimes(1); + }); + }); + + it('onPressEnd is called synchronously if delay is 0ms', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + expect(onPressEnd).toHaveBeenCalledTimes(1); + }); }); describe('onPressChange', () => { @@ -155,6 +343,117 @@ describe('Event responder: Press', () => { expect(onPressChange).toHaveBeenCalledTimes(2); expect(onPressChange).toHaveBeenCalledWith(false); }); + + it('is called after valid "keydown" and "keyup" events', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + expect(onPressChange).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: 'Enter'})); + expect(onPressChange).toHaveBeenCalledTimes(2); + expect(onPressChange).toHaveBeenCalledWith(false); + }); + + it('is called after delayed onPressStart', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + jest.advanceTimersByTime(499); + expect(onPressChange).not.toBeCalled(); + jest.advanceTimersByTime(1); + expect(onPressChange).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledWith(true); + }); + + it('is called after delayPressStart is cut short', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + jest.advanceTimersByTime(100); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + jest.advanceTimersByTime(10); + expect(onPressChange).toHaveBeenCalledWith(true); + expect(onPressChange).toHaveBeenCalledWith(false); + expect(onPressChange).toHaveBeenCalledTimes(2); + }); + + it('is called after delayed onPressEnd', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + expect(onPressChange).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + jest.advanceTimersByTime(499); + expect(onPressChange).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(1); + expect(onPressChange).toHaveBeenCalledTimes(2); + expect(onPressChange).toHaveBeenCalledWith(false); + }); + + // No PointerEvent fallbacks + it('is called after "mousedown" and "mouseup" events', () => { + ref.current.dispatchEvent(createPointerEvent('mousedown')); + expect(onPressChange).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createPointerEvent('mouseup')); + expect(onPressChange).toHaveBeenCalledTimes(2); + expect(onPressChange).toHaveBeenCalledWith(false); + }); + it('is called after "touchstart" and "touchend" events', () => { + ref.current.dispatchEvent(createPointerEvent('touchstart')); + expect(onPressChange).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createPointerEvent('touchend')); + expect(onPressChange).toHaveBeenCalledTimes(2); + expect(onPressChange).toHaveBeenCalledWith(false); + }); + + it('is called but does not bubble', () => { + const element = ( + + +
+ + + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + expect(onPressChange).toHaveBeenCalledTimes(1); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + expect(onPressChange).toHaveBeenCalledTimes(2); + }); + + it('is called and bubbles correctly with stopPropagation set to false', () => { + const element = ( + + +
+ + + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + expect(onPressChange).toHaveBeenCalledTimes(2); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + expect(onPressChange).toHaveBeenCalledTimes(4); + }); }); describe('onPress', () => { @@ -172,10 +471,75 @@ describe('Event responder: Press', () => { }); it('is called after "pointerup" event', () => { + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent( + createPointerEvent('pointerup', {pointerType: 'pen'}), + ); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'pen', type: 'press'}), + ); + }); + + it('is called after valid "keyup" event', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: 'Enter'})); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'keyboard', type: 'press'}), + ); + }); + + it('is always called immediately after press is released', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + // No PointerEvent fallbacks + // TODO: jsdom missing APIs + // it('is called after "touchend" event', () => { + // ref.current.dispatchEvent(createPointerEvent('touchstart')); + // ref.current.dispatchEvent(createPointerEvent('touchend')); + // expect(onPress).toHaveBeenCalledTimes(1); + // }); + + it('is called but does not bubble', () => { + const element = ( + + +
+ + + ); + ReactDOM.render(element, container); + ref.current.dispatchEvent(createPointerEvent('pointerdown')); ref.current.dispatchEvent(createPointerEvent('pointerup')); expect(onPress).toHaveBeenCalledTimes(1); }); + + it('is called and bubbles correctly with stopPropagation set to false', () => { + const element = ( + + +
+ + + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + expect(onPress).toHaveBeenCalledTimes(2); + }); }); describe('onLongPress', () => { @@ -192,15 +556,20 @@ describe('Event responder: Press', () => { ReactDOM.render(element, container); }); - it('is called if press lasts default delay', () => { - ref.current.dispatchEvent(createPointerEvent('pointerdown')); + it('is called if "pointerdown" lasts default delay', () => { + ref.current.dispatchEvent( + createPointerEvent('pointerdown', {pointerType: 'pen'}), + ); jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY - 1); expect(onLongPress).not.toBeCalled(); jest.advanceTimersByTime(1); expect(onLongPress).toHaveBeenCalledTimes(1); + expect(onLongPress).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'pen', type: 'longpress'}), + ); }); - it('is not called if press is released before delay', () => { + it('is not called if "pointerup" is dispatched before delay', () => { ref.current.dispatchEvent(createPointerEvent('pointerdown')); jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY - 1); ref.current.dispatchEvent(createPointerEvent('pointerup')); @@ -208,6 +577,57 @@ describe('Event responder: Press', () => { expect(onLongPress).not.toBeCalled(); }); + it('is called if valid "keydown" lasts default delay', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY - 1); + expect(onLongPress).not.toBeCalled(); + jest.advanceTimersByTime(1); + expect(onLongPress).toHaveBeenCalledTimes(1); + expect(onLongPress).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'keyboard', type: 'longpress'}), + ); + }); + + it('is not called if valid "keyup" is dispatched before delay', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY - 1); + ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: 'Enter'})); + jest.advanceTimersByTime(1); + expect(onLongPress).not.toBeCalled(); + }); + + it('is called but does not bubble', () => { + const element = ( + + +
+ + + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + it('is called and bubbles correctly with stopPropagation set to false', () => { + const element = ( + + +
+ + + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + expect(onLongPress).toHaveBeenCalledTimes(2); + }); + describe('delayLongPress', () => { it('can be configured', () => { const element = ( @@ -239,7 +659,6 @@ describe('Event responder: Press', () => { expect(onLongPress).toHaveBeenCalledTimes(1); }); - /* it('compounds with "delayPressStart"', () => { const delayPressStart = 100; const element = ( @@ -250,12 +669,13 @@ describe('Event responder: Press', () => { ReactDOM.render(element, container); ref.current.dispatchEvent(createPointerEvent('pointerdown')); - jest.advanceTimersByTime(delayPressStart + DEFAULT_LONG_PRESS_DELAY - 1); + jest.advanceTimersByTime( + delayPressStart + DEFAULT_LONG_PRESS_DELAY - 1, + ); expect(onLongPress).not.toBeCalled(); jest.advanceTimersByTime(1); expect(onLongPress).toHaveBeenCalledTimes(1); }); - */ }); }); @@ -278,6 +698,28 @@ describe('Event responder: Press', () => { expect(onLongPressChange).toHaveBeenCalledTimes(2); expect(onLongPressChange).toHaveBeenCalledWith(false); }); + + it('is called after delayed onPressEnd', () => { + const onLongPressChange = jest.fn(); + const ref = React.createRef(); + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY); + expect(onLongPressChange).toHaveBeenCalledTimes(1); + expect(onLongPressChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + jest.advanceTimersByTime(499); + expect(onLongPressChange).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(1); + expect(onLongPressChange).toHaveBeenCalledTimes(2); + expect(onLongPressChange).toHaveBeenCalledWith(false); + }); }); describe('onLongPressShouldCancelPress', () => { @@ -306,40 +748,340 @@ describe('Event responder: Press', () => { }); }); - // TODO - //describe('`onPress*` with movement', () => { - //describe('within bounds of hit rect', () => { - /** ┌──────────────────┐ - * │ ┌────────────┐ │ - * │ │ VisualRect │ │ - * │ └────────────┘ │ - * │ HitRect X │ <= Move to X and release - * └──────────────────┘ - */ - - //it('"onPress*" events are called when no delay', () => {}); - //it('"onPress*" events are called after a delay', () => {}); - //}); - - //describe('beyond bounds of hit rect', () => { - /** ┌──────────────────┐ - * │ ┌────────────┐ │ - * │ │ VisualRect │ │ - * │ └────────────┘ │ - * │ HitRect │ - * └──────────────────┘ - * X <= Move to X and release - */ - - //it('"onPress" only is not called when no delay', () => {}); - //it('"onPress*" events are not called after a delay', () => {}); - //it('"onPress*" events are called when press is released before measure completes', () => {}); - //}); - //}); + describe('onPressMove', () => { + it('is called after "pointermove"', () => { + const onPressMove = jest.fn(); + const ref = React.createRef(); + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.getBoundingClientRect = () => ({ + top: 50, + left: 50, + bottom: 500, + right: 500, + }); + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent( + createPointerEvent('pointermove', { + pointerType: 'touch', + pageX: 55, + pageY: 55, + }), + ); + expect(onPressMove).toHaveBeenCalledTimes(1); + expect(onPressMove).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'touch', type: 'pressmove'}), + ); + }); + }); + + describe('press with movement', () => { + const rectMock = { + width: 100, + height: 100, + top: 50, + left: 50, + right: 500, + bottom: 500, + }; + const pressRectOffset = 20; + const getBoundingClientRectMock = () => rectMock; + const coordinatesInside = { + pageX: rectMock.left - pressRectOffset, + pageY: rectMock.top - pressRectOffset, + }; + const coordinatesOutside = { + pageX: rectMock.left - pressRectOffset - 1, + pageY: rectMock.top - pressRectOffset - 1, + }; + + describe('within bounds of hit rect', () => { + /** ┌──────────────────┐ + * │ ┌────────────┐ │ + * │ │ VisualRect │ │ + * │ └────────────┘ │ + * │ HitRect X │ <= Move to X and release + * └──────────────────┘ + */ + it('no delay and "onPress*" events are called immediately', () => { + let events = []; + const ref = React.createRef(); + const createEventHandler = msg => () => { + events.push(msg); + }; + + const element = ( + +
+ + ); + + ReactDOM.render(element, container); + + ref.current.getBoundingClientRect = getBoundingClientRectMock; + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent( + createPointerEvent('pointermove', coordinatesInside), + ); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + jest.runAllTimers(); + + expect(events).toEqual([ + 'onPressStart', + 'onPressChange', + 'onPressMove', + 'onPressEnd', + 'onPressChange', + 'onPress', + ]); + }); + + it('delay and "onPressMove" is called before "onPress*" events', () => { + let events = []; + const ref = React.createRef(); + const createEventHandler = msg => () => { + events.push(msg); + }; + + const element = ( + +
+ + ); + + ReactDOM.render(element, container); + + ref.current.getBoundingClientRect = getBoundingClientRectMock; + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent( + createPointerEvent('pointermove', coordinatesInside), + ); + jest.advanceTimersByTime(499); + expect(events).toEqual(['onPressMove']); + events = []; + + jest.advanceTimersByTime(1); + expect(events).toEqual(['onPressStart', 'onPressChange']); + events = []; + + ref.current.dispatchEvent(createPointerEvent('pointerup')); + expect(events).toEqual(['onPressEnd', 'onPressChange', 'onPress']); + }); + + it('press retention offset can be configured', () => { + let events = []; + const ref = React.createRef(); + const createEventHandler = msg => () => { + events.push(msg); + }; + const pressRetentionOffset = {top: 40, bottom: 40, left: 40, right: 40}; + + const element = ( + +
+ + ); + + ReactDOM.render(element, container); + ref.current.getBoundingClientRect = getBoundingClientRectMock; + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent( + createPointerEvent('pointermove', { + pageX: rectMock.left - pressRetentionOffset.left, + pageY: rectMock.top - pressRetentionOffset.top, + }), + ); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + expect(events).toEqual([ + 'onPressStart', + 'onPressChange', + 'onPressMove', + 'onPressEnd', + 'onPressChange', + 'onPress', + ]); + }); + }); + + describe('beyond bounds of hit rect', () => { + /** ┌──────────────────┐ + * │ ┌────────────┐ │ + * │ │ VisualRect │ │ + * │ └────────────┘ │ + * │ HitRect │ + * └──────────────────┘ + * X <= Move to X and release + */ + + it('"onPress" is not called on release', () => { + let events = []; + const ref = React.createRef(); + const createEventHandler = msg => () => { + events.push(msg); + }; + + const element = ( + +
+ + ); + + ReactDOM.render(element, container); + + ref.current.getBoundingClientRect = getBoundingClientRectMock; + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent( + createPointerEvent('pointermove', coordinatesInside), + ); + ref.current.dispatchEvent( + createPointerEvent('pointermove', coordinatesOutside), + ); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + jest.runAllTimers(); + + expect(events).toEqual([ + 'onPressStart', + 'onPressChange', + 'onPressMove', + 'onPressEnd', + 'onPressChange', + ]); + }); + + it('"onPress*" events are not called after delay expires', () => { + let events = []; + const ref = React.createRef(); + const createEventHandler = msg => () => { + events.push(msg); + }; + + const element = ( + +
+ + ); + + ReactDOM.render(element, container); + + ref.current.getBoundingClientRect = getBoundingClientRectMock; + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent( + createPointerEvent('pointermove', coordinatesInside), + ); + ref.current.dispatchEvent( + createPointerEvent('pointermove', coordinatesOutside), + ); + jest.runAllTimers(); + expect(events).toEqual(['onPressMove']); + events = []; + ref.current.dispatchEvent(createPointerEvent('pointerup')); + jest.runAllTimers(); + expect(events).toEqual([]); + }); + }); + }); + + describe('delayed and multiple events', () => { + it('dispatches in the correct order', () => { + let events; + const ref = React.createRef(); + const createEventHandler = msg => () => { + events.push(msg); + }; + + const element = ( + +
+ + ); + + ReactDOM.render(element, container); + + // 1 + events = []; + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + jest.runAllTimers(); + + expect(events).toEqual([ + 'onPressStart', + 'onPressChange', + 'onPress', + 'onPressStart', + 'onPress', + 'onPressEnd', + 'onPressChange', + ]); + + // 2 + events = []; + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + jest.advanceTimersByTime(250); + jest.advanceTimersByTime(500); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + jest.runAllTimers(); + + expect(events).toEqual([ + 'onPressStart', + 'onPressChange', + 'onLongPress', + 'onLongPressChange', + 'onPress', + 'onPressEnd', + 'onPressChange', + 'onLongPressChange', + ]); + }); + }); describe('nested responders', () => { it('dispatch events in the correct order', () => { - let events = []; + const events = []; const ref = React.createRef(); const createEventHandler = msg => () => { events.push(msg); @@ -355,7 +1097,8 @@ describe('Event responder: Press', () => { onPress={createEventHandler('inner: onPress')} onPressChange={createEventHandler('inner: onPressChange')} onPressStart={createEventHandler('inner: onPressStart')} - onPressEnd={createEventHandler('inner: onPressEnd')}> + onPressEnd={createEventHandler('inner: onPressEnd')} + stopPropagation={false}>
{ 'outer: onPressChange', 'outer: onPress', ]); + }); + }); - events = []; - ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); - // TODO update this test once we have a form of stopPropagation in - // the responder system again. This test had to be updated because - // we have removed stopPropagation() from synthetic events. - expect(events).toEqual(['keydown', 'inner: onPress', 'outer: onPress']); + describe('link components', () => { + it('prevents native behaviour by default', () => { + const onPress = jest.fn(); + const preventDefault = jest.fn(); + const ref = React.createRef(); + const element = ( + + + + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + ref.current.dispatchEvent(createPointerEvent('click', {preventDefault})); + expect(preventDefault).toBeCalled(); + }); + + it('uses native behaviour for interactions with modifier keys', () => { + const onPress = jest.fn(); + const preventDefault = jest.fn(); + const ref = React.createRef(); + const element = ( + + + + ); + ReactDOM.render(element, container); + + ['metaKey', 'ctrlKey', 'shiftKey'].forEach(modifierKey => { + ref.current.dispatchEvent( + createPointerEvent('pointerdown', {[modifierKey]: true}), + ); + ref.current.dispatchEvent( + createPointerEvent('pointerup', {[modifierKey]: true}), + ); + ref.current.dispatchEvent( + createPointerEvent('click', {[modifierKey]: true, preventDefault}), + ); + expect(preventDefault).not.toBeCalled(); + }); + }); + + it('uses native behaviour if preventDefault is false', () => { + const onPress = jest.fn(); + const preventDefault = jest.fn(); + const ref = React.createRef(); + const element = ( + + + + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerdown')); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + ref.current.dispatchEvent(createPointerEvent('click', {preventDefault})); + expect(preventDefault).not.toBeCalled(); }); }); diff --git a/packages/react-events/src/__tests__/TouchHitTarget-test.internal.js b/packages/react-events/src/__tests__/TouchHitTarget-test.internal.js index e47f7f3cb4714..672e0b8bfc5ff 100644 --- a/packages/react-events/src/__tests__/TouchHitTarget-test.internal.js +++ b/packages/react-events/src/__tests__/TouchHitTarget-test.internal.js @@ -16,13 +16,14 @@ let ReactFeatureFlags; let EventComponent; let ReactTestRenderer; let ReactDOM; +let ReactDOMServer; let ReactSymbols; let ReactEvents; let TouchHitTarget; const noOpResponder = { targetEventTypes: [], - handleEvent() {}, + onEvent() {}, }; function createReactEventComponent() { @@ -58,6 +59,11 @@ function initReactDOM() { ReactDOM = require('react-dom'); } +function initReactDOMServer() { + init(); + ReactDOMServer = require('react-dom/server'); +} + describe('TouchHitTarget', () => { describe('NoopRenderer', () => { beforeEach(() => { @@ -94,9 +100,7 @@ describe('TouchHitTarget', () => { expect(() => { ReactNoop.render(); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: must not have any children.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); const Test2 = () => ( @@ -109,9 +113,7 @@ describe('TouchHitTarget', () => { expect(() => { ReactNoop.render(); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: must not have any children.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); // Should render without warnings const Test3 = () => ( @@ -181,9 +183,7 @@ describe('TouchHitTarget', () => { expect(() => { root.update(); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: must not have any children.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); const Test2 = () => ( @@ -196,9 +196,7 @@ describe('TouchHitTarget', () => { expect(() => { root.update(); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: must not have any children.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); // Should render without warnings const Test3 = () => ( @@ -269,9 +267,7 @@ describe('TouchHitTarget', () => { expect(() => { ReactDOM.render(, container); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: must not have any children.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); const Test2 = () => ( @@ -284,9 +280,7 @@ describe('TouchHitTarget', () => { expect(() => { ReactDOM.render(, container); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: must not have any children.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); // Should render without warnings const Test3 = () => ( @@ -318,5 +312,280 @@ describe('TouchHitTarget', () => { 'Ensure is a direct child of a DOM element.', ); }); + + it('should render a conditional TouchHitTarget correctly (false -> true)', () => { + let cond = false; + + const Test = () => ( + +
+ {cond ? null : ( + + )} +
+
+ ); + + const container = document.createElement('div'); + ReactDOM.render(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe( + '
', + ); + + cond = true; + ReactDOM.render(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe('
'); + }); + + it('should render a conditional TouchHitTarget correctly (true -> false)', () => { + let cond = true; + + const Test = () => ( + +
+ {cond ? null : ( + + )} +
+
+ ); + + const container = document.createElement('div'); + ReactDOM.render(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe('
'); + + cond = false; + ReactDOM.render(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe( + '
', + ); + }); + + it('should render a conditional TouchHitTarget hit slop correctly (false -> true)', () => { + let cond = false; + + const Test = () => ( + +
+ {cond ? ( + + ) : ( + + )} +
+
+ ); + + const container = document.createElement('div'); + ReactDOM.render(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe( + '
', + ); + + cond = true; + ReactDOM.render(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe('
'); + }); + + it('should render a conditional TouchHitTarget hit slop correctly (true -> false)', () => { + let cond = true; + + const Test = () => ( + +
+ Random span 1 + {cond ? ( + + ) : ( + + )} + Random span 2 +
+
+ ); + + const container = document.createElement('div'); + ReactDOM.render(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe( + '
Random span 1Random span 2
', + ); + + cond = false; + ReactDOM.render(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe( + '
Random span 1
Random span 2
', + ); + }); + + it('should update TouchHitTarget hit slop values correctly (false -> true)', () => { + let cond = false; + + const Test = () => ( + +
+ Random span 1 + {cond ? ( + + ) : ( + + )} + Random span 2 +
+
+ ); + + const container = document.createElement('div'); + ReactDOM.render(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe( + '
Random span 1
Random span 2
', + ); + + cond = true; + ReactDOM.render(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe( + '
Random span 1
Random span 2
', + ); + }); + + it('should update TouchHitTarget hit slop values correctly (true -> false)', () => { + let cond = true; + + const Test = () => ( + +
+ Random span 1 + {cond ? ( + + ) : ( + + )} + Random span 2 +
+
+ ); + + const container = document.createElement('div'); + ReactDOM.render(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe( + '
Random span 1
Random span 2
', + ); + + cond = false; + ReactDOM.render(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe( + '
Random span 1
Random span 2
', + ); + }); + + it('should hydrate TouchHitTarget hit slop elements correcty and patch them', () => { + const Test = () => ( + +
+ +
+
+ ); + + const container = document.createElement('div'); + container.innerHTML = '
'; + expect(() => { + ReactDOM.hydrate(, container); + expect(Scheduler).toFlushWithoutYielding(); + }).toWarnDev( + 'Warning: Expected server HTML to contain a matching
in
.', + {withoutStack: true}, + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe( + '
', + ); + }); + }); + + describe('ReactDOMServer', () => { + beforeEach(() => { + initReactDOMServer(); + EventComponent = createReactEventComponent(); + TouchHitTarget = ReactEvents.TouchHitTarget; + }); + + it('should not warn when a TouchHitTarget is used correctly', () => { + const Test = () => ( + +
+ +
+
+ ); + + const output = ReactDOMServer.renderToString(); + expect(output).toBe('
'); + }); + + it('should render a TouchHitTarget without hit slop values', () => { + const Test = () => ( + +
+ +
+
+ ); + + let output = ReactDOMServer.renderToString(); + expect(output).toBe('
'); + + const Test2 = () => ( + +
+ +
+
+ ); + + output = ReactDOMServer.renderToString(); + expect(output).toBe('
'); + + const Test3 = () => ( + +
+ +
+
+ ); + + output = ReactDOMServer.renderToString(); + expect(output).toBe('
'); + }); }); }); diff --git a/packages/react-native-renderer/src/NativeMethodsMixin.js b/packages/react-native-renderer/src/NativeMethodsMixin.js index 5df51392a993d..97d659aa2b251 100644 --- a/packages/react-native-renderer/src/NativeMethodsMixin.js +++ b/packages/react-native-renderer/src/NativeMethodsMixin.js @@ -18,6 +18,7 @@ import type { import invariant from 'shared/invariant'; // Modules provided by RN: import TextInputState from 'TextInputState'; +import * as FabricUIManager from 'FabricUIManager'; import UIManager from 'UIManager'; import {create} from './ReactNativeAttributePayload'; @@ -68,10 +69,33 @@ export default function( * prop](docs/view.html#onlayout) instead. */ measure: function(callback: MeasureOnSuccessCallback) { - UIManager.measure( - findNodeHandle(this), - mountSafeCallback_NOT_REALLY_SAFE(this, callback), - ); + let maybeInstance; + + // Fiber errors if findNodeHandle is called for an umounted component. + // Tests using ReactTestRenderer will trigger this case indirectly. + // Mimicking stack behavior, we should silently ignore this case. + // TODO Fix ReactTestRenderer so we can remove this try/catch. + try { + maybeInstance = findHostInstance(this); + } catch (error) {} + + // If there is no host component beneath this we should fail silently. + // This is not an error; it could mean a class component rendered null. + if (maybeInstance == null) { + return; + } + + if (maybeInstance.canonical) { + FabricUIManager.measure( + maybeInstance.node, + mountSafeCallback_NOT_REALLY_SAFE(this, callback), + ); + } else { + UIManager.measure( + findNodeHandle(this), + mountSafeCallback_NOT_REALLY_SAFE(this, callback), + ); + } }, /** @@ -90,10 +114,33 @@ export default function( * has been completed in native. */ measureInWindow: function(callback: MeasureInWindowOnSuccessCallback) { - UIManager.measureInWindow( - findNodeHandle(this), - mountSafeCallback_NOT_REALLY_SAFE(this, callback), - ); + let maybeInstance; + + // Fiber errors if findNodeHandle is called for an umounted component. + // Tests using ReactTestRenderer will trigger this case indirectly. + // Mimicking stack behavior, we should silently ignore this case. + // TODO Fix ReactTestRenderer so we can remove this try/catch. + try { + maybeInstance = findHostInstance(this); + } catch (error) {} + + // If there is no host component beneath this we should fail silently. + // This is not an error; it could mean a class component rendered null. + if (maybeInstance == null) { + return; + } + + if (maybeInstance.canonical) { + FabricUIManager.measureInWindow( + maybeInstance.node, + mountSafeCallback_NOT_REALLY_SAFE(this, callback), + ); + } else { + UIManager.measureInWindow( + findNodeHandle(this), + mountSafeCallback_NOT_REALLY_SAFE(this, callback), + ); + } }, /** @@ -105,16 +152,60 @@ export default function( * `findNodeHandle(component)`. */ measureLayout: function( - relativeToNativeNode: number, + relativeToNativeNode: number | Object, onSuccess: MeasureLayoutOnSuccessCallback, onFail: () => void /* currently unused */, ) { - UIManager.measureLayout( - findNodeHandle(this), - relativeToNativeNode, - mountSafeCallback_NOT_REALLY_SAFE(this, onFail), - mountSafeCallback_NOT_REALLY_SAFE(this, onSuccess), - ); + let maybeInstance; + + // Fiber errors if findNodeHandle is called for an umounted component. + // Tests using ReactTestRenderer will trigger this case indirectly. + // Mimicking stack behavior, we should silently ignore this case. + // TODO Fix ReactTestRenderer so we can remove this try/catch. + try { + maybeInstance = findHostInstance(this); + } catch (error) {} + + // If there is no host component beneath this we should fail silently. + // This is not an error; it could mean a class component rendered null. + if (maybeInstance == null) { + return; + } + + if (maybeInstance.canonical) { + warningWithoutStack( + false, + 'Warning: measureLayout on components using NativeMethodsMixin ' + + 'or ReactNative.NativeComponent is not currently supported in Fabric. ' + + 'measureLayout must be called on a native ref. Consider using forwardRef.', + ); + return; + } else { + let relativeNode; + + if (typeof relativeToNativeNode === 'number') { + // Already a node handle + relativeNode = relativeToNativeNode; + } else if (relativeToNativeNode._nativeTag) { + relativeNode = relativeToNativeNode._nativeTag; + } + + if (relativeNode == null) { + warningWithoutStack( + false, + 'Warning: ref.measureLayout must be called with a node handle or a ref to a native component.', + ); + + return; + } + + UIManager.measureLayout( + findNodeHandle(this), + relativeNode, + mountSafeCallback_NOT_REALLY_SAFE(this, onFail), + mountSafeCallback_NOT_REALLY_SAFE(this, onSuccess), + ); + } }, /** diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index c2ccde544cb98..69f0f57040c8f 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -14,7 +14,7 @@ import type { NativeMethodsMixinType, ReactNativeBaseComponentViewConfig, } from './ReactNativeTypes'; -import type {ReactEventResponder} from 'shared/ReactTypes'; +import type {ReactEventComponentInstance} from 'shared/ReactTypes'; import {mountSafeCallback_NOT_REALLY_SAFE} from './NativeMethodsMixinUtils'; import {create, diff} from './ReactNativeAttributePayload'; @@ -39,8 +39,10 @@ import { appendChildToSet as appendChildNodeToSet, completeRoot, registerEventHandler, + measure as fabricMeasure, + measureInWindow as fabricMeasureInWindow, + measureLayout as fabricMeasureLayout, } from 'FabricUIManager'; -import UIManager from 'UIManager'; // Counter for uniquely identifying views. // % 10 === 1 means it is a rootTag. @@ -85,15 +87,18 @@ class ReactFabricHostComponent { _nativeTag: number; viewConfig: ReactNativeBaseComponentViewConfig<>; currentProps: Props; + _internalInstanceHandle: Object; constructor( tag: number, viewConfig: ReactNativeBaseComponentViewConfig<>, props: Props, + internalInstanceHandle: Object, ) { this._nativeTag = tag; this.viewConfig = viewConfig; this.currentProps = props; + this._internalInstanceHandle = internalInstanceHandle; } blur() { @@ -105,15 +110,15 @@ class ReactFabricHostComponent { } measure(callback: MeasureOnSuccessCallback) { - UIManager.measure( - this._nativeTag, + fabricMeasure( + this._internalInstanceHandle.stateNode.node, mountSafeCallback_NOT_REALLY_SAFE(this, callback), ); } measureInWindow(callback: MeasureInWindowOnSuccessCallback) { - UIManager.measureInWindow( - this._nativeTag, + fabricMeasureInWindow( + this._internalInstanceHandle.stateNode.node, mountSafeCallback_NOT_REALLY_SAFE(this, callback), ); } @@ -123,32 +128,21 @@ class ReactFabricHostComponent { onSuccess: MeasureLayoutOnSuccessCallback, onFail: () => void /* currently unused */, ) { - let relativeNode; - - if (typeof relativeToNativeNode === 'number') { - // Already a node handle - relativeNode = relativeToNativeNode; - } else if (relativeToNativeNode._nativeTag) { - relativeNode = relativeToNativeNode._nativeTag; - } else if ( - relativeToNativeNode.canonical && - relativeToNativeNode.canonical._nativeTag + if ( + typeof relativeToNativeNode === 'number' || + !(relativeToNativeNode instanceof ReactFabricHostComponent) ) { - relativeNode = relativeToNativeNode.canonical._nativeTag; - } - - if (relativeNode == null) { warningWithoutStack( false, - 'Warning: ref.measureLayout must be called with a node handle or a ref to a native component.', + 'Warning: ref.measureLayout must be called with a ref to a native component.', ); return; } - UIManager.measureLayout( - this._nativeTag, - relativeNode, + fabricMeasureLayout( + this._internalInstanceHandle.stateNode.node, + relativeToNativeNode._internalInstanceHandle.stateNode.node, mountSafeCallback_NOT_REALLY_SAFE(this, onFail), mountSafeCallback_NOT_REALLY_SAFE(this, onSuccess), ); @@ -212,7 +206,12 @@ export function createInstance( internalInstanceHandle, // internalInstanceHandle ); - const component = new ReactFabricHostComponent(tag, viewConfig, props); + const component = new ReactFabricHostComponent( + tag, + viewConfig, + props, + internalInstanceHandle, + ); return { node: node, @@ -434,19 +433,45 @@ export function replaceContainerChildren( newChildren: ChildSet, ): void {} -export function handleEventComponent( - eventResponder: ReactEventResponder, - rootContainerInstance: Container, - internalInstanceHandle: Object, +export function mountEventComponent( + eventComponentInstance: ReactEventComponentInstance, +) { + throw new Error('Not yet implemented.'); +} + +export function updateEventComponent( + eventComponentInstance: ReactEventComponentInstance, ) { - // TODO: add handleEventComponent implementation + throw new Error('Not yet implemented.'); +} + +export function unmountEventComponent( + eventComponentInstance: ReactEventComponentInstance, +): void { + throw new Error('Not yet implemented.'); +} + +export function getEventTargetChildElement( + type: Symbol | number, + props: Props, +): null { + throw new Error('Not yet implemented.'); } export function handleEventTarget( type: Symbol | number, props: Props, - parentInstance: Container, + rootContainerInstance: Container, internalInstanceHandle: Object, -) { - // TODO: add handleEventTarget implementation +): boolean { + throw new Error('Not yet implemented.'); +} + +export function commitEventTarget( + type: Symbol | number, + props: Props, + instance: Instance, + parentInstance: Instance, +): void { + throw new Error('Not yet implemented.'); } diff --git a/packages/react-native-renderer/src/ReactNativeComponent.js b/packages/react-native-renderer/src/ReactNativeComponent.js index d16adf61831a3..df19c665403ef 100644 --- a/packages/react-native-renderer/src/ReactNativeComponent.js +++ b/packages/react-native-renderer/src/ReactNativeComponent.js @@ -19,6 +19,7 @@ import type { import React from 'react'; // Modules provided by RN: import TextInputState from 'TextInputState'; +import * as FabricUIManager from 'FabricUIManager'; import UIManager from 'UIManager'; import {create} from './ReactNativeAttributePayload'; @@ -83,10 +84,33 @@ export default function( * [`onLayout` prop](docs/view.html#onlayout) instead. */ measure(callback: MeasureOnSuccessCallback): void { - UIManager.measure( - findNodeHandle(this), - mountSafeCallback_NOT_REALLY_SAFE(this, callback), - ); + let maybeInstance; + + // Fiber errors if findNodeHandle is called for an umounted component. + // Tests using ReactTestRenderer will trigger this case indirectly. + // Mimicking stack behavior, we should silently ignore this case. + // TODO Fix ReactTestRenderer so we can remove this try/catch. + try { + maybeInstance = findHostInstance(this); + } catch (error) {} + + // If there is no host component beneath this we should fail silently. + // This is not an error; it could mean a class component rendered null. + if (maybeInstance == null) { + return; + } + + if (maybeInstance.canonical) { + FabricUIManager.measure( + maybeInstance.node, + mountSafeCallback_NOT_REALLY_SAFE(this, callback), + ); + } else { + UIManager.measure( + findNodeHandle(this), + mountSafeCallback_NOT_REALLY_SAFE(this, callback), + ); + } } /** @@ -103,10 +127,33 @@ export default function( * These values are not available until after natives rendering completes. */ measureInWindow(callback: MeasureInWindowOnSuccessCallback): void { - UIManager.measureInWindow( - findNodeHandle(this), - mountSafeCallback_NOT_REALLY_SAFE(this, callback), - ); + let maybeInstance; + + // Fiber errors if findNodeHandle is called for an umounted component. + // Tests using ReactTestRenderer will trigger this case indirectly. + // Mimicking stack behavior, we should silently ignore this case. + // TODO Fix ReactTestRenderer so we can remove this try/catch. + try { + maybeInstance = findHostInstance(this); + } catch (error) {} + + // If there is no host component beneath this we should fail silently. + // This is not an error; it could mean a class component rendered null. + if (maybeInstance == null) { + return; + } + + if (maybeInstance.canonical) { + FabricUIManager.measureInWindow( + maybeInstance.node, + mountSafeCallback_NOT_REALLY_SAFE(this, callback), + ); + } else { + UIManager.measureInWindow( + findNodeHandle(this), + mountSafeCallback_NOT_REALLY_SAFE(this, callback), + ); + } } /** @@ -116,16 +163,60 @@ export default function( * Obtain a native node handle with `ReactNative.findNodeHandle(component)`. */ measureLayout( - relativeToNativeNode: number, + relativeToNativeNode: number | Object, onSuccess: MeasureLayoutOnSuccessCallback, onFail: () => void /* currently unused */, ): void { - UIManager.measureLayout( - findNodeHandle(this), - relativeToNativeNode, - mountSafeCallback_NOT_REALLY_SAFE(this, onFail), - mountSafeCallback_NOT_REALLY_SAFE(this, onSuccess), - ); + let maybeInstance; + + // Fiber errors if findNodeHandle is called for an umounted component. + // Tests using ReactTestRenderer will trigger this case indirectly. + // Mimicking stack behavior, we should silently ignore this case. + // TODO Fix ReactTestRenderer so we can remove this try/catch. + try { + maybeInstance = findHostInstance(this); + } catch (error) {} + + // If there is no host component beneath this we should fail silently. + // This is not an error; it could mean a class component rendered null. + if (maybeInstance == null) { + return; + } + + if (maybeInstance.canonical) { + warningWithoutStack( + false, + 'Warning: measureLayout on components using NativeMethodsMixin ' + + 'or ReactNative.NativeComponent is not currently supported in Fabric. ' + + 'measureLayout must be called on a native ref. Consider using forwardRef.', + ); + return; + } else { + let relativeNode; + + if (typeof relativeToNativeNode === 'number') { + // Already a node handle + relativeNode = relativeToNativeNode; + } else if (relativeToNativeNode._nativeTag) { + relativeNode = relativeToNativeNode._nativeTag; + } + + if (relativeNode == null) { + warningWithoutStack( + false, + 'Warning: ref.measureLayout must be called with a node handle or a ref to a native component.', + ); + + return; + } + + UIManager.measureLayout( + findNodeHandle(this), + relativeNode, + mountSafeCallback_NOT_REALLY_SAFE(this, onFail), + mountSafeCallback_NOT_REALLY_SAFE(this, onSuccess), + ); + } } /** diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index f4b24a1c39f4b..63bb4d2eeb944 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -8,7 +8,7 @@ */ import type {ReactNativeBaseComponentViewConfig} from './ReactNativeTypes'; -import type {ReactEventResponder} from 'shared/ReactTypes'; +import type {ReactEventComponentInstance} from 'shared/ReactTypes'; import invariant from 'shared/invariant'; @@ -493,19 +493,45 @@ export function unhideTextInstance( throw new Error('Not yet implemented.'); } -export function handleEventComponent( - eventResponder: ReactEventResponder, - rootContainerInstance: Container, - internalInstanceHandle: Object, +export function mountEventComponent( + eventComponentInstance: ReactEventComponentInstance, +) { + throw new Error('Not yet implemented.'); +} + +export function updateEventComponent( + eventComponentInstance: ReactEventComponentInstance, ) { - // TODO: add handleEventComponent implementation + throw new Error('Not yet implemented.'); +} + +export function unmountEventComponent( + eventComponentInstance: ReactEventComponentInstance, +): void { + throw new Error('Not yet implemented.'); +} + +export function getEventTargetChildElement( + type: Symbol | number, + props: Props, +): null { + throw new Error('Not yet implemented.'); } export function handleEventTarget( type: Symbol | number, props: Props, - parentInstance: Container, + rootContainerInstance: Container, internalInstanceHandle: Object, -) { - // TODO: add handleEventTarget implementation +): boolean { + throw new Error('Not yet implemented.'); +} + +export function commitEventTarget( + type: Symbol | number, + props: Props, + instance: Instance, + parentInstance: Instance, +): void { + throw new Error('Not yet implemented.'); } diff --git a/packages/react-native-renderer/src/ReactNativeTypes.js b/packages/react-native-renderer/src/ReactNativeTypes.js index 1e145ed5e1d5c..3e777114db2ae 100644 --- a/packages/react-native-renderer/src/ReactNativeTypes.js +++ b/packages/react-native-renderer/src/ReactNativeTypes.js @@ -89,7 +89,7 @@ class ReactNativeComponent extends React.Component { measure(callback: MeasureOnSuccessCallback): void {} measureInWindow(callback: MeasureInWindowOnSuccessCallback): void {} measureLayout( - relativeToNativeNode: number, + relativeToNativeNode: number | Object, onSuccess: MeasureLayoutOnSuccessCallback, onFail?: () => void, ): void {} @@ -106,7 +106,7 @@ export type NativeMethodsMixinType = { measure(callback: MeasureOnSuccessCallback): void, measureInWindow(callback: MeasureInWindowOnSuccessCallback): void, measureLayout( - relativeToNativeNode: number, + relativeToNativeNode: number | Object, onSuccess: MeasureLayoutOnSuccessCallback, onFail: () => void, ): void, diff --git a/packages/react-native-renderer/src/__mocks__/FabricUIManager.js b/packages/react-native-renderer/src/__mocks__/FabricUIManager.js index a3f80b04493cc..c44031b194353 100644 --- a/packages/react-native-renderer/src/__mocks__/FabricUIManager.js +++ b/packages/react-native-renderer/src/__mocks__/FabricUIManager.js @@ -119,6 +119,57 @@ const RCTFabricUIManager = { }), registerEventHandler: jest.fn(function registerEventHandler(callback) {}), + + measure: jest.fn(function measure(node, callback) { + invariant( + typeof node === 'object', + 'Expected node to be an object, was passed "%s"', + typeof node, + ); + invariant( + typeof node.viewName === 'string', + 'Expected node to be a host node.', + ); + callback(10, 10, 100, 100, 0, 0); + }), + measureInWindow: jest.fn(function measureInWindow(node, callback) { + invariant( + typeof node === 'object', + 'Expected node to be an object, was passed "%s"', + typeof node, + ); + invariant( + typeof node.viewName === 'string', + 'Expected node to be a host node.', + ); + callback(10, 10, 100, 100); + }), + measureLayout: jest.fn(function measureLayout( + node, + relativeNode, + fail, + success, + ) { + invariant( + typeof node === 'object', + 'Expected node to be an object, was passed "%s"', + typeof node, + ); + invariant( + typeof node.viewName === 'string', + 'Expected node to be a host node.', + ); + invariant( + typeof relativeNode === 'object', + 'Expected relative node to be an object, was passed "%s"', + typeof relativeNode, + ); + invariant( + typeof relativeNode.viewName === 'string', + 'Expected relative node to be a host node.', + ); + success(1, 1, 100, 100); + }), }; module.exports = RCTFabricUIManager; diff --git a/packages/react-native-renderer/src/__mocks__/UIManager.js b/packages/react-native-renderer/src/__mocks__/UIManager.js index 2905761af0560..41ed47725cefa 100644 --- a/packages/react-native-renderer/src/__mocks__/UIManager.js +++ b/packages/react-native-renderer/src/__mocks__/UIManager.js @@ -153,7 +153,40 @@ const RCTUIManager = { views.get(parentTag).children.forEach(tag => removeChild(parentTag, tag)); }), replaceExistingNonRootView: jest.fn(), - measureLayout: jest.fn(), + measure: jest.fn(function measure(tag, callback) { + invariant( + typeof tag === 'number', + 'Expected tag to be a number, was passed %s', + tag, + ); + callback(10, 10, 100, 100, 0, 0); + }), + measureInWindow: jest.fn(function measureInWindow(tag, callback) { + invariant( + typeof tag === 'number', + 'Expected tag to be a number, was passed %s', + tag, + ); + callback(10, 10, 100, 100); + }), + measureLayout: jest.fn(function measureLayout( + tag, + relativeTag, + fail, + success, + ) { + invariant( + typeof tag === 'number', + 'Expected tag to be a number, was passed %s', + tag, + ); + invariant( + typeof relativeTag === 'number', + 'Expected relativeTag to be a number, was passed %s', + relativeTag, + ); + success(1, 1, 100, 100); + }), __takeSnapshot: jest.fn(), }; diff --git a/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js b/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js index 9ec320ad74e6b..5681aa7661795 100644 --- a/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js +++ b/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js @@ -302,6 +302,88 @@ describe('ReactFabric', () => { }); }); + it('should call FabricUIManager.measure on ref.measure', () => { + const View = createReactNativeComponentClass('RCTView', () => ({ + validAttributes: {foo: true}, + uiViewClassName: 'RCTView', + })); + + class Subclass extends ReactFabric.NativeComponent { + render() { + return {this.props.children}; + } + } + + const CreateClass = createReactClass({ + mixins: [NativeMethodsMixin], + render() { + return {this.props.children}; + }, + }); + + [View, Subclass, CreateClass].forEach(Component => { + FabricUIManager.measure.mockClear(); + + let viewRef; + ReactFabric.render( + { + viewRef = ref; + }} + />, + 11, + ); + + expect(FabricUIManager.measure).not.toBeCalled(); + const successCallback = jest.fn(); + viewRef.measure(successCallback); + expect(FabricUIManager.measure).toHaveBeenCalledTimes(1); + expect(successCallback).toHaveBeenCalledTimes(1); + expect(successCallback).toHaveBeenCalledWith(10, 10, 100, 100, 0, 0); + }); + }); + + it('should call FabricUIManager.measureInWindow on ref.measureInWindow', () => { + const View = createReactNativeComponentClass('RCTView', () => ({ + validAttributes: {foo: true}, + uiViewClassName: 'RCTView', + })); + + class Subclass extends ReactFabric.NativeComponent { + render() { + return {this.props.children}; + } + } + + const CreateClass = createReactClass({ + mixins: [NativeMethodsMixin], + render() { + return {this.props.children}; + }, + }); + + [View, Subclass, CreateClass].forEach(Component => { + FabricUIManager.measureInWindow.mockClear(); + + let viewRef; + ReactFabric.render( + { + viewRef = ref; + }} + />, + 11, + ); + + expect(FabricUIManager.measureInWindow).not.toBeCalled(); + const successCallback = jest.fn(); + viewRef.measureInWindow(successCallback); + expect(FabricUIManager.measureInWindow).toHaveBeenCalledTimes(1); + expect(successCallback).toHaveBeenCalledTimes(1); + expect(successCallback).toHaveBeenCalledWith(10, 10, 100, 100); + }); + }); + it('should support ref in ref.measureLayout', () => { const View = createReactNativeComponentClass('RCTView', () => ({ validAttributes: {foo: true}, @@ -309,7 +391,7 @@ describe('ReactFabric', () => { })); [View].forEach(Component => { - UIManager.measureLayout.mockReset(); + FabricUIManager.measureLayout.mockClear(); let viewRef; let otherRef; @@ -330,30 +412,75 @@ describe('ReactFabric', () => { 11, ); - expect(UIManager.measureLayout).not.toBeCalled(); - + expect(FabricUIManager.measureLayout).not.toBeCalled(); const successCallback = jest.fn(); const failureCallback = jest.fn(); viewRef.measureLayout(otherRef, successCallback, failureCallback); + expect(FabricUIManager.measureLayout).toHaveBeenCalledTimes(1); + expect(successCallback).toHaveBeenCalledTimes(1); + expect(successCallback).toHaveBeenCalledWith(1, 1, 100, 100); + }); + }); + + it('should warn when calling measureLayout on Subclass and NativeMethodsMixin', () => { + const View = createReactNativeComponentClass('RCTView', () => ({ + validAttributes: {foo: true}, + uiViewClassName: 'RCTView', + })); + + class Subclass extends ReactFabric.NativeComponent { + render() { + return {this.props.children}; + } + } + + const CreateClass = createReactClass({ + mixins: [NativeMethodsMixin], + render() { + return {this.props.children}; + }, + }); - expect(UIManager.measureLayout).toHaveBeenCalledTimes(1); - expect(UIManager.measureLayout).toHaveBeenCalledWith( - expect.any(Number), - expect.any(Number), - expect.any(Function), - expect.any(Function), + [Subclass, CreateClass].forEach(Component => { + FabricUIManager.measureLayout.mockReset(); + + let viewRef; + let otherRef; + ReactFabric.render( + + { + viewRef = ref; + }} + /> + { + otherRef = ref; + }} + /> + , + 11, ); - const args = UIManager.measureLayout.mock.calls[0]; - expect(args[0]).not.toEqual(args[1]); - expect(successCallback).not.toBeCalled(); - expect(failureCallback).not.toBeCalled(); - args[2]('fail'); - expect(failureCallback).toBeCalledWith('fail'); + const successCallback = jest.fn(); + const failureCallback = jest.fn(); + + expect(() => { + viewRef.measureLayout(otherRef, successCallback, failureCallback); + }).toWarnDev( + [ + 'Warning: measureLayout on components using NativeMethodsMixin ' + + 'or ReactNative.NativeComponent is not currently supported in Fabric. ' + + 'measureLayout must be called on a native ref. Consider using forwardRef.', + ], + { + withoutStack: true, + }, + ); - expect(successCallback).not.toBeCalled(); - args[3]('success'); - expect(successCallback).toBeCalledWith('success'); + expect(FabricUIManager.measureLayout).not.toBeCalled(); + expect(UIManager.measureLayout).not.toBeCalled(); }); }); diff --git a/packages/react-native-renderer/src/__tests__/ReactNativeMount-test.internal.js b/packages/react-native-renderer/src/__tests__/ReactNativeMount-test.internal.js index fed124ed8afa0..e590b9f63534e 100644 --- a/packages/react-native-renderer/src/__tests__/ReactNativeMount-test.internal.js +++ b/packages/react-native-renderer/src/__tests__/ReactNativeMount-test.internal.js @@ -249,6 +249,88 @@ describe('ReactNative', () => { }); }); + it('should call UIManager.measure on ref.measure', () => { + const View = createReactNativeComponentClass('RCTView', () => ({ + validAttributes: {foo: true}, + uiViewClassName: 'RCTView', + })); + + class Subclass extends ReactNative.NativeComponent { + render() { + return {this.props.children}; + } + } + + const CreateClass = createReactClass({ + mixins: [NativeMethodsMixin], + render() { + return {this.props.children}; + }, + }); + + [View, Subclass, CreateClass].forEach(Component => { + UIManager.measure.mockClear(); + + let viewRef; + ReactNative.render( + { + viewRef = ref; + }} + />, + 11, + ); + + expect(UIManager.measure).not.toBeCalled(); + const successCallback = jest.fn(); + viewRef.measure(successCallback); + expect(UIManager.measure).toHaveBeenCalledTimes(1); + expect(successCallback).toHaveBeenCalledTimes(1); + expect(successCallback).toHaveBeenCalledWith(10, 10, 100, 100, 0, 0); + }); + }); + + it('should call UIManager.measureInWindow on ref.measureInWindow', () => { + const View = createReactNativeComponentClass('RCTView', () => ({ + validAttributes: {foo: true}, + uiViewClassName: 'RCTView', + })); + + class Subclass extends ReactNative.NativeComponent { + render() { + return {this.props.children}; + } + } + + const CreateClass = createReactClass({ + mixins: [NativeMethodsMixin], + render() { + return {this.props.children}; + }, + }); + + [View, Subclass, CreateClass].forEach(Component => { + UIManager.measureInWindow.mockClear(); + + let viewRef; + ReactNative.render( + { + viewRef = ref; + }} + />, + 11, + ); + + expect(UIManager.measureInWindow).not.toBeCalled(); + const successCallback = jest.fn(); + viewRef.measureInWindow(successCallback); + expect(UIManager.measureInWindow).toHaveBeenCalledTimes(1); + expect(successCallback).toHaveBeenCalledTimes(1); + expect(successCallback).toHaveBeenCalledWith(10, 10, 100, 100); + }); + }); + it('should support reactTag in ref.measureLayout', () => { const View = createReactNativeComponentClass('RCTView', () => ({ validAttributes: {foo: true}, @@ -269,7 +351,7 @@ describe('ReactNative', () => { }); [View, Subclass, CreateClass].forEach(Component => { - UIManager.measureLayout.mockReset(); + UIManager.measureLayout.mockClear(); let viewRef; let otherRef; @@ -291,7 +373,6 @@ describe('ReactNative', () => { ); expect(UIManager.measureLayout).not.toBeCalled(); - const successCallback = jest.fn(); const failureCallback = jest.fn(); viewRef.measureLayout( @@ -299,25 +380,9 @@ describe('ReactNative', () => { successCallback, failureCallback, ); - expect(UIManager.measureLayout).toHaveBeenCalledTimes(1); - expect(UIManager.measureLayout).toHaveBeenCalledWith( - expect.any(Number), - expect.any(Number), - expect.any(Function), - expect.any(Function), - ); - - const args = UIManager.measureLayout.mock.calls[0]; - expect(args[0]).not.toEqual(args[1]); - expect(successCallback).not.toBeCalled(); - expect(failureCallback).not.toBeCalled(); - args[2]('fail'); - expect(failureCallback).toBeCalledWith('fail'); - - expect(successCallback).not.toBeCalled(); - args[3]('success'); - expect(successCallback).toBeCalledWith('success'); + expect(successCallback).toHaveBeenCalledTimes(1); + expect(successCallback).toHaveBeenCalledWith(1, 1, 100, 100); }); }); @@ -328,7 +393,7 @@ describe('ReactNative', () => { })); [View].forEach(Component => { - UIManager.measureLayout.mockReset(); + UIManager.measureLayout.mockClear(); let viewRef; let otherRef; @@ -350,29 +415,12 @@ describe('ReactNative', () => { ); expect(UIManager.measureLayout).not.toBeCalled(); - const successCallback = jest.fn(); const failureCallback = jest.fn(); viewRef.measureLayout(otherRef, successCallback, failureCallback); - expect(UIManager.measureLayout).toHaveBeenCalledTimes(1); - expect(UIManager.measureLayout).toHaveBeenCalledWith( - expect.any(Number), - expect.any(Number), - expect.any(Function), - expect.any(Function), - ); - - const args = UIManager.measureLayout.mock.calls[0]; - expect(args[0]).not.toEqual(args[1]); - expect(successCallback).not.toBeCalled(); - expect(failureCallback).not.toBeCalled(); - args[2]('fail'); - expect(failureCallback).toBeCalledWith('fail'); - - expect(successCallback).not.toBeCalled(); - args[3]('success'); - expect(successCallback).toBeCalledWith('success'); + expect(successCallback).toHaveBeenCalledTimes(1); + expect(successCallback).toHaveBeenCalledWith(1, 1, 100, 100); }); }); diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 4b0f4f5b95275..4d6acebfe8f5d 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -33,12 +33,32 @@ import ReactSharedInternals from 'shared/ReactSharedInternals'; import warningWithoutStack from 'shared/warningWithoutStack'; import {enableEventAPI} from 'shared/ReactFeatureFlags'; +type EventTargetChildElement = { + type: string, + props: null | { + style?: { + position?: string, + bottom?: string, + left?: string, + right?: string, + top?: string, + }, + }, +}; type Container = { rootID: string, children: Array, pendingChildren: Array, }; -type Props = {prop: any, hidden: boolean, children?: mixed}; +type Props = { + prop: any, + hidden: boolean, + children?: mixed, + bottom?: null | number, + left?: null | number, + right?: null | number, + top?: null | number, +}; type Instance = {| type: string, id: number, @@ -299,12 +319,6 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { rootContainerInstance: Container, hostContext: HostContext, ): Instance { - if (__DEV__ && enableEventAPI) { - warning( - hostContext !== EVENT_TOUCH_HIT_TARGET_CONTEXT, - 'validateDOMNesting: must not have any children.', - ); - } if (type === 'errorInCompletePhase') { throw new Error('Error in host config.'); } @@ -379,22 +393,12 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { internalInstanceHandle: Object, ): TextInstance { if (__DEV__ && enableEventAPI) { - warning( - hostContext !== EVENT_TOUCH_HIT_TARGET_CONTEXT, - 'validateDOMNesting: must not have any children.', - ); warning( hostContext !== EVENT_COMPONENT_CONTEXT, 'validateDOMNesting: React event components cannot have text DOM nodes as children. ' + 'Wrap the child text "%s" in an element.', text, ); - warning( - hostContext !== EVENT_TARGET_CONTEXT, - 'validateDOMNesting: React event targets cannot have text DOM nodes as children. ' + - 'Wrap the child text "%s" in an element.', - text, - ); } if (hostContext === UPPERCASE_CONTEXT) { text = text.toUpperCase(); @@ -427,19 +431,63 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { isPrimaryRenderer: true, supportsHydration: false, - handleEventComponent() { + mountEventComponent(): void { + // NO-OP + }, + + updateEventComponent(): void { + // NO-OP + }, + + unmountEventComponent(): void { // NO-OP }, + getEventTargetChildElement( + type: Symbol | number, + props: Props, + ): null | EventTargetChildElement { + if (enableEventAPI) { + if (type === REACT_EVENT_TARGET_TOUCH_HIT) { + const {bottom, left, right, top} = props; + + if (!bottom && !left && !right && !top) { + return null; + } + return { + type: 'div', + props: { + style: { + position: 'absolute', + zIndex: -1, + bottom: bottom ? `-${bottom}px` : '0px', + left: left ? `-${left}px` : '0px', + right: right ? `-${right}px` : '0px', + top: top ? `-${top}px` : '0px', + }, + }, + }; + } + } + return null; + }, + handleEventTarget( type: Symbol | number, props: Props, - parentInstance: Container, + rootContainerInstance: Container, internalInstanceHandle: Object, - ) { - if (type === REACT_EVENT_TARGET_TOUCH_HIT) { - // TODO - } + ): boolean { + return false; + }, + + commitEventTarget( + type: Symbol | number, + props: Props, + instance: Instance, + parentInstance: Instance, + ): void { + // NO-OP }, }; diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 35b62021d574f..8ca24a16a670c 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -623,10 +623,6 @@ export function createFiberFromEventComponent( const fiber = createFiber(EventComponent, pendingProps, key, mode); fiber.elementType = eventComponent; fiber.type = eventComponent; - fiber.stateNode = { - props: pendingProps, - state: null, - }; fiber.expirationTime = expirationTime; return fiber; } @@ -642,6 +638,10 @@ export function createFiberFromEventTarget( fiber.elementType = eventTarget; fiber.type = eventTarget; fiber.expirationTime = expirationTime; + // Store latest props + fiber.stateNode = { + props: pendingProps, + }; return fiber; } diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index e6b04911a7025..8659e010a9847 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -96,6 +96,8 @@ import { registerSuspenseInstanceRetry, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; +import {getEventTargetChildElement} from './ReactFiberHostConfig'; +import {shouldSuspend} from './ReactFiberReconciler'; import { pushHostContext, pushHostContainer, @@ -1392,6 +1394,12 @@ function updateSuspenseComponent( const mode = workInProgress.mode; const nextProps = workInProgress.pendingProps; + if (__DEV__) { + if (shouldSuspend(workInProgress)) { + workInProgress.effectTag |= DidCapture; + } + } + // We should attempt to render the primary children unless this boundary // already suspended during this render (`alreadyCaptured` is true). let nextState: SuspenseState | null = workInProgress.memoizedState; @@ -1405,7 +1413,8 @@ function updateSuspenseComponent( // Something in this boundary's subtree already suspended. Switch to // rendering the fallback children. nextState = { - timedOutAt: nextState !== null ? nextState.timedOutAt : NoWork, + fallbackExpirationTime: + nextState !== null ? nextState.fallbackExpirationTime : NoWork, }; nextDidTimeout = true; workInProgress.effectTag &= ~DidCapture; @@ -1981,15 +1990,33 @@ function updateEventComponent(current, workInProgress, renderExpirationTime) { } function updateEventTarget(current, workInProgress, renderExpirationTime) { + const type = workInProgress.type.type; const nextProps = workInProgress.pendingProps; - let nextChildren = nextProps.children; + const eventTargetChild = getEventTargetChildElement(type, nextProps); - reconcileChildren( - current, - workInProgress, - nextChildren, - renderExpirationTime, - ); + if (__DEV__) { + warning( + nextProps.children == null, + 'Event targets should not have children.', + ); + } + if (eventTargetChild !== null) { + const child = (workInProgress.child = createFiberFromTypeAndProps( + eventTargetChild.type, + null, + eventTargetChild.props, + null, + workInProgress.mode, + renderExpirationTime, + )); + child.return = workInProgress; + + if (current === null || current.child === null) { + child.effectTag = Placement; + } + } else { + reconcileChildren(current, workInProgress, null, renderExpirationTime); + } pushHostContextForEventTarget(workInProgress); return workInProgress.child; } diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index a3e479c20ad62..3bf04ea9c11a7 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -28,6 +28,7 @@ import { enableSchedulerTracing, enableProfilerTimer, enableSuspenseServerRenderer, + enableEventAPI, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -43,6 +44,8 @@ import { IncompleteClassComponent, MemoComponent, SimpleMemoComponent, + EventComponent, + EventTarget, } from 'shared/ReactWorkTags'; import { invokeGuardedCallback, @@ -60,7 +63,10 @@ import invariant from 'shared/invariant'; import warningWithoutStack from 'shared/warningWithoutStack'; import warning from 'shared/warning'; -import {NoWork} from './ReactFiberExpirationTime'; +import { + NoWork, + computeAsyncExpirationNoBucket, +} from './ReactFiberExpirationTime'; import {onCommitUnmount} from './ReactFiberDevToolsHook'; import {startPhaseTimer, stopPhaseTimer} from './ReactDebugFiberPerf'; import {getStackByFiberInDevAndProd} from './ReactCurrentFiber'; @@ -90,6 +96,8 @@ import { hideTextInstance, unhideInstance, unhideTextInstance, + unmountEventComponent, + commitEventTarget, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -299,6 +307,7 @@ function commitBeforeMutationLifeCycles( case HostText: case HostPortal: case IncompleteClassComponent: + case EventTarget: // Nothing to do for these component types return; default: { @@ -585,6 +594,7 @@ function commitLifeCycles( } case SuspenseComponent: case IncompleteClassComponent: + case EventTarget: break; default: { invariant( @@ -740,6 +750,13 @@ function commitUnmount(current: Fiber): void { } return; } + case EventComponent: { + if (enableEventAPI) { + const eventComponentInstance = current.stateNode; + unmountEventComponent(eventComponentInstance); + current.stateNode = null; + } + } } } @@ -817,7 +834,8 @@ function commitContainer(finishedWork: Fiber) { switch (finishedWork.tag) { case ClassComponent: case HostComponent: - case HostText: { + case HostText: + case EventTarget: { return; } case HostRoot: @@ -955,17 +973,18 @@ function commitPlacement(finishedWork: Fiber): void { let node: Fiber = finishedWork; while (true) { if (node.tag === HostComponent || node.tag === HostText) { + const stateNode = node.stateNode; if (before) { if (isContainer) { - insertInContainerBefore(parent, node.stateNode, before); + insertInContainerBefore(parent, stateNode, before); } else { - insertBefore(parent, node.stateNode, before); + insertBefore(parent, stateNode, before); } } else { if (isContainer) { - appendChildToContainer(parent, node.stateNode); + appendChildToContainer(parent, stateNode); } else { - appendChild(parent, node.stateNode); + appendChild(parent, stateNode); } } } else if (node.tag === HostPortal) { @@ -1195,6 +1214,34 @@ function commitWork(current: Fiber | null, finishedWork: Fiber): void { commitTextUpdate(textInstance, oldText, newText); return; } + case EventTarget: { + if (enableEventAPI) { + const type = finishedWork.type.type; + const props = finishedWork.memoizedProps; + const instance = finishedWork.stateNode; + let parentInstance = null; + + let node = finishedWork.return; + // Traverse up the fiber tree until we find the parent host node. + while (node !== null) { + if (node.tag === HostComponent) { + parentInstance = node.stateNode; + break; + } else if (node.tag === HostRoot) { + parentInstance = node.stateNode.containerInfo; + break; + } + node = node.return; + } + invariant( + parentInstance !== null, + 'This should have a parent host component initialized. This error is likely ' + + 'caused by a bug in React. Please file an issue.', + ); + commitEventTarget(type, props, instance, parentInstance); + } + return; + } case HostRoot: { return; } @@ -1228,11 +1275,15 @@ function commitSuspenseComponent(finishedWork: Fiber) { } else { newDidTimeout = true; primaryChildParent = finishedWork.child; - if (newState.timedOutAt === NoWork) { + if (newState.fallbackExpirationTime === NoWork) { // If the children had not already timed out, record the time. // This is used to compute the elapsed time during subsequent // attempts to render the children. - newState.timedOutAt = requestCurrentTime(); + // We model this as a normal pri expiration time since that's + // how we infer start time for updates. + newState.fallbackExpirationTime = computeAsyncExpirationNoBucket( + requestCurrentTime(), + ); } } diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index abd125d3acb78..da5385eb3b059 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -17,6 +17,8 @@ import type { Container, ChildSet, } from './ReactFiberHostConfig'; +import type {ReactEventComponentInstance} from 'shared/ReactTypes'; +import type {SuspenseState} from './ReactFiberSuspenseComponent'; import { IndeterminateComponent, @@ -41,6 +43,7 @@ import { EventComponent, EventTarget, } from 'shared/ReactWorkTags'; +import {ConcurrentMode, NoContext} from './ReactTypeOfMode'; import { Placement, Ref, @@ -65,7 +68,8 @@ import { createContainerChildSet, appendChildToContainerChildSet, finalizeContainerChildren, - handleEventComponent, + mountEventComponent, + updateEventComponent, handleEventTarget, } from './ReactFiberHostConfig'; import { @@ -90,6 +94,7 @@ import { enableSuspenseServerRenderer, enableEventAPI, } from 'shared/ReactFeatureFlags'; +import {markRenderEventTime, renderDidSuspend} from './ReactFiberScheduler'; function markUpdate(workInProgress: Fiber) { // Tag the fiber with an update effect. This turns a Placement into @@ -663,7 +668,7 @@ function completeWork( case ForwardRef: break; case SuspenseComponent: { - const nextState = workInProgress.memoizedState; + const nextState: null | SuspenseState = workInProgress.memoizedState; if ((workInProgress.effectTag & DidCapture) !== NoEffect) { // Something suspended. Re-render with the fallback children. workInProgress.expirationTime = renderExpirationTime; @@ -672,34 +677,58 @@ function completeWork( } const nextDidTimeout = nextState !== null; - const prevDidTimeout = current !== null && current.memoizedState !== null; - + let prevDidTimeout = false; if (current === null) { // In cases where we didn't find a suitable hydration boundary we never // downgraded this to a DehydratedSuspenseComponent, but we still need to // pop the hydration state since we might be inside the insertion tree. popHydrationState(workInProgress); - } else if (!nextDidTimeout && prevDidTimeout) { - // We just switched from the fallback to the normal children. Delete - // the fallback. - // TODO: Would it be better to store the fallback fragment on - // the stateNode during the begin phase? - const currentFallbackChild: Fiber | null = (current.child: any).sibling; - if (currentFallbackChild !== null) { - // Deletions go at the beginning of the return fiber's effect list - const first = workInProgress.firstEffect; - if (first !== null) { - workInProgress.firstEffect = currentFallbackChild; - currentFallbackChild.nextEffect = first; - } else { - workInProgress.firstEffect = workInProgress.lastEffect = currentFallbackChild; - currentFallbackChild.nextEffect = null; + } else { + const prevState: null | SuspenseState = current.memoizedState; + prevDidTimeout = prevState !== null; + if (!nextDidTimeout && prevState !== null) { + // We just switched from the fallback to the normal children. + + // Mark the event time of the switching from fallback to normal children, + // based on the start of when we first showed the fallback. This time + // was given a normal pri expiration time at the time it was shown. + const fallbackExpirationTime: ExpirationTime = + prevState.fallbackExpirationTime; + markRenderEventTime(fallbackExpirationTime); + + // Delete the fallback. + // TODO: Would it be better to store the fallback fragment on + // the stateNode during the begin phase? + const currentFallbackChild: Fiber | null = (current.child: any) + .sibling; + if (currentFallbackChild !== null) { + // Deletions go at the beginning of the return fiber's effect list + const first = workInProgress.firstEffect; + if (first !== null) { + workInProgress.firstEffect = currentFallbackChild; + currentFallbackChild.nextEffect = first; + } else { + workInProgress.firstEffect = workInProgress.lastEffect = currentFallbackChild; + currentFallbackChild.nextEffect = null; + } + currentFallbackChild.effectTag = Deletion; } - currentFallbackChild.effectTag = Deletion; + } + } + + if (nextDidTimeout && !prevDidTimeout) { + // If this subtreee is running in concurrent mode we can suspend, + // otherwise we won't suspend. + // TODO: This will still suspend a synchronous tree if anything + // in the concurrent tree already suspended during this render. + // This is a known bug. + if ((workInProgress.mode & ConcurrentMode) !== NoContext) { + renderDidSuspend(); } } if (supportsPersistence) { + // TODO: Only schedule updates if not prevDidTimeout. if (nextDidTimeout) { // If this boundary just timed out, schedule an effect to attach a // retry listener to the proimse. This flag is also used to hide the @@ -708,6 +737,7 @@ function completeWork( } } if (supportsMutation) { + // TODO: Only schedule updates if these values are non equal, i.e. it changed. if (nextDidTimeout || prevDidTimeout) { // If this boundary just timed out, schedule an effect to attach a // retry listener to the proimse. This flag is also used to hide the @@ -774,9 +804,29 @@ function completeWork( popHostContext(workInProgress); const rootContainerInstance = getRootHostContainer(); const responder = workInProgress.type.responder; - // Update the props on the event component state node - workInProgress.stateNode.props = newProps; - handleEventComponent(responder, rootContainerInstance, workInProgress); + let eventComponentInstance: ReactEventComponentInstance | null = + workInProgress.stateNode; + + if (eventComponentInstance === null) { + let responderState = null; + if (responder.createInitialState !== undefined) { + responderState = responder.createInitialState(newProps); + } + eventComponentInstance = workInProgress.stateNode = { + context: null, + props: newProps, + responder, + rootInstance: rootContainerInstance, + state: responderState, + }; + mountEventComponent(eventComponentInstance); + } else { + // Update the props on the event component state node + eventComponentInstance.props = newProps; + // Update the root container, so we can properly unmount events at some point + eventComponentInstance.rootInstance = rootContainerInstance; + updateEventComponent(eventComponentInstance); + } } break; } @@ -784,18 +834,18 @@ function completeWork( if (enableEventAPI) { popHostContext(workInProgress); const type = workInProgress.type.type; - let node = workInProgress.return; - let parentHostInstance = null; - // Traverse up the fiber tree till we find a host component fiber - while (node !== null) { - if (node.tag === HostComponent) { - parentHostInstance = node.stateNode; - break; - } - node = node.return; - } - if (parentHostInstance !== null) { - handleEventTarget(type, newProps, parentHostInstance, workInProgress); + const rootContainerInstance = getRootHostContainer(); + const shouldUpdate = handleEventTarget( + type, + newProps, + rootContainerInstance, + workInProgress, + ); + // Update the latest props on the stateNode. This is used + // during the event phase to find the most current props. + workInProgress.stateNode.props = newProps; + if (shouldUpdate) { + markUpdate(workInProgress); } } break; diff --git a/packages/react-reconciler/src/ReactFiberExpirationTime.js b/packages/react-reconciler/src/ReactFiberExpirationTime.js index f75ca42a74a57..e28e888545a29 100644 --- a/packages/react-reconciler/src/ReactFiberExpirationTime.js +++ b/packages/react-reconciler/src/ReactFiberExpirationTime.js @@ -70,6 +70,14 @@ export function computeAsyncExpiration( ); } +// Same as computeAsyncExpiration but without the bucketing logic. This is +// used to compute timestamps instead of actual expiration times. +export function computeAsyncExpirationNoBucket( + currentTime: ExpirationTime, +): ExpirationTime { + return currentTime - LOW_PRIORITY_EXPIRATION / UNIT_SIZE; +} + // We intentionally set a higher expiration time for interactive updates in // dev than in production. // diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index a2cc3f953bac3..9686088ad238e 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -35,6 +35,7 @@ import { requestCurrentTime, warnIfNotCurrentlyActingUpdatesInDev, warnIfNotScopedWithMatchingAct, + markRenderEventTime, } from './ReactFiberScheduler'; import invariant from 'shared/invariant'; @@ -316,8 +317,8 @@ function areHookInputsEqual( 'Previous: %s\n' + 'Incoming: %s', currentHookNameInDev, - `[${nextDeps.join(', ')}]`, `[${prevDeps.join(', ')}]`, + `[${nextDeps.join(', ')}]`, ); } } @@ -719,6 +720,16 @@ function updateReducer( remainingExpirationTime = updateExpirationTime; } } else { + // This update does have sufficient priority. + + // Mark the event time of this update as relevant to this render pass. + // TODO: This should ideally use the true event time of this update rather than + // its priority which is a derived and not reverseable value. + // TODO: We should skip this update if it was already committed but currently + // we have no way of detecting the difference between a committed and suspended + // update here. + markRenderEventTime(updateExpirationTime); + // Process this update. if (update.eagerReducer === reducer) { // If this update was processed eagerly, and its reducer matches the diff --git a/packages/react-reconciler/src/ReactFiberPendingPriority.js b/packages/react-reconciler/src/ReactFiberPendingPriority.js deleted file mode 100644 index 3e3a038ef58ef..0000000000000 --- a/packages/react-reconciler/src/ReactFiberPendingPriority.js +++ /dev/null @@ -1,282 +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. - * - * @flow - */ - -import type {FiberRoot} from './ReactFiberRoot'; -import type {ExpirationTime} from './ReactFiberExpirationTime'; - -import {NoWork} from './ReactFiberExpirationTime'; - -// TODO: Offscreen updates should never suspend. However, a promise that -// suspended inside an offscreen subtree should be able to ping at the priority -// of the outer render. - -export function markPendingPriorityLevel( - root: FiberRoot, - expirationTime: ExpirationTime, -): void { - // If there's a gap between completing a failed root and retrying it, - // additional updates may be scheduled. Clear `didError`, in case the update - // is sufficient to fix the error. - root.didError = false; - - // Update the latest and earliest pending times - const earliestPendingTime = root.earliestPendingTime; - if (earliestPendingTime === NoWork) { - // No other pending updates. - root.earliestPendingTime = root.latestPendingTime = expirationTime; - } else { - if (earliestPendingTime < expirationTime) { - // This is the earliest pending update. - root.earliestPendingTime = expirationTime; - } else { - const latestPendingTime = root.latestPendingTime; - if (latestPendingTime > expirationTime) { - // This is the latest pending update - root.latestPendingTime = expirationTime; - } - } - } - findNextExpirationTimeToWorkOn(expirationTime, root); -} - -export function markCommittedPriorityLevels( - root: FiberRoot, - earliestRemainingTime: ExpirationTime, -): void { - root.didError = false; - - if (earliestRemainingTime === NoWork) { - // Fast path. There's no remaining work. Clear everything. - root.earliestPendingTime = NoWork; - root.latestPendingTime = NoWork; - root.earliestSuspendedTime = NoWork; - root.latestSuspendedTime = NoWork; - root.latestPingedTime = NoWork; - findNextExpirationTimeToWorkOn(NoWork, root); - return; - } - - if (earliestRemainingTime < root.latestPingedTime) { - root.latestPingedTime = NoWork; - } - - // Let's see if the previous latest known pending level was just flushed. - const latestPendingTime = root.latestPendingTime; - if (latestPendingTime !== NoWork) { - if (latestPendingTime > earliestRemainingTime) { - // We've flushed all the known pending levels. - root.earliestPendingTime = root.latestPendingTime = NoWork; - } else { - const earliestPendingTime = root.earliestPendingTime; - if (earliestPendingTime > earliestRemainingTime) { - // We've flushed the earliest known pending level. Set this to the - // latest pending time. - root.earliestPendingTime = root.latestPendingTime; - } - } - } - - // Now let's handle the earliest remaining level in the whole tree. We need to - // decide whether to treat it as a pending level or as suspended. Check - // it falls within the range of known suspended levels. - - const earliestSuspendedTime = root.earliestSuspendedTime; - if (earliestSuspendedTime === NoWork) { - // There's no suspended work. Treat the earliest remaining level as a - // pending level. - markPendingPriorityLevel(root, earliestRemainingTime); - findNextExpirationTimeToWorkOn(NoWork, root); - return; - } - - const latestSuspendedTime = root.latestSuspendedTime; - if (earliestRemainingTime < latestSuspendedTime) { - // The earliest remaining level is later than all the suspended work. That - // means we've flushed all the suspended work. - root.earliestSuspendedTime = NoWork; - root.latestSuspendedTime = NoWork; - root.latestPingedTime = NoWork; - - // There's no suspended work. Treat the earliest remaining level as a - // pending level. - markPendingPriorityLevel(root, earliestRemainingTime); - findNextExpirationTimeToWorkOn(NoWork, root); - return; - } - - if (earliestRemainingTime > earliestSuspendedTime) { - // The earliest remaining time is earlier than all the suspended work. - // Treat it as a pending update. - markPendingPriorityLevel(root, earliestRemainingTime); - findNextExpirationTimeToWorkOn(NoWork, root); - return; - } - - // The earliest remaining time falls within the range of known suspended - // levels. We should treat this as suspended work. - findNextExpirationTimeToWorkOn(NoWork, root); -} - -export function hasLowerPriorityWork( - root: FiberRoot, - erroredExpirationTime: ExpirationTime, -): boolean { - const latestPendingTime = root.latestPendingTime; - const latestSuspendedTime = root.latestSuspendedTime; - const latestPingedTime = root.latestPingedTime; - return ( - (latestPendingTime !== NoWork && - latestPendingTime < erroredExpirationTime) || - (latestSuspendedTime !== NoWork && - latestSuspendedTime < erroredExpirationTime) || - (latestPingedTime !== NoWork && latestPingedTime < erroredExpirationTime) - ); -} - -export function isPriorityLevelSuspended( - root: FiberRoot, - expirationTime: ExpirationTime, -): boolean { - const earliestSuspendedTime = root.earliestSuspendedTime; - const latestSuspendedTime = root.latestSuspendedTime; - return ( - earliestSuspendedTime !== NoWork && - expirationTime <= earliestSuspendedTime && - expirationTime >= latestSuspendedTime - ); -} - -export function markSuspendedPriorityLevel( - root: FiberRoot, - suspendedTime: ExpirationTime, -): void { - root.didError = false; - clearPing(root, suspendedTime); - - // First, check the known pending levels and update them if needed. - const earliestPendingTime = root.earliestPendingTime; - const latestPendingTime = root.latestPendingTime; - if (earliestPendingTime === suspendedTime) { - if (latestPendingTime === suspendedTime) { - // Both known pending levels were suspended. Clear them. - root.earliestPendingTime = root.latestPendingTime = NoWork; - } else { - // The earliest pending level was suspended. Clear by setting it to the - // latest pending level. - root.earliestPendingTime = latestPendingTime; - } - } else if (latestPendingTime === suspendedTime) { - // The latest pending level was suspended. Clear by setting it to the - // latest pending level. - root.latestPendingTime = earliestPendingTime; - } - - // Finally, update the known suspended levels. - const earliestSuspendedTime = root.earliestSuspendedTime; - const latestSuspendedTime = root.latestSuspendedTime; - if (earliestSuspendedTime === NoWork) { - // No other suspended levels. - root.earliestSuspendedTime = root.latestSuspendedTime = suspendedTime; - } else { - if (earliestSuspendedTime < suspendedTime) { - // This is the earliest suspended level. - root.earliestSuspendedTime = suspendedTime; - } else if (latestSuspendedTime > suspendedTime) { - // This is the latest suspended level - root.latestSuspendedTime = suspendedTime; - } - } - - findNextExpirationTimeToWorkOn(suspendedTime, root); -} - -export function markPingedPriorityLevel( - root: FiberRoot, - pingedTime: ExpirationTime, -): void { - root.didError = false; - - // TODO: When we add back resuming, we need to ensure the progressed work - // is thrown out and not reused during the restarted render. One way to - // invalidate the progressed work is to restart at expirationTime + 1. - const latestPingedTime = root.latestPingedTime; - if (latestPingedTime === NoWork || latestPingedTime > pingedTime) { - root.latestPingedTime = pingedTime; - } - findNextExpirationTimeToWorkOn(pingedTime, root); -} - -function clearPing(root, completedTime) { - const latestPingedTime = root.latestPingedTime; - if (latestPingedTime >= completedTime) { - root.latestPingedTime = NoWork; - } -} - -export function findEarliestOutstandingPriorityLevel( - root: FiberRoot, - renderExpirationTime: ExpirationTime, -): ExpirationTime { - let earliestExpirationTime = renderExpirationTime; - - const earliestPendingTime = root.earliestPendingTime; - const earliestSuspendedTime = root.earliestSuspendedTime; - if (earliestPendingTime > earliestExpirationTime) { - earliestExpirationTime = earliestPendingTime; - } - if (earliestSuspendedTime > earliestExpirationTime) { - earliestExpirationTime = earliestSuspendedTime; - } - return earliestExpirationTime; -} - -export function didExpireAtExpirationTime( - root: FiberRoot, - currentTime: ExpirationTime, -): void { - const expirationTime = root.expirationTime; - if (expirationTime !== NoWork && currentTime <= expirationTime) { - // The root has expired. Flush all work up to the current time. - root.nextExpirationTimeToWorkOn = currentTime; - } -} - -function findNextExpirationTimeToWorkOn(completedExpirationTime, root) { - const earliestSuspendedTime = root.earliestSuspendedTime; - const latestSuspendedTime = root.latestSuspendedTime; - const earliestPendingTime = root.earliestPendingTime; - const latestPingedTime = root.latestPingedTime; - - // Work on the earliest pending time. Failing that, work on the latest - // pinged time. - let nextExpirationTimeToWorkOn = - earliestPendingTime !== NoWork ? earliestPendingTime : latestPingedTime; - - // If there is no pending or pinged work, check if there's suspended work - // that's lower priority than what we just completed. - if ( - nextExpirationTimeToWorkOn === NoWork && - (completedExpirationTime === NoWork || - latestSuspendedTime < completedExpirationTime) - ) { - // The lowest priority suspended work is the work most likely to be - // committed next. Let's start rendering it again, so that if it times out, - // it's ready to commit. - nextExpirationTimeToWorkOn = latestSuspendedTime; - } - - let expirationTime = nextExpirationTimeToWorkOn; - if (expirationTime !== NoWork && earliestSuspendedTime > expirationTime) { - // Expire using the earliest known expiration time. - expirationTime = earliestSuspendedTime; - } - - root.nextExpirationTimeToWorkOn = nextExpirationTimeToWorkOn; - root.expirationTime = expirationTime; -} diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index bf8a0f5b15ee9..051b3238d1dca 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -277,7 +277,7 @@ export function createContainer( containerInfo: Container, isConcurrent: boolean, hydrate: boolean, -): OpaqueRoot { +): OpaqueRoot { const fiberRoot = createFiberRoot(containerInfo, isConcurrent, hydrate); // jest isn't a 'global', it's just exposed to tests via a wrapped function // further, this isn't a test file, so flow doesn't recognize the symbol. So... @@ -350,8 +350,16 @@ export function findHostInstanceWithNoPortals( return hostFiber.stateNode; } +let shouldSuspendImpl = fiber => false; + +export function shouldSuspend(fiber: Fiber): boolean { + return shouldSuspendImpl(fiber); +} + let overrideHookState = null; let overrideProps = null; +let scheduleUpdate = null; +let setSuspenseHandler = null; if (__DEV__) { const copyWithSetImpl = ( @@ -419,6 +427,15 @@ if (__DEV__) { } scheduleWork(fiber, Sync); }; + + scheduleUpdate = (fiber: Fiber) => { + flushPassiveEffects(); + scheduleWork(fiber, Sync); + }; + + setSuspenseHandler = (newShouldSuspendImpl: Fiber => boolean) => { + shouldSuspendImpl = newShouldSuspendImpl; + }; } export function injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean { @@ -429,6 +446,8 @@ export function injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean { ...devToolsConfig, overrideHookState, overrideProps, + setSuspenseHandler, + scheduleUpdate, currentDispatcherRef: ReactCurrentDispatcher, findHostInstanceByFiber(fiber: Fiber): Instance | TextInstance | null { const hostFiber = findCurrentHostFiber(fiber); diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 2b085604ce808..38563ae2533bd 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -16,10 +16,7 @@ import type {Interaction} from 'scheduler/src/Tracing'; import {noTimeout} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber'; import {NoWork} from './ReactFiberExpirationTime'; -import { - enableSchedulerTracing, - enableNewScheduler, -} from 'shared/ReactFeatureFlags'; +import {enableSchedulerTracing} from 'shared/ReactFeatureFlags'; import {unstable_getThreadID} from 'scheduler/tracing'; // TODO: This should be lifted into the renderer. @@ -40,31 +37,11 @@ type BaseFiberRootProperties = {| // The currently active root fiber. This is the mutable root of the tree. current: Fiber, - // The following priority levels are used to distinguish between 1) - // uncommitted work, 2) uncommitted work that is suspended, and 3) uncommitted - // work that may be unsuspended. We choose not to track each individual - // pending level, trading granularity for performance. - // - // The earliest and latest priority levels that are suspended from committing. - earliestSuspendedTime: ExpirationTime, - latestSuspendedTime: ExpirationTime, - // The earliest and latest priority levels that are not known to be suspended. - earliestPendingTime: ExpirationTime, - latestPendingTime: ExpirationTime, - // The latest priority level that was pinged by a resolved promise and can - // be retried. - latestPingedTime: ExpirationTime, - pingCache: | WeakMap> | Map> | null, - // If an error is thrown, and there are no more updates in the queue, we try - // rendering from the root one more time, synchronously, before handling - // the error. - didError: boolean, - pendingCommitExpirationTime: ExpirationTime, // A finished work-in-progress HostRoot that's ready to be committed. finishedWork: Fiber | null, @@ -76,22 +53,19 @@ type BaseFiberRootProperties = {| pendingContext: Object | null, // Determines if we should attempt to hydrate on the initial mount +hydrate: boolean, - // Remaining expiration time on this root. - // TODO: Lift this into the renderer - nextExpirationTimeToWorkOn: ExpirationTime, - expirationTime: ExpirationTime, // List of top-level batches. This list indicates whether a commit should be // deferred. Also contains completion callbacks. // TODO: Lift this into the renderer firstBatch: Batch | null, - // Linked-list of roots - nextScheduledRoot: FiberRoot | null, - - // New Scheduler fields + // Node returned by Scheduler.scheduleCallback callbackNode: *, + // Expiration of the callback associated with this root callbackExpirationTime: ExpirationTime, + // The earliest pending expiration time that exists in the tree firstPendingTime: ExpirationTime, + // The latest pending expiration time that exists in the tree lastPendingTime: ExpirationTime, + // The time at which a suspended component pinged the root to render again pingTime: ExpirationTime, |}; @@ -127,24 +101,11 @@ function FiberRootNode(containerInfo, hydrate) { this.pendingContext = null; this.hydrate = hydrate; this.firstBatch = null; - - if (enableNewScheduler) { - this.callbackNode = null; - this.callbackExpirationTime = NoWork; - this.firstPendingTime = NoWork; - this.lastPendingTime = NoWork; - this.pingTime = NoWork; - } else { - this.earliestPendingTime = NoWork; - this.latestPendingTime = NoWork; - this.earliestSuspendedTime = NoWork; - this.latestSuspendedTime = NoWork; - this.latestPingedTime = NoWork; - this.didError = false; - this.nextExpirationTimeToWorkOn = NoWork; - this.expirationTime = NoWork; - this.nextScheduledRoot = null; - } + this.callbackNode = null; + this.callbackExpirationTime = NoWork; + this.firstPendingTime = NoWork; + this.lastPendingTime = NoWork; + this.pingTime = NoWork; if (enableSchedulerTracing) { this.interactionThreadID = unstable_getThreadID(); diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index cac6133c0e9f2..da59cbab2d1ed 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -7,141 +7,2266 @@ * @flow */ -import {enableNewScheduler} from 'shared/ReactFeatureFlags'; +import type {Fiber} from './ReactFiber'; +import type {FiberRoot} from './ReactFiberRoot'; +import type {ExpirationTime} from './ReactFiberExpirationTime'; +import type { + ReactPriorityLevel, + SchedulerCallback, +} from './SchedulerWithReactIntegration'; +import type {Interaction} from 'scheduler/src/Tracing'; import { - requestCurrentTime as requestCurrentTime_old, - computeExpirationForFiber as computeExpirationForFiber_old, - captureCommitPhaseError as captureCommitPhaseError_old, - onUncaughtError as onUncaughtError_old, - renderDidSuspend as renderDidSuspend_old, - renderDidError as renderDidError_old, - pingSuspendedRoot as pingSuspendedRoot_old, - retryTimedOutBoundary as retryTimedOutBoundary_old, - resolveRetryThenable as resolveRetryThenable_old, - markLegacyErrorBoundaryAsFailed as markLegacyErrorBoundaryAsFailed_old, - isAlreadyFailedLegacyErrorBoundary as isAlreadyFailedLegacyErrorBoundary_old, - scheduleWork as scheduleWork_old, - flushRoot as flushRoot_old, - batchedUpdates as batchedUpdates_old, - unbatchedUpdates as unbatchedUpdates_old, - flushSync as flushSync_old, - flushControlled as flushControlled_old, - deferredUpdates as deferredUpdates_old, - syncUpdates as syncUpdates_old, - interactiveUpdates as interactiveUpdates_old, - flushInteractiveUpdates as flushInteractiveUpdates_old, - computeUniqueAsyncExpiration as computeUniqueAsyncExpiration_old, - flushPassiveEffects as flushPassiveEffects_old, - warnIfNotScopedWithMatchingAct as warnIfNotScopedWithMatchingAct_old, - warnIfNotCurrentlyActingUpdatesInDev as warnIfNotCurrentlyActingUpdatesInDev_old, - inferStartTimeFromExpirationTime as inferStartTimeFromExpirationTime_old, -} from './ReactFiberScheduler.old'; + warnAboutDeprecatedLifecycles, + enableUserTimingAPI, + enableSuspenseServerRenderer, + replayFailedUnitOfWorkWithInvokeGuardedCallback, + enableProfilerTimer, + disableYielding, + enableSchedulerTracing, +} from 'shared/ReactFeatureFlags'; +import ReactSharedInternals from 'shared/ReactSharedInternals'; +import invariant from 'shared/invariant'; +import warning from 'shared/warning'; import { - requestCurrentTime as requestCurrentTime_new, - computeExpirationForFiber as computeExpirationForFiber_new, - captureCommitPhaseError as captureCommitPhaseError_new, - onUncaughtError as onUncaughtError_new, - renderDidSuspend as renderDidSuspend_new, - renderDidError as renderDidError_new, - pingSuspendedRoot as pingSuspendedRoot_new, - retryTimedOutBoundary as retryTimedOutBoundary_new, - resolveRetryThenable as resolveRetryThenable_new, - markLegacyErrorBoundaryAsFailed as markLegacyErrorBoundaryAsFailed_new, - isAlreadyFailedLegacyErrorBoundary as isAlreadyFailedLegacyErrorBoundary_new, - scheduleWork as scheduleWork_new, - flushRoot as flushRoot_new, - batchedUpdates as batchedUpdates_new, - unbatchedUpdates as unbatchedUpdates_new, - flushSync as flushSync_new, - flushControlled as flushControlled_new, - deferredUpdates as deferredUpdates_new, - syncUpdates as syncUpdates_new, - interactiveUpdates as interactiveUpdates_new, - flushInteractiveUpdates as flushInteractiveUpdates_new, - computeUniqueAsyncExpiration as computeUniqueAsyncExpiration_new, - flushPassiveEffects as flushPassiveEffects_new, - warnIfNotScopedWithMatchingAct as warnIfNotScopedWithMatchingAct_new, - warnIfNotCurrentlyActingUpdatesInDev as warnIfNotCurrentlyActingUpdatesInDev_new, - inferStartTimeFromExpirationTime as inferStartTimeFromExpirationTime_new, -} from './ReactFiberScheduler.new'; - -export const requestCurrentTime = enableNewScheduler - ? requestCurrentTime_new - : requestCurrentTime_old; -export const computeExpirationForFiber = enableNewScheduler - ? computeExpirationForFiber_new - : computeExpirationForFiber_old; -export const captureCommitPhaseError = enableNewScheduler - ? captureCommitPhaseError_new - : captureCommitPhaseError_old; -export const onUncaughtError = enableNewScheduler - ? onUncaughtError_new - : onUncaughtError_old; -export const renderDidSuspend = enableNewScheduler - ? renderDidSuspend_new - : renderDidSuspend_old; -export const renderDidError = enableNewScheduler - ? renderDidError_new - : renderDidError_old; -export const pingSuspendedRoot = enableNewScheduler - ? pingSuspendedRoot_new - : pingSuspendedRoot_old; -export const retryTimedOutBoundary = enableNewScheduler - ? retryTimedOutBoundary_new - : retryTimedOutBoundary_old; -export const resolveRetryThenable = enableNewScheduler - ? resolveRetryThenable_new - : resolveRetryThenable_old; -export const markLegacyErrorBoundaryAsFailed = enableNewScheduler - ? markLegacyErrorBoundaryAsFailed_new - : markLegacyErrorBoundaryAsFailed_old; -export const isAlreadyFailedLegacyErrorBoundary = enableNewScheduler - ? isAlreadyFailedLegacyErrorBoundary_new - : isAlreadyFailedLegacyErrorBoundary_old; -export const scheduleWork = enableNewScheduler - ? scheduleWork_new - : scheduleWork_old; -export const flushRoot = enableNewScheduler ? flushRoot_new : flushRoot_old; -export const batchedUpdates = enableNewScheduler - ? batchedUpdates_new - : batchedUpdates_old; -export const unbatchedUpdates = enableNewScheduler - ? unbatchedUpdates_new - : unbatchedUpdates_old; -export const flushSync = enableNewScheduler ? flushSync_new : flushSync_old; -export const flushControlled = enableNewScheduler - ? flushControlled_new - : flushControlled_old; -export const deferredUpdates = enableNewScheduler - ? deferredUpdates_new - : deferredUpdates_old; -export const syncUpdates = enableNewScheduler - ? syncUpdates_new - : syncUpdates_old; -export const interactiveUpdates = enableNewScheduler - ? interactiveUpdates_new - : interactiveUpdates_old; -export const flushInteractiveUpdates = enableNewScheduler - ? flushInteractiveUpdates_new - : flushInteractiveUpdates_old; -export const computeUniqueAsyncExpiration = enableNewScheduler - ? computeUniqueAsyncExpiration_new - : computeUniqueAsyncExpiration_old; -export const flushPassiveEffects = enableNewScheduler - ? flushPassiveEffects_new - : flushPassiveEffects_old; -export const warnIfNotScopedWithMatchingAct = enableNewScheduler - ? warnIfNotScopedWithMatchingAct_new - : warnIfNotScopedWithMatchingAct_old; -export const warnIfNotCurrentlyActingUpdatesInDev = enableNewScheduler - ? warnIfNotCurrentlyActingUpdatesInDev_new - : warnIfNotCurrentlyActingUpdatesInDev_old; -export const inferStartTimeFromExpirationTime = enableNewScheduler - ? inferStartTimeFromExpirationTime_new - : inferStartTimeFromExpirationTime_old; + scheduleCallback, + cancelCallback, + getCurrentPriorityLevel, + runWithPriority, + shouldYield, + now, + ImmediatePriority, + UserBlockingPriority, + NormalPriority, + LowPriority, + IdlePriority, + flushImmediateQueue, +} from './SchedulerWithReactIntegration'; + +import {__interactionsRef, __subscriberRef} from 'scheduler/tracing'; + +import { + prepareForCommit, + resetAfterCommit, + scheduleTimeout, + cancelTimeout, + noTimeout, +} from './ReactFiberHostConfig'; + +import {createWorkInProgress, assignFiberPropertiesInDEV} from './ReactFiber'; +import {NoContext, ConcurrentMode, ProfileMode} from './ReactTypeOfMode'; +import { + HostRoot, + ClassComponent, + SuspenseComponent, + DehydratedSuspenseComponent, + FunctionComponent, + ForwardRef, + MemoComponent, + SimpleMemoComponent, +} from 'shared/ReactWorkTags'; +import { + NoEffect, + PerformedWork, + Placement, + Update, + PlacementAndUpdate, + Deletion, + Ref, + ContentReset, + Snapshot, + Callback, + Passive, + Incomplete, + HostEffectMask, +} from 'shared/ReactSideEffectTags'; +import { + NoWork, + Sync, + Never, + msToExpirationTime, + expirationTimeToMs, + computeInteractiveExpiration, + computeAsyncExpiration, + inferPriorityFromExpirationTime, + LOW_PRIORITY_EXPIRATION, +} from './ReactFiberExpirationTime'; +import {beginWork as originalBeginWork} from './ReactFiberBeginWork'; +import {completeWork} from './ReactFiberCompleteWork'; +import { + throwException, + unwindWork, + unwindInterruptedWork, + createRootErrorUpdate, + createClassErrorUpdate, +} from './ReactFiberUnwindWork'; +import { + commitBeforeMutationLifeCycles as commitBeforeMutationEffectOnFiber, + commitLifeCycles as commitLayoutEffectOnFiber, + commitPassiveHookEffects, + commitPlacement, + commitWork, + commitDeletion, + commitDetachRef, + commitAttachRef, + commitResetTextContent, +} from './ReactFiberCommitWork'; +import {enqueueUpdate} from './ReactUpdateQueue'; +// TODO: Ahaha Andrew is bad at spellling +import {resetContextDependences as resetContextDependencies} from './ReactFiberNewContext'; +import {resetHooks, ContextOnlyDispatcher} from './ReactFiberHooks'; +import {createCapturedValue} from './ReactCapturedValue'; +import ReactActingUpdatesSigil from './ReactActingUpdatesSigil'; + +import { + recordCommitTime, + startProfilerTimer, + stopProfilerTimerIfRunningAndRecordDelta, +} from './ReactProfilerTimer'; + +// DEV stuff +import warningWithoutStack from 'shared/warningWithoutStack'; +import getComponentName from 'shared/getComponentName'; +import ReactStrictModeWarnings from './ReactStrictModeWarnings'; +import { + phase as ReactCurrentDebugFiberPhaseInDEV, + resetCurrentFiber as resetCurrentDebugFiberInDEV, + setCurrentFiber as setCurrentDebugFiberInDEV, + getStackByFiberInDevAndProd, +} from './ReactCurrentFiber'; +import { + recordEffect, + recordScheduleUpdate, + startRequestCallbackTimer, + stopRequestCallbackTimer, + startWorkTimer, + stopWorkTimer, + stopFailedWorkTimer, + startWorkLoopTimer, + stopWorkLoopTimer, + startCommitTimer, + stopCommitTimer, + startCommitSnapshotEffectsTimer, + stopCommitSnapshotEffectsTimer, + startCommitHostEffectsTimer, + stopCommitHostEffectsTimer, + startCommitLifeCyclesTimer, + stopCommitLifeCyclesTimer, +} from './ReactDebugFiberPerf'; +import { + invokeGuardedCallback, + hasCaughtError, + clearCaughtError, +} from 'shared/ReactErrorUtils'; +import {onCommitRoot} from './ReactFiberDevToolsHook'; + +const ceil = Math.ceil; + +const { + ReactCurrentDispatcher, + ReactCurrentOwner, + ReactShouldWarnActingUpdates, +} = ReactSharedInternals; + +type WorkPhase = 0 | 1 | 2 | 3 | 4 | 5; +const NotWorking = 0; +const BatchedPhase = 1; +const LegacyUnbatchedPhase = 2; +const FlushSyncPhase = 3; +const RenderPhase = 4; +const CommitPhase = 5; + +type RootExitStatus = 0 | 1 | 2 | 3; +const RootIncomplete = 0; +const RootErrored = 1; +const RootSuspended = 2; +const RootCompleted = 3; export type Thenable = { - then(resolve: () => mixed, reject?: () => mixed): void | Thenable, + then(resolve: () => mixed, reject?: () => mixed): Thenable | void, }; + +// The phase of work we're currently in +let workPhase: WorkPhase = NotWorking; +// The root we're working on +let workInProgressRoot: FiberRoot | null = null; +// The fiber we're working on +let workInProgress: Fiber | null = null; +// The expiration time we're rendering +let renderExpirationTime: ExpirationTime = NoWork; +// Whether to root completed, errored, suspended, etc. +let workInProgressRootExitStatus: RootExitStatus = RootIncomplete; +// Most recent event time among processed updates during this render. +// This is conceptually a time stamp but expressed in terms of an ExpirationTime +// because we deal mostly with expiration times in the hot path, so this avoids +// the conversion happening in the hot path. +let workInProgressRootMostRecentEventTime: ExpirationTime = Sync; + +let nextEffect: Fiber | null = null; +let hasUncaughtError = false; +let firstUncaughtError = null; +let legacyErrorBoundariesThatAlreadyFailed: Set | null = null; + +let rootDoesHavePassiveEffects: boolean = false; +let rootWithPendingPassiveEffects: FiberRoot | null = null; +let pendingPassiveEffectsExpirationTime: ExpirationTime = NoWork; + +let rootsWithPendingDiscreteUpdates: Map< + FiberRoot, + ExpirationTime, +> | null = null; + +// Use these to prevent an infinite loop of nested updates +const NESTED_UPDATE_LIMIT = 50; +let nestedUpdateCount: number = 0; +let rootWithNestedUpdates: FiberRoot | null = null; + +const NESTED_PASSIVE_UPDATE_LIMIT = 50; +let nestedPassiveUpdateCount: number = 0; + +let interruptedBy: Fiber | null = null; + +// Expiration times are computed by adding to the current time (the start +// time). However, if two updates are scheduled within the same event, we +// should treat their start times as simultaneous, even if the actual clock +// time has advanced between the first and second call. + +// In other words, because expiration times determine how updates are batched, +// we want all updates of like priority that occur within the same event to +// receive the same expiration time. Otherwise we get tearing. +let initialTimeMs: number = now(); +let currentEventTime: ExpirationTime = NoWork; + +export function requestCurrentTime() { + if (workPhase === RenderPhase || workPhase === CommitPhase) { + // We're inside React, so it's fine to read the actual time. + return msToExpirationTime(now() - initialTimeMs); + } + // We're not inside React, so we may be in the middle of a browser event. + if (currentEventTime !== NoWork) { + // Use the same start time for all updates until we enter React again. + return currentEventTime; + } + // This is the first update since React yielded. Compute a new start time. + currentEventTime = msToExpirationTime(now() - initialTimeMs); + return currentEventTime; +} + +export function computeExpirationForFiber( + currentTime: ExpirationTime, + fiber: Fiber, +): ExpirationTime { + if ((fiber.mode & ConcurrentMode) === NoContext) { + return Sync; + } + + if (workPhase === RenderPhase) { + // Use whatever time we're already rendering + return renderExpirationTime; + } + + // Compute an expiration time based on the Scheduler priority. + let expirationTime; + const priorityLevel = getCurrentPriorityLevel(); + switch (priorityLevel) { + case ImmediatePriority: + expirationTime = Sync; + break; + case UserBlockingPriority: + // TODO: Rename this to computeUserBlockingExpiration + expirationTime = computeInteractiveExpiration(currentTime); + break; + case NormalPriority: + case LowPriority: // TODO: Handle LowPriority + // TODO: Rename this to... something better. + expirationTime = computeAsyncExpiration(currentTime); + break; + case IdlePriority: + expirationTime = Never; + break; + default: + invariant(false, 'Expected a valid priority level'); + } + + // If we're in the middle of rendering a tree, do not update at the same + // expiration time that is already rendering. + if (workInProgressRoot !== null && expirationTime === renderExpirationTime) { + // This is a trick to move this update into a separate batch + expirationTime -= 1; + } + + return expirationTime; +} + +let lastUniqueAsyncExpiration = NoWork; +export function computeUniqueAsyncExpiration(): ExpirationTime { + const currentTime = requestCurrentTime(); + let result = computeAsyncExpiration(currentTime); + if (result <= lastUniqueAsyncExpiration) { + // Since we assume the current time monotonically increases, we only hit + // this branch when computeUniqueAsyncExpiration is fired multiple times + // within a 200ms window (or whatever the async bucket size is). + result -= 1; + } + lastUniqueAsyncExpiration = result; + return result; +} + +export function scheduleUpdateOnFiber( + fiber: Fiber, + expirationTime: ExpirationTime, +) { + checkForNestedUpdates(); + warnAboutInvalidUpdatesOnClassComponentsInDEV(fiber); + + const root = markUpdateTimeFromFiberToRoot(fiber, expirationTime); + if (root === null) { + warnAboutUpdateOnUnmountedFiberInDEV(fiber); + return; + } + + root.pingTime = NoWork; + + checkForInterruption(fiber, expirationTime); + recordScheduleUpdate(); + + if (expirationTime === Sync) { + if (workPhase === LegacyUnbatchedPhase) { + // This is a legacy edge case. The initial mount of a ReactDOM.render-ed + // root inside of batchedUpdates should be synchronous, but layout updates + // should be deferred until the end of the batch. + let callback = renderRoot(root, Sync, true); + while (callback !== null) { + callback = callback(true); + } + } else { + scheduleCallbackForRoot(root, ImmediatePriority, Sync); + if (workPhase === NotWorking) { + // Flush the synchronous work now, wnless we're already working or inside + // a batch. This is intentionally inside scheduleUpdateOnFiber instead of + // scheduleCallbackForFiber to preserve the ability to schedule a callback + // without immediately flushing it. We only do this for user-initated + // updates, to preserve historical behavior of sync mode. + flushImmediateQueue(); + } + } + } else { + // TODO: computeExpirationForFiber also reads the priority. Pass the + // priority as an argument to that function and this one. + const priorityLevel = getCurrentPriorityLevel(); + if (priorityLevel === UserBlockingPriority) { + // This is the result of a discrete event. Track the lowest priority + // discrete update per root so we can flush them early, if needed. + if (rootsWithPendingDiscreteUpdates === null) { + rootsWithPendingDiscreteUpdates = new Map([[root, expirationTime]]); + } else { + const lastDiscreteTime = rootsWithPendingDiscreteUpdates.get(root); + if ( + lastDiscreteTime === undefined || + lastDiscreteTime > expirationTime + ) { + rootsWithPendingDiscreteUpdates.set(root, expirationTime); + } + } + } + scheduleCallbackForRoot(root, priorityLevel, expirationTime); + } +} +export const scheduleWork = scheduleUpdateOnFiber; + +// This is split into a separate function so we can mark a fiber with pending +// work without treating it as a typical update that originates from an event; +// e.g. retrying a Suspense boundary isn't an update, but it does schedule work +// on a fiber. +function markUpdateTimeFromFiberToRoot(fiber, expirationTime) { + // Update the source fiber's expiration time + if (fiber.expirationTime < expirationTime) { + fiber.expirationTime = expirationTime; + } + let alternate = fiber.alternate; + if (alternate !== null && alternate.expirationTime < expirationTime) { + alternate.expirationTime = expirationTime; + } + // Walk the parent path to the root and update the child expiration time. + let node = fiber.return; + let root = null; + if (node === null && fiber.tag === HostRoot) { + root = fiber.stateNode; + } else { + while (node !== null) { + alternate = node.alternate; + if (node.childExpirationTime < expirationTime) { + node.childExpirationTime = expirationTime; + if ( + alternate !== null && + alternate.childExpirationTime < expirationTime + ) { + alternate.childExpirationTime = expirationTime; + } + } else if ( + alternate !== null && + alternate.childExpirationTime < expirationTime + ) { + alternate.childExpirationTime = expirationTime; + } + if (node.return === null && node.tag === HostRoot) { + root = node.stateNode; + break; + } + node = node.return; + } + } + + if (root !== null) { + // Update the first and last pending expiration times in this root + const firstPendingTime = root.firstPendingTime; + if (expirationTime > firstPendingTime) { + root.firstPendingTime = expirationTime; + } + const lastPendingTime = root.lastPendingTime; + if (lastPendingTime === NoWork || expirationTime < lastPendingTime) { + root.lastPendingTime = expirationTime; + } + } + + return root; +} + +// Use this function, along with runRootCallback, to ensure that only a single +// callback per root is scheduled. It's still possible to call renderRoot +// directly, but scheduling via this function helps avoid excessive callbacks. +// It works by storing the callback node and expiration time on the root. When a +// new callback comes in, it compares the expiration time to determine if it +// should cancel the previous one. It also relies on commitRoot scheduling a +// callback to render the next level, because that means we don't need a +// separate callback per expiration time. +function scheduleCallbackForRoot( + root: FiberRoot, + priorityLevel: ReactPriorityLevel, + expirationTime: ExpirationTime, +) { + const existingCallbackExpirationTime = root.callbackExpirationTime; + if (existingCallbackExpirationTime < expirationTime) { + // New callback has higher priority than the existing one. + const existingCallbackNode = root.callbackNode; + if (existingCallbackNode !== null) { + cancelCallback(existingCallbackNode); + } + root.callbackExpirationTime = expirationTime; + const options = + expirationTime === Sync + ? null + : {timeout: expirationTimeToMs(expirationTime)}; + root.callbackNode = scheduleCallback( + priorityLevel, + runRootCallback.bind( + null, + root, + renderRoot.bind(null, root, expirationTime), + ), + options, + ); + if ( + enableUserTimingAPI && + expirationTime !== Sync && + workPhase !== RenderPhase && + workPhase !== CommitPhase + ) { + // Scheduled an async callback, and we're not already working. Add an + // entry to the flamegraph that shows we're waiting for a callback + // to fire. + startRequestCallbackTimer(); + } + } + + const timeoutHandle = root.timeoutHandle; + if (timeoutHandle !== noTimeout) { + // The root previous suspended and scheduled a timeout to commit a fallback + // state. Now that we have additional work, cancel the timeout. + root.timeoutHandle = noTimeout; + // $FlowFixMe Complains noTimeout is not a TimeoutID, despite the check above + cancelTimeout(timeoutHandle); + } + + // Add the current set of interactions to the pending set associated with + // this root. + schedulePendingInteraction(root, expirationTime); +} + +function runRootCallback(root, callback, isSync) { + const prevCallbackNode = root.callbackNode; + let continuation = null; + try { + continuation = callback(isSync); + if (continuation !== null) { + return runRootCallback.bind(null, root, continuation); + } else { + return null; + } + } finally { + // If the callback exits without returning a continuation, remove the + // corresponding callback node from the root. Unless the callback node + // has changed, which implies that it was already cancelled by a high + // priority update. + if (continuation === null && prevCallbackNode === root.callbackNode) { + root.callbackNode = null; + root.callbackExpirationTime = NoWork; + } + } +} + +export function flushRoot(root: FiberRoot, expirationTime: ExpirationTime) { + if (workPhase === RenderPhase || workPhase === CommitPhase) { + invariant( + false, + 'work.commit(): Cannot commit while already rendering. This likely ' + + 'means you attempted to commit from inside a lifecycle method.', + ); + } + scheduleCallback( + ImmediatePriority, + renderRoot.bind(null, root, expirationTime), + ); + flushImmediateQueue(); +} + +export function flushInteractiveUpdates() { + if (workPhase === RenderPhase || workPhase === CommitPhase) { + // Can't synchronously flush interactive updates if React is already + // working. This is currently a no-op. + // TODO: Should we fire a warning? This happens if you synchronously invoke + // an input event inside an effect, like with `element.click()`. + return; + } + flushPendingDiscreteUpdates(); +} + +function resolveLocksOnRoot(root: FiberRoot, expirationTime: ExpirationTime) { + const firstBatch = root.firstBatch; + if ( + firstBatch !== null && + firstBatch._defer && + firstBatch._expirationTime >= expirationTime + ) { + root.finishedWork = root.current.alternate; + root.pendingCommitExpirationTime = expirationTime; + scheduleCallback(NormalPriority, () => { + firstBatch._onComplete(); + return null; + }); + return true; + } else { + return false; + } +} + +export function deferredUpdates
(fn: () => A): A { + // TODO: Remove in favor of Scheduler.next + return runWithPriority(NormalPriority, fn); +} + +export function interactiveUpdates( + fn: (A, B, C) => R, + a: A, + b: B, + c: C, +): R { + if (workPhase === NotWorking) { + // TODO: Remove this call. Instead of doing this automatically, the caller + // should explicitly call flushInteractiveUpdates. + flushPendingDiscreteUpdates(); + } + return runWithPriority(UserBlockingPriority, fn.bind(null, a, b, c)); +} + +export function syncUpdates( + fn: (A, B, C) => R, + a: A, + b: B, + c: C, +): R { + return runWithPriority(ImmediatePriority, fn.bind(null, a, b, c)); +} + +function flushPendingDiscreteUpdates() { + if (rootsWithPendingDiscreteUpdates !== null) { + // For each root with pending discrete updates, schedule a callback to + // immediately flush them. + const roots = rootsWithPendingDiscreteUpdates; + rootsWithPendingDiscreteUpdates = null; + roots.forEach((expirationTime, root) => { + scheduleCallback( + ImmediatePriority, + renderRoot.bind(null, root, expirationTime), + ); + }); + // Now flush the immediate queue. + flushImmediateQueue(); + } +} + +export function batchedUpdates(fn: A => R, a: A): R { + if (workPhase !== NotWorking) { + // We're already working, or inside a batch, so batchedUpdates is a no-op. + return fn(a); + } + workPhase = BatchedPhase; + try { + return fn(a); + } finally { + workPhase = NotWorking; + // Flush the immediate callbacks that were scheduled during this batch + flushImmediateQueue(); + } +} + +export function unbatchedUpdates(fn: (a: A) => R, a: A): R { + if (workPhase !== BatchedPhase && workPhase !== FlushSyncPhase) { + // We're not inside batchedUpdates or flushSync, so unbatchedUpdates is + // a no-op. + return fn(a); + } + const prevWorkPhase = workPhase; + workPhase = LegacyUnbatchedPhase; + try { + return fn(a); + } finally { + workPhase = prevWorkPhase; + } +} + +export function flushSync(fn: A => R, a: A): R { + if (workPhase === RenderPhase || workPhase === CommitPhase) { + invariant( + false, + 'flushSync was called from inside a lifecycle method. It cannot be ' + + 'called when React is already rendering.', + ); + } + const prevWorkPhase = workPhase; + workPhase = FlushSyncPhase; + try { + return runWithPriority(ImmediatePriority, fn.bind(null, a)); + } finally { + workPhase = prevWorkPhase; + // Flush the immediate callbacks that were scheduled during this batch. + // Note that this will happen even if batchedUpdates is higher up + // the stack. + flushImmediateQueue(); + } +} + +export function flushControlled(fn: () => mixed): void { + const prevWorkPhase = workPhase; + workPhase = BatchedPhase; + try { + runWithPriority(ImmediatePriority, fn); + } finally { + workPhase = prevWorkPhase; + if (workPhase === NotWorking) { + // Flush the immediate callbacks that were scheduled during this batch + flushImmediateQueue(); + } + } +} + +function prepareFreshStack(root, expirationTime) { + root.pendingCommitExpirationTime = NoWork; + + if (workInProgress !== null) { + let interruptedWork = workInProgress.return; + while (interruptedWork !== null) { + unwindInterruptedWork(interruptedWork); + interruptedWork = interruptedWork.return; + } + } + workInProgressRoot = root; + workInProgress = createWorkInProgress(root.current, null, expirationTime); + renderExpirationTime = expirationTime; + workInProgressRootExitStatus = RootIncomplete; + workInProgressRootMostRecentEventTime = Sync; + + if (__DEV__) { + ReactStrictModeWarnings.discardPendingWarnings(); + } +} + +function renderRoot( + root: FiberRoot, + expirationTime: ExpirationTime, + isSync: boolean, +): SchedulerCallback | null { + invariant( + workPhase !== RenderPhase && workPhase !== CommitPhase, + 'Should not already be working.', + ); + + if (enableUserTimingAPI && expirationTime !== Sync) { + const didExpire = isSync; + const timeoutMs = expirationTimeToMs(expirationTime); + stopRequestCallbackTimer(didExpire, timeoutMs); + } + + if (root.firstPendingTime < expirationTime) { + // If there's no work left at this expiration time, exit immediately. This + // happens when multiple callbacks are scheduled for a single root, but an + // earlier callback flushes the work of a later one. + return null; + } + + if (root.pendingCommitExpirationTime === expirationTime) { + // There's already a pending commit at this expiration time. + root.pendingCommitExpirationTime = NoWork; + return commitRoot.bind(null, root, expirationTime); + } + + flushPassiveEffects(); + + // If the root or expiration time have changed, throw out the existing stack + // and prepare a fresh one. Otherwise we'll continue where we left off. + if (root !== workInProgressRoot || expirationTime !== renderExpirationTime) { + prepareFreshStack(root, expirationTime); + startWorkOnPendingInteraction(root, expirationTime); + } + + // If we have a work-in-progress fiber, it means there's still work to do + // in this root. + if (workInProgress !== null) { + const prevWorkPhase = workPhase; + workPhase = RenderPhase; + let prevDispatcher = ReactCurrentDispatcher.current; + if (prevDispatcher === null) { + // The React isomorphic package does not include a default dispatcher. + // Instead the first renderer will lazily attach one, in order to give + // nicer error messages. + prevDispatcher = ContextOnlyDispatcher; + } + ReactCurrentDispatcher.current = ContextOnlyDispatcher; + let prevInteractions: Set | null = null; + if (enableSchedulerTracing) { + prevInteractions = __interactionsRef.current; + __interactionsRef.current = root.memoizedInteractions; + } + + startWorkLoopTimer(workInProgress); + + // TODO: Fork renderRoot into renderRootSync and renderRootAsync + if (isSync) { + if (expirationTime !== Sync) { + // An async update expired. There may be other expired updates on + // this root. We should render all the expired work in a + // single batch. + const currentTime = requestCurrentTime(); + if (currentTime < expirationTime) { + // Restart at the current time. + workPhase = prevWorkPhase; + resetContextDependencies(); + ReactCurrentDispatcher.current = prevDispatcher; + if (enableSchedulerTracing) { + __interactionsRef.current = ((prevInteractions: any): Set< + Interaction, + >); + } + return renderRoot.bind(null, root, currentTime); + } + } + } else { + // Since we know we're in a React event, we can clear the current + // event time. The next update will compute a new event time. + currentEventTime = NoWork; + } + + do { + try { + if (isSync) { + workLoopSync(); + } else { + workLoop(); + } + break; + } catch (thrownValue) { + // Reset module-level state that was set during the render phase. + resetContextDependencies(); + resetHooks(); + + const sourceFiber = workInProgress; + if (sourceFiber === null || sourceFiber.return === null) { + // Expected to be working on a non-root fiber. This is a fatal error + // because there's no ancestor that can handle it; the root is + // supposed to capture all errors that weren't caught by an error + // boundary. + prepareFreshStack(root, expirationTime); + workPhase = prevWorkPhase; + throw thrownValue; + } + + if (enableProfilerTimer && sourceFiber.mode & ProfileMode) { + // Record the time spent rendering before an error was thrown. This + // avoids inaccurate Profiler durations in the case of a + // suspended render. + stopProfilerTimerIfRunningAndRecordDelta(sourceFiber, true); + } + + const returnFiber = sourceFiber.return; + throwException( + root, + returnFiber, + sourceFiber, + thrownValue, + renderExpirationTime, + ); + workInProgress = completeUnitOfWork(sourceFiber); + } + } while (true); + + workPhase = prevWorkPhase; + resetContextDependencies(); + ReactCurrentDispatcher.current = prevDispatcher; + if (enableSchedulerTracing) { + __interactionsRef.current = ((prevInteractions: any): Set); + } + + if (workInProgress !== null) { + // There's still work left over. Return a continuation. + stopInterruptedWorkLoopTimer(); + if (expirationTime !== Sync) { + startRequestCallbackTimer(); + } + return renderRoot.bind(null, root, expirationTime); + } + } + + // We now have a consistent tree. The next step is either to commit it, or, if + // something suspended, wait to commit it after a timeout. + stopFinishedWorkLoopTimer(); + + const isLocked = resolveLocksOnRoot(root, expirationTime); + if (isLocked) { + // This root has a lock that prevents it from committing. Exit. If we begin + // work on the root again, without any intervening updates, it will finish + // without doing additional work. + return null; + } + + // Set this to null to indicate there's no in-progress render. + workInProgressRoot = null; + + switch (workInProgressRootExitStatus) { + case RootIncomplete: { + invariant(false, 'Should have a work-in-progress.'); + } + // Flow knows about invariant, so it compains if I add a break statement, + // but eslint doesn't know about invariant, so it complains if I do. + // eslint-disable-next-line no-fallthrough + case RootErrored: { + // An error was thrown. First check if there is lower priority work + // scheduled on this root. + const lastPendingTime = root.lastPendingTime; + if (root.lastPendingTime < expirationTime) { + // There's lower priority work. Before raising the error, try rendering + // at the lower priority to see if it fixes it. Use a continuation to + // maintain the existing priority and position in the queue. + return renderRoot.bind(null, root, lastPendingTime); + } + if (!isSync) { + // If we're rendering asynchronously, it's possible the error was + // caused by tearing due to a mutation during an event. Try rendering + // one more time without yiedling to events. + prepareFreshStack(root, expirationTime); + scheduleCallback( + ImmediatePriority, + renderRoot.bind(null, root, expirationTime), + ); + return null; + } + // If we're already rendering synchronously, commit the root in its + // errored state. + return commitRoot.bind(null, root, expirationTime); + } + case RootSuspended: { + if (!isSync) { + const lastPendingTime = root.lastPendingTime; + if (root.lastPendingTime < expirationTime) { + // There's lower priority work. It might be unsuspended. Try rendering + // at that level. + return renderRoot.bind(null, root, lastPendingTime); + } + // If workInProgressRootMostRecentEventTime is Sync, that means we didn't + // track any event times. That can happen if we retried but nothing switched + // from fallback to content. There's no reason to delay doing no work. + if (workInProgressRootMostRecentEventTime !== Sync) { + let msUntilTimeout = computeMsUntilTimeout( + workInProgressRootMostRecentEventTime, + expirationTime, + ); + // Don't bother with a very short suspense time. + if (msUntilTimeout > 10) { + // The render is suspended, it hasn't timed out, and there's no lower + // priority work to do. Instead of committing the fallback + // immediately, wait for more data to arrive. + root.timeoutHandle = scheduleTimeout( + commitRoot.bind(null, root, expirationTime), + msUntilTimeout, + ); + return null; + } + } + } + // The work expired. Commit immediately. + return commitRoot.bind(null, root, expirationTime); + } + case RootCompleted: { + // The work completed. Ready to commit. + return commitRoot.bind(null, root, expirationTime); + } + default: { + invariant(false, 'Unknown root exit status.'); + } + } +} + +export function markRenderEventTime(expirationTime: ExpirationTime): void { + if (expirationTime < workInProgressRootMostRecentEventTime) { + workInProgressRootMostRecentEventTime = expirationTime; + } +} + +export function renderDidSuspend(): void { + if (workInProgressRootExitStatus === RootIncomplete) { + workInProgressRootExitStatus = RootSuspended; + } +} + +export function renderDidError() { + if ( + workInProgressRootExitStatus === RootIncomplete || + workInProgressRootExitStatus === RootSuspended + ) { + workInProgressRootExitStatus = RootErrored; + } +} + +function inferTimeFromExpirationTime(expirationTime: ExpirationTime): number { + // We don't know exactly when the update was scheduled, but we can infer an + // approximate start time from the expiration time. + const earliestExpirationTimeMs = expirationTimeToMs(expirationTime); + return earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION + initialTimeMs; +} + +function workLoopSync() { + // Already timed out, so perform work without checking if we need to yield. + while (workInProgress !== null) { + workInProgress = performUnitOfWork(workInProgress); + } +} + +function workLoop() { + // Perform work until Scheduler asks us to yield + while (workInProgress !== null && !shouldYield()) { + workInProgress = performUnitOfWork(workInProgress); + } +} + +function performUnitOfWork(unitOfWork: Fiber): Fiber | null { + // The current, flushed, state of this fiber is the alternate. Ideally + // nothing should rely on this, but relying on it here means that we don't + // need an additional field on the work in progress. + const current = unitOfWork.alternate; + + startWorkTimer(unitOfWork); + setCurrentDebugFiberInDEV(unitOfWork); + + let next; + if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoContext) { + startProfilerTimer(unitOfWork); + next = beginWork(current, unitOfWork, renderExpirationTime); + stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true); + } else { + next = beginWork(current, unitOfWork, renderExpirationTime); + } + + resetCurrentDebugFiberInDEV(); + unitOfWork.memoizedProps = unitOfWork.pendingProps; + if (next === null) { + // If this doesn't spawn new work, complete the current work. + next = completeUnitOfWork(unitOfWork); + } + + ReactCurrentOwner.current = null; + return next; +} + +function completeUnitOfWork(unitOfWork: Fiber): Fiber | null { + // Attempt to complete the current unit of work, then move to the next + // sibling. If there are no more siblings, return to the parent fiber. + workInProgress = unitOfWork; + do { + // The current, flushed, state of this fiber is the alternate. Ideally + // nothing should rely on this, but relying on it here means that we don't + // need an additional field on the work in progress. + const current = workInProgress.alternate; + const returnFiber = workInProgress.return; + + // Check if the work completed or if something threw. + if ((workInProgress.effectTag & Incomplete) === NoEffect) { + setCurrentDebugFiberInDEV(workInProgress); + let next; + if ( + !enableProfilerTimer || + (workInProgress.mode & ProfileMode) === NoContext + ) { + next = completeWork(current, workInProgress, renderExpirationTime); + } else { + startProfilerTimer(workInProgress); + next = completeWork(current, workInProgress, renderExpirationTime); + // Update render duration assuming we didn't error. + stopProfilerTimerIfRunningAndRecordDelta(workInProgress, false); + } + stopWorkTimer(workInProgress); + resetCurrentDebugFiberInDEV(); + resetChildExpirationTime(workInProgress); + + if (next !== null) { + // Completing this fiber spawned new work. Work on that next. + return next; + } + + if ( + returnFiber !== null && + // Do not append effects to parents if a sibling failed to complete + (returnFiber.effectTag & Incomplete) === NoEffect + ) { + // Append all the effects of the subtree and this fiber onto the effect + // list of the parent. The completion order of the children affects the + // side-effect order. + if (returnFiber.firstEffect === null) { + returnFiber.firstEffect = workInProgress.firstEffect; + } + if (workInProgress.lastEffect !== null) { + if (returnFiber.lastEffect !== null) { + returnFiber.lastEffect.nextEffect = workInProgress.firstEffect; + } + returnFiber.lastEffect = workInProgress.lastEffect; + } + + // If this fiber had side-effects, we append it AFTER the children's + // side-effects. We can perform certain side-effects earlier if needed, + // by doing multiple passes over the effect list. We don't want to + // schedule our own side-effect on our own list because if end up + // reusing children we'll schedule this effect onto itself since we're + // at the end. + const effectTag = workInProgress.effectTag; + + // Skip both NoWork and PerformedWork tags when creating the effect + // list. PerformedWork effect is read by React DevTools but shouldn't be + // committed. + if (effectTag > PerformedWork) { + if (returnFiber.lastEffect !== null) { + returnFiber.lastEffect.nextEffect = workInProgress; + } else { + returnFiber.firstEffect = workInProgress; + } + returnFiber.lastEffect = workInProgress; + } + } + } else { + // This fiber did not complete because something threw. Pop values off + // the stack without entering the complete phase. If this is a boundary, + // capture values if possible. + const next = unwindWork(workInProgress, renderExpirationTime); + + // Because this fiber did not complete, don't reset its expiration time. + + if ( + enableProfilerTimer && + (workInProgress.mode & ProfileMode) !== NoContext + ) { + // Record the render duration for the fiber that errored. + stopProfilerTimerIfRunningAndRecordDelta(workInProgress, false); + + // Include the time spent working on failed children before continuing. + let actualDuration = workInProgress.actualDuration; + let child = workInProgress.child; + while (child !== null) { + actualDuration += child.actualDuration; + child = child.sibling; + } + workInProgress.actualDuration = actualDuration; + } + + if (next !== null) { + // If completing this work spawned new work, do that next. We'll come + // back here again. + // Since we're restarting, remove anything that is not a host effect + // from the effect tag. + // TODO: The name stopFailedWorkTimer is misleading because Suspense + // also captures and restarts. + stopFailedWorkTimer(workInProgress); + next.effectTag &= HostEffectMask; + return next; + } + stopWorkTimer(workInProgress); + + if (returnFiber !== null) { + // Mark the parent fiber as incomplete and clear its effect list. + returnFiber.firstEffect = returnFiber.lastEffect = null; + returnFiber.effectTag |= Incomplete; + } + } + + const siblingFiber = workInProgress.sibling; + if (siblingFiber !== null) { + // If there is more work to do in this returnFiber, do that next. + return siblingFiber; + } + // Otherwise, return to the parent + workInProgress = returnFiber; + } while (workInProgress !== null); + + // We've reached the root. + if (workInProgressRootExitStatus === RootIncomplete) { + workInProgressRootExitStatus = RootCompleted; + } + return null; +} + +function resetChildExpirationTime(completedWork: Fiber) { + if ( + renderExpirationTime !== Never && + completedWork.childExpirationTime === Never + ) { + // The children of this component are hidden. Don't bubble their + // expiration times. + return; + } + + let newChildExpirationTime = NoWork; + + // Bubble up the earliest expiration time. + if (enableProfilerTimer && (completedWork.mode & ProfileMode) !== NoContext) { + // In profiling mode, resetChildExpirationTime is also used to reset + // profiler durations. + let actualDuration = completedWork.actualDuration; + let treeBaseDuration = completedWork.selfBaseDuration; + + // When a fiber is cloned, its actualDuration is reset to 0. This value will + // only be updated if work is done on the fiber (i.e. it doesn't bailout). + // When work is done, it should bubble to the parent's actualDuration. If + // the fiber has not been cloned though, (meaning no work was done), then + // this value will reflect the amount of time spent working on a previous + // render. In that case it should not bubble. We determine whether it was + // cloned by comparing the child pointer. + const shouldBubbleActualDurations = + completedWork.alternate === null || + completedWork.child !== completedWork.alternate.child; + + let child = completedWork.child; + while (child !== null) { + const childUpdateExpirationTime = child.expirationTime; + const childChildExpirationTime = child.childExpirationTime; + if (childUpdateExpirationTime > newChildExpirationTime) { + newChildExpirationTime = childUpdateExpirationTime; + } + if (childChildExpirationTime > newChildExpirationTime) { + newChildExpirationTime = childChildExpirationTime; + } + if (shouldBubbleActualDurations) { + actualDuration += child.actualDuration; + } + treeBaseDuration += child.treeBaseDuration; + child = child.sibling; + } + completedWork.actualDuration = actualDuration; + completedWork.treeBaseDuration = treeBaseDuration; + } else { + let child = completedWork.child; + while (child !== null) { + const childUpdateExpirationTime = child.expirationTime; + const childChildExpirationTime = child.childExpirationTime; + if (childUpdateExpirationTime > newChildExpirationTime) { + newChildExpirationTime = childUpdateExpirationTime; + } + if (childChildExpirationTime > newChildExpirationTime) { + newChildExpirationTime = childChildExpirationTime; + } + child = child.sibling; + } + } + + completedWork.childExpirationTime = newChildExpirationTime; +} + +function commitRoot(root, expirationTime) { + runWithPriority( + ImmediatePriority, + commitRootImpl.bind(null, root, expirationTime), + ); + // If there are passive effects, schedule a callback to flush them. This goes + // outside commitRootImpl so that it inherits the priority of the render. + if (rootWithPendingPassiveEffects !== null) { + const priorityLevel = getCurrentPriorityLevel(); + scheduleCallback(priorityLevel, () => { + flushPassiveEffects(); + return null; + }); + } + return null; +} + +function commitRootImpl(root, expirationTime) { + flushPassiveEffects(); + flushRenderPhaseStrictModeWarningsInDEV(); + + invariant( + workPhase !== RenderPhase && workPhase !== CommitPhase, + 'Should not already be working.', + ); + const finishedWork = root.current.alternate; + invariant(finishedWork !== null, 'Should have a work-in-progress root.'); + + // commitRoot never returns a continuation; it always finishes synchronously. + // So we can clear these now to allow a new callback to be scheduled. + root.callbackNode = null; + root.callbackExpirationTime = NoWork; + + startCommitTimer(); + + // Update the first and last pending times on this root. The new first + // pending time is whatever is left on the root fiber. + const updateExpirationTimeBeforeCommit = finishedWork.expirationTime; + const childExpirationTimeBeforeCommit = finishedWork.childExpirationTime; + const firstPendingTimeBeforeCommit = + childExpirationTimeBeforeCommit > updateExpirationTimeBeforeCommit + ? childExpirationTimeBeforeCommit + : updateExpirationTimeBeforeCommit; + root.firstPendingTime = firstPendingTimeBeforeCommit; + if (firstPendingTimeBeforeCommit < root.lastPendingTime) { + // This usually means we've finished all the work, but it can also happen + // when something gets downprioritized during render, like a hidden tree. + root.lastPendingTime = firstPendingTimeBeforeCommit; + } + + if (root === workInProgressRoot) { + // We can reset these now that they are finished. + workInProgressRoot = null; + workInProgress = null; + renderExpirationTime = NoWork; + } else { + // This indicates that the last root we worked on is not the same one that + // we're committing now. This most commonly happens when a suspended root + // times out. + } + + // Get the list of effects. + let firstEffect; + if (finishedWork.effectTag > PerformedWork) { + // A fiber's effect list consists only of its children, not itself. So if + // the root has an effect, we need to add it to the end of the list. The + // resulting list is the set that would belong to the root's parent, if it + // had one; that is, all the effects in the tree including the root. + if (finishedWork.lastEffect !== null) { + finishedWork.lastEffect.nextEffect = finishedWork; + firstEffect = finishedWork.firstEffect; + } else { + firstEffect = finishedWork; + } + } else { + // There is no effect on the root. + firstEffect = finishedWork.firstEffect; + } + + if (firstEffect !== null) { + const prevWorkPhase = workPhase; + workPhase = CommitPhase; + let prevInteractions: Set | null = null; + if (enableSchedulerTracing) { + prevInteractions = __interactionsRef.current; + __interactionsRef.current = root.memoizedInteractions; + } + + // Reset this to null before calling lifecycles + ReactCurrentOwner.current = null; + + // The commit phase is broken into several sub-phases. We do a separate pass + // of the effect list for each phase: all mutation effects come before all + // layout effects, and so on. + + // The first phase a "before mutation" phase. We use this phase to read the + // state of the host tree right before we mutate it. This is where + // getSnapshotBeforeUpdate is called. + startCommitSnapshotEffectsTimer(); + prepareForCommit(root.containerInfo); + nextEffect = firstEffect; + do { + if (__DEV__) { + invokeGuardedCallback(null, commitBeforeMutationEffects, null); + if (hasCaughtError()) { + invariant(nextEffect !== null, 'Should be working on an effect.'); + const error = clearCaughtError(); + captureCommitPhaseError(nextEffect, error); + nextEffect = nextEffect.nextEffect; + } + } else { + try { + commitBeforeMutationEffects(); + } catch (error) { + invariant(nextEffect !== null, 'Should be working on an effect.'); + captureCommitPhaseError(nextEffect, error); + nextEffect = nextEffect.nextEffect; + } + } + } while (nextEffect !== null); + stopCommitSnapshotEffectsTimer(); + + if (enableProfilerTimer) { + // Mark the current commit time to be shared by all Profilers in this + // batch. This enables them to be grouped later. + recordCommitTime(); + } + + // The next phase is the mutation phase, where we mutate the host tree. + startCommitHostEffectsTimer(); + nextEffect = firstEffect; + do { + if (__DEV__) { + invokeGuardedCallback(null, commitMutationEffects, null); + if (hasCaughtError()) { + invariant(nextEffect !== null, 'Should be working on an effect.'); + const error = clearCaughtError(); + captureCommitPhaseError(nextEffect, error); + nextEffect = nextEffect.nextEffect; + } + } else { + try { + commitMutationEffects(); + } catch (error) { + invariant(nextEffect !== null, 'Should be working on an effect.'); + captureCommitPhaseError(nextEffect, error); + nextEffect = nextEffect.nextEffect; + } + } + } while (nextEffect !== null); + stopCommitHostEffectsTimer(); + resetAfterCommit(root.containerInfo); + + // The work-in-progress tree is now the current tree. This must come after + // the mutation phase, so that the previous tree is still current during + // componentWillUnmount, but before the layout phase, so that the finished + // work is current during componentDidMount/Update. + root.current = finishedWork; + + // The next phase is the layout phase, where we call effects that read + // the host tree after it's been mutated. The idiomatic use case for this is + // layout, but class component lifecycles also fire here for legacy reasons. + startCommitLifeCyclesTimer(); + nextEffect = firstEffect; + do { + if (__DEV__) { + invokeGuardedCallback( + null, + commitLayoutEffects, + null, + root, + expirationTime, + ); + if (hasCaughtError()) { + invariant(nextEffect !== null, 'Should be working on an effect.'); + const error = clearCaughtError(); + captureCommitPhaseError(nextEffect, error); + nextEffect = nextEffect.nextEffect; + } + } else { + try { + commitLayoutEffects(root, expirationTime); + } catch (error) { + invariant(nextEffect !== null, 'Should be working on an effect.'); + captureCommitPhaseError(nextEffect, error); + nextEffect = nextEffect.nextEffect; + } + } + } while (nextEffect !== null); + stopCommitLifeCyclesTimer(); + + nextEffect = null; + + if (enableSchedulerTracing) { + __interactionsRef.current = ((prevInteractions: any): Set); + } + workPhase = prevWorkPhase; + } else { + // No effects. + root.current = finishedWork; + // Measure these anyway so the flamegraph explicitly shows that there were + // no effects. + // TODO: Maybe there's a better way to report this. + startCommitSnapshotEffectsTimer(); + stopCommitSnapshotEffectsTimer(); + if (enableProfilerTimer) { + recordCommitTime(); + } + startCommitHostEffectsTimer(); + stopCommitHostEffectsTimer(); + startCommitLifeCyclesTimer(); + stopCommitLifeCyclesTimer(); + } + + stopCommitTimer(); + + if (rootDoesHavePassiveEffects) { + // This commit has passive effects. Stash a reference to them. But don't + // schedule a callback until after flushing layout work. + rootDoesHavePassiveEffects = false; + rootWithPendingPassiveEffects = root; + pendingPassiveEffectsExpirationTime = expirationTime; + } else { + if (enableSchedulerTracing) { + // If there are no passive effects, then we can complete the pending + // interactions. Otherwise, we'll wait until after the passive effects + // are flushed. + finishPendingInteractions(root, expirationTime); + } + } + + // Check if there's remaining work on this root + const remainingExpirationTime = root.firstPendingTime; + if (remainingExpirationTime !== NoWork) { + const currentTime = requestCurrentTime(); + const priorityLevel = inferPriorityFromExpirationTime( + currentTime, + remainingExpirationTime, + ); + scheduleCallbackForRoot(root, priorityLevel, remainingExpirationTime); + } else { + // If there's no remaining work, we can clear the set of already failed + // error boundaries. + legacyErrorBoundariesThatAlreadyFailed = null; + } + + onCommitRoot(finishedWork.stateNode); + + if (remainingExpirationTime === Sync) { + // Count the number of times the root synchronously re-renders without + // finishing. If there are too many, it indicates an infinite update loop. + if (root === rootWithNestedUpdates) { + nestedUpdateCount++; + } else { + nestedUpdateCount = 0; + rootWithNestedUpdates = root; + } + } else { + nestedUpdateCount = 0; + } + + if (hasUncaughtError) { + hasUncaughtError = false; + const error = firstUncaughtError; + firstUncaughtError = null; + throw error; + } + + if (workPhase === LegacyUnbatchedPhase) { + // This is a legacy edge case. We just committed the initial mount of + // a ReactDOM.render-ed root inside of batchedUpdates. The commit fired + // synchronously, but layout updates should be deferred until the end + // of the batch. + return null; + } + + // If layout work was scheduled, flush it now. + flushImmediateQueue(); + return null; +} + +function commitBeforeMutationEffects() { + while (nextEffect !== null) { + if ((nextEffect.effectTag & Snapshot) !== NoEffect) { + setCurrentDebugFiberInDEV(nextEffect); + recordEffect(); + + const current = nextEffect.alternate; + commitBeforeMutationEffectOnFiber(current, nextEffect); + + resetCurrentDebugFiberInDEV(); + } + nextEffect = nextEffect.nextEffect; + } +} + +function commitMutationEffects() { + // TODO: Should probably move the bulk of this function to commitWork. + while (nextEffect !== null) { + setCurrentDebugFiberInDEV(nextEffect); + + const effectTag = nextEffect.effectTag; + + if (effectTag & ContentReset) { + commitResetTextContent(nextEffect); + } + + if (effectTag & Ref) { + const current = nextEffect.alternate; + if (current !== null) { + commitDetachRef(current); + } + } + + // The following switch statement is only concerned about placement, + // updates, and deletions. To avoid needing to add a case for every possible + // bitmap value, we remove the secondary effects from the effect tag and + // switch on that value. + let primaryEffectTag = effectTag & (Placement | Update | Deletion); + switch (primaryEffectTag) { + case Placement: { + commitPlacement(nextEffect); + // Clear the "placement" from effect tag so that we know that this is + // inserted, before any life-cycles like componentDidMount gets called. + // TODO: findDOMNode doesn't rely on this any more but isMounted does + // and isMounted is deprecated anyway so we should be able to kill this. + nextEffect.effectTag &= ~Placement; + break; + } + case PlacementAndUpdate: { + // Placement + commitPlacement(nextEffect); + // Clear the "placement" from effect tag so that we know that this is + // inserted, before any life-cycles like componentDidMount gets called. + nextEffect.effectTag &= ~Placement; + + // Update + const current = nextEffect.alternate; + commitWork(current, nextEffect); + break; + } + case Update: { + const current = nextEffect.alternate; + commitWork(current, nextEffect); + break; + } + case Deletion: { + commitDeletion(nextEffect); + break; + } + } + + // TODO: Only record a mutation effect if primaryEffectTag is non-zero. + recordEffect(); + + resetCurrentDebugFiberInDEV(); + nextEffect = nextEffect.nextEffect; + } +} + +function commitLayoutEffects( + root: FiberRoot, + committedExpirationTime: ExpirationTime, +) { + // TODO: Should probably move the bulk of this function to commitWork. + while (nextEffect !== null) { + setCurrentDebugFiberInDEV(nextEffect); + + const effectTag = nextEffect.effectTag; + + if (effectTag & (Update | Callback)) { + recordEffect(); + const current = nextEffect.alternate; + commitLayoutEffectOnFiber( + root, + current, + nextEffect, + committedExpirationTime, + ); + } + + if (effectTag & Ref) { + recordEffect(); + commitAttachRef(nextEffect); + } + + if (effectTag & Passive) { + rootDoesHavePassiveEffects = true; + } + + resetCurrentDebugFiberInDEV(); + nextEffect = nextEffect.nextEffect; + } +} + +export function flushPassiveEffects() { + if (rootWithPendingPassiveEffects === null) { + return false; + } + const root = rootWithPendingPassiveEffects; + const expirationTime = pendingPassiveEffectsExpirationTime; + rootWithPendingPassiveEffects = null; + pendingPassiveEffectsExpirationTime = NoWork; + + let prevInteractions: Set | null = null; + if (enableSchedulerTracing) { + prevInteractions = __interactionsRef.current; + __interactionsRef.current = root.memoizedInteractions; + } + + invariant( + workPhase !== RenderPhase && workPhase !== CommitPhase, + 'Cannot flush passive effects while already rendering.', + ); + const prevWorkPhase = workPhase; + workPhase = CommitPhase; + + // Note: This currently assumes there are no passive effects on the root + // fiber, because the root is not part of its own effect list. This could + // change in the future. + let effect = root.current.firstEffect; + while (effect !== null) { + if (__DEV__) { + setCurrentDebugFiberInDEV(effect); + invokeGuardedCallback(null, commitPassiveHookEffects, null, effect); + if (hasCaughtError()) { + invariant(effect !== null, 'Should be working on an effect.'); + const error = clearCaughtError(); + captureCommitPhaseError(effect, error); + } + resetCurrentDebugFiberInDEV(); + } else { + try { + commitPassiveHookEffects(effect); + } catch (error) { + invariant(effect !== null, 'Should be working on an effect.'); + captureCommitPhaseError(effect, error); + } + } + effect = effect.nextEffect; + } + + if (enableSchedulerTracing) { + __interactionsRef.current = ((prevInteractions: any): Set); + finishPendingInteractions(root, expirationTime); + } + + workPhase = prevWorkPhase; + flushImmediateQueue(); + + // If additional passive effects were scheduled, increment a counter. If this + // exceeds the limit, we'll fire a warning. + nestedPassiveUpdateCount = + rootWithPendingPassiveEffects === null ? 0 : nestedPassiveUpdateCount + 1; + + return true; +} + +export function isAlreadyFailedLegacyErrorBoundary(instance: mixed): boolean { + return ( + legacyErrorBoundariesThatAlreadyFailed !== null && + legacyErrorBoundariesThatAlreadyFailed.has(instance) + ); +} + +export function markLegacyErrorBoundaryAsFailed(instance: mixed) { + if (legacyErrorBoundariesThatAlreadyFailed === null) { + legacyErrorBoundariesThatAlreadyFailed = new Set([instance]); + } else { + legacyErrorBoundariesThatAlreadyFailed.add(instance); + } +} + +function prepareToThrowUncaughtError(error: mixed) { + if (!hasUncaughtError) { + hasUncaughtError = true; + firstUncaughtError = error; + } +} +export const onUncaughtError = prepareToThrowUncaughtError; + +function captureCommitPhaseErrorOnRoot( + rootFiber: Fiber, + sourceFiber: Fiber, + error: mixed, +) { + const errorInfo = createCapturedValue(error, sourceFiber); + const update = createRootErrorUpdate(rootFiber, errorInfo, Sync); + enqueueUpdate(rootFiber, update); + const root = markUpdateTimeFromFiberToRoot(rootFiber, Sync); + if (root !== null) { + scheduleCallbackForRoot(root, ImmediatePriority, Sync); + } +} + +export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) { + if (sourceFiber.tag === HostRoot) { + // Error was thrown at the root. There is no parent, so the root + // itself should capture it. + captureCommitPhaseErrorOnRoot(sourceFiber, sourceFiber, error); + return; + } + + let fiber = sourceFiber.return; + while (fiber !== null) { + if (fiber.tag === HostRoot) { + captureCommitPhaseErrorOnRoot(fiber, sourceFiber, error); + return; + } else if (fiber.tag === ClassComponent) { + const ctor = fiber.type; + const instance = fiber.stateNode; + if ( + typeof ctor.getDerivedStateFromError === 'function' || + (typeof instance.componentDidCatch === 'function' && + !isAlreadyFailedLegacyErrorBoundary(instance)) + ) { + const errorInfo = createCapturedValue(error, sourceFiber); + const update = createClassErrorUpdate( + fiber, + errorInfo, + // TODO: This is always sync + Sync, + ); + enqueueUpdate(fiber, update); + const root = markUpdateTimeFromFiberToRoot(fiber, Sync); + if (root !== null) { + scheduleCallbackForRoot(root, ImmediatePriority, Sync); + } + return; + } + } + fiber = fiber.return; + } +} + +export function pingSuspendedRoot( + root: FiberRoot, + thenable: Thenable, + suspendedTime: ExpirationTime, +) { + const pingCache = root.pingCache; + if (pingCache !== null) { + // The thenable resolved, so we no longer need to memoize, because it will + // never be thrown again. + pingCache.delete(thenable); + } + + if (workInProgressRoot === root && renderExpirationTime === suspendedTime) { + // Received a ping at the same priority level at which we're currently + // rendering. Restart from the root. Don't need to schedule a ping because + // we're already working on this tree. + prepareFreshStack(root, renderExpirationTime); + return; + } + + const lastPendingTime = root.lastPendingTime; + if (lastPendingTime < suspendedTime) { + // The root is no longer suspended at this time. + return; + } + + const pingTime = root.pingTime; + if (pingTime !== NoWork && pingTime < suspendedTime) { + // There's already a lower priority ping scheduled. + return; + } + + // Mark the time at which this ping was scheduled. + root.pingTime = suspendedTime; + + const currentTime = requestCurrentTime(); + const priorityLevel = inferPriorityFromExpirationTime( + currentTime, + suspendedTime, + ); + scheduleCallbackForRoot(root, priorityLevel, suspendedTime); +} + +export function retryTimedOutBoundary(boundaryFiber: Fiber) { + // The boundary fiber (a Suspense component) previously timed out and was + // rendered in its fallback state. One of the promises that suspended it has + // resolved, which means at least part of the tree was likely unblocked. Try + // rendering again, at a new expiration time. + const currentTime = requestCurrentTime(); + const retryTime = computeExpirationForFiber(currentTime, boundaryFiber); + // TODO: Special case idle priority? + const priorityLevel = inferPriorityFromExpirationTime(currentTime, retryTime); + const root = markUpdateTimeFromFiberToRoot(boundaryFiber, retryTime); + if (root !== null) { + scheduleCallbackForRoot(root, priorityLevel, retryTime); + } +} + +export function resolveRetryThenable(boundaryFiber: Fiber, thenable: Thenable) { + let retryCache: WeakSet | Set | null; + if (enableSuspenseServerRenderer) { + switch (boundaryFiber.tag) { + case SuspenseComponent: + retryCache = boundaryFiber.stateNode; + break; + case DehydratedSuspenseComponent: + retryCache = boundaryFiber.memoizedState; + break; + default: + invariant( + false, + 'Pinged unknown suspense boundary type. ' + + 'This is probably a bug in React.', + ); + } + } else { + retryCache = boundaryFiber.stateNode; + } + + if (retryCache !== null) { + // The thenable resolved, so we no longer need to memoize, because it will + // never be thrown again. + retryCache.delete(thenable); + } + + retryTimedOutBoundary(boundaryFiber); +} + +// Computes the next Just Noticeable Difference (JND) boundary. +// The theory is that a person can't tell the difference between small differences in time. +// Therefore, if we wait a bit longer than necessary that won't translate to a noticeable +// difference in the experience. However, waiting for longer might mean that we can avoid +// showing an intermediate loading state. The longer we have already waited, the harder it +// is to tell small differences in time. Therefore, the longer we've already waited, +// the longer we can wait additionally. At some point we have to give up though. +// We pick a train model where the next boundary commits at a consistent schedule. +// These particular numbers are vague estimates. We expect to adjust them based on research. +function jnd(timeElapsed: number) { + return timeElapsed < 120 + ? 120 + : timeElapsed < 480 + ? 480 + : timeElapsed < 1080 + ? 1080 + : timeElapsed < 1920 + ? 1920 + : timeElapsed < 3000 + ? 3000 + : timeElapsed < 4320 + ? 4320 + : ceil(timeElapsed / 1960) * 1960; +} + +function computeMsUntilTimeout( + mostRecentEventTime: ExpirationTime, + committedExpirationTime: ExpirationTime, +) { + if (disableYielding) { + // Timeout immediately when yielding is disabled. + return 0; + } + + const eventTimeMs: number = inferTimeFromExpirationTime(mostRecentEventTime); + const currentTimeMs: number = now(); + const timeElapsed = currentTimeMs - eventTimeMs; + + let msUntilTimeout = jnd(timeElapsed) - timeElapsed; + + // Compute the time until this render pass would expire. + const timeUntilExpirationMs = + expirationTimeToMs(committedExpirationTime) + initialTimeMs - currentTimeMs; + + // Clamp the timeout to the expiration time. + // TODO: Once the event time is exact instead of inferred from expiration time + // we don't need this. + if (timeUntilExpirationMs < msUntilTimeout) { + msUntilTimeout = timeUntilExpirationMs; + } + + // This is the value that is passed to `setTimeout`. + return msUntilTimeout; +} + +function checkForNestedUpdates() { + if (nestedUpdateCount > NESTED_UPDATE_LIMIT) { + nestedUpdateCount = 0; + rootWithNestedUpdates = null; + invariant( + false, + 'Maximum update depth exceeded. This can happen when a component ' + + 'repeatedly calls setState inside componentWillUpdate or ' + + 'componentDidUpdate. React limits the number of nested updates to ' + + 'prevent infinite loops.', + ); + } + + if (__DEV__) { + if (nestedPassiveUpdateCount > NESTED_PASSIVE_UPDATE_LIMIT) { + nestedPassiveUpdateCount = 0; + warning( + false, + 'Maximum update depth exceeded. This can happen when a component ' + + "calls setState inside useEffect, but useEffect either doesn't " + + 'have a dependency array, or one of the dependencies changes on ' + + 'every render.', + ); + } + } +} + +function flushRenderPhaseStrictModeWarningsInDEV() { + if (__DEV__) { + ReactStrictModeWarnings.flushPendingUnsafeLifecycleWarnings(); + ReactStrictModeWarnings.flushLegacyContextWarning(); + + if (warnAboutDeprecatedLifecycles) { + ReactStrictModeWarnings.flushPendingDeprecationWarnings(); + } + } +} + +function stopFinishedWorkLoopTimer() { + const didCompleteRoot = true; + stopWorkLoopTimer(interruptedBy, didCompleteRoot); + interruptedBy = null; +} + +function stopInterruptedWorkLoopTimer() { + // TODO: Track which fiber caused the interruption. + const didCompleteRoot = false; + stopWorkLoopTimer(interruptedBy, didCompleteRoot); + interruptedBy = null; +} + +function checkForInterruption( + fiberThatReceivedUpdate: Fiber, + updateExpirationTime: ExpirationTime, +) { + if ( + enableUserTimingAPI && + workInProgressRoot !== null && + updateExpirationTime > renderExpirationTime + ) { + interruptedBy = fiberThatReceivedUpdate; + } +} + +let didWarnStateUpdateForUnmountedComponent: Set | null = null; +function warnAboutUpdateOnUnmountedFiberInDEV(fiber) { + if (__DEV__) { + const tag = fiber.tag; + if ( + tag !== HostRoot && + tag !== ClassComponent && + tag !== FunctionComponent && + tag !== ForwardRef && + tag !== MemoComponent && + tag !== SimpleMemoComponent + ) { + // Only warn for user-defined components, not internal ones like Suspense. + return; + } + // We show the whole stack but dedupe on the top component's name because + // the problematic code almost always lies inside that component. + const componentName = getComponentName(fiber.type) || 'ReactComponent'; + if (didWarnStateUpdateForUnmountedComponent !== null) { + if (didWarnStateUpdateForUnmountedComponent.has(componentName)) { + return; + } + didWarnStateUpdateForUnmountedComponent.add(componentName); + } else { + didWarnStateUpdateForUnmountedComponent = new Set([componentName]); + } + warningWithoutStack( + false, + "Can't perform a React state update on an unmounted component. This " + + 'is a no-op, but it indicates a memory leak in your application. To ' + + 'fix, cancel all subscriptions and asynchronous tasks in %s.%s', + tag === ClassComponent + ? 'the componentWillUnmount method' + : 'a useEffect cleanup function', + getStackByFiberInDevAndProd(fiber), + ); + } +} + +let beginWork; +if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { + let dummyFiber = null; + beginWork = (current, unitOfWork, expirationTime) => { + // If a component throws an error, we replay it again in a synchronously + // dispatched event, so that the debugger will treat it as an uncaught + // error See ReactErrorUtils for more information. + + // Before entering the begin phase, copy the work-in-progress onto a dummy + // fiber. If beginWork throws, we'll use this to reset the state. + const originalWorkInProgressCopy = assignFiberPropertiesInDEV( + dummyFiber, + unitOfWork, + ); + try { + return originalBeginWork(current, unitOfWork, expirationTime); + } catch (originalError) { + if ( + originalError !== null && + typeof originalError === 'object' && + typeof originalError.then === 'function' + ) { + // Don't replay promises. Treat everything else like an error. + throw originalError; + } + + // Keep this code in sync with renderRoot; any changes here must have + // corresponding changes there. + resetContextDependencies(); + resetHooks(); + + // Unwind the failed stack frame + unwindInterruptedWork(unitOfWork); + + // Restore the original properties of the fiber. + assignFiberPropertiesInDEV(unitOfWork, originalWorkInProgressCopy); + + if (enableProfilerTimer && unitOfWork.mode & ProfileMode) { + // Reset the profiler timer. + startProfilerTimer(unitOfWork); + } + + // Run beginWork again. + invokeGuardedCallback( + null, + originalBeginWork, + null, + current, + unitOfWork, + expirationTime, + ); + + if (hasCaughtError()) { + const replayError = clearCaughtError(); + // `invokeGuardedCallback` sometimes sets an expando `_suppressLogging`. + // Rethrow this error instead of the original one. + throw replayError; + } else { + // This branch is reachable if the render phase is impure. + throw originalError; + } + } + }; +} else { + beginWork = originalBeginWork; +} + +let didWarnAboutUpdateInRender = false; +let didWarnAboutUpdateInGetChildContext = false; +function warnAboutInvalidUpdatesOnClassComponentsInDEV(fiber) { + if (__DEV__) { + if (fiber.tag === ClassComponent) { + switch (ReactCurrentDebugFiberPhaseInDEV) { + case 'getChildContext': + if (didWarnAboutUpdateInGetChildContext) { + return; + } + warningWithoutStack( + false, + 'setState(...): Cannot call setState() inside getChildContext()', + ); + didWarnAboutUpdateInGetChildContext = true; + break; + case 'render': + if (didWarnAboutUpdateInRender) { + return; + } + warningWithoutStack( + false, + 'Cannot update during an existing state transition (such as ' + + 'within `render`). Render methods should be a pure function of ' + + 'props and state.', + ); + didWarnAboutUpdateInRender = true; + break; + } + } + } +} + +export function warnIfNotScopedWithMatchingAct(fiber: Fiber): void { + if (__DEV__) { + if ( + ReactShouldWarnActingUpdates.current !== null && + ReactShouldWarnActingUpdates.current !== ReactActingUpdatesSigil + ) { + // it looks like we're using the wrong matching act(), so log a warning + warningWithoutStack( + false, + "It looks like you're using the wrong act() around your interactions.\n" + + 'Be sure to use the matching version of act() corresponding to your renderer. e.g. -\n' + + "for react-dom, import {act} from 'react-dom/test-utils';\n" + + 'for react-test-renderer, const {act} = TestRenderer.' + + '%s', + getStackByFiberInDevAndProd(fiber), + ); + } + } +} + +// in a test-like environment, we want to warn if dispatchAction() is +// called outside of a TestUtils.act(...)/batchedUpdates/render call. +// so we have a a step counter for when we descend/ascend from +// act() calls, and test on it for when to warn +// It's a tuple with a single value. Look into ReactTestUtilsAct as an +// example of how we change the value + +function warnIfNotCurrentlyActingUpdatesInDEV(fiber: Fiber): void { + if (__DEV__) { + if ( + workPhase === NotWorking && + ReactShouldWarnActingUpdates.current !== ReactActingUpdatesSigil + ) { + warningWithoutStack( + false, + 'An update to %s inside a test was not wrapped in act(...).\n\n' + + 'When testing, code that causes React state updates should be ' + + 'wrapped into act(...):\n\n' + + 'act(() => {\n' + + ' /* fire events that update state */\n' + + '});\n' + + '/* assert on the output */\n\n' + + "This ensures that you're testing the behavior the user would see " + + 'in the browser.' + + ' Learn more at https://fb.me/react-wrap-tests-with-act' + + '%s', + getComponentName(fiber.type), + getStackByFiberInDevAndProd(fiber), + ); + } + } +} + +export const warnIfNotCurrentlyActingUpdatesInDev = warnIfNotCurrentlyActingUpdatesInDEV; + +function computeThreadID(root, expirationTime) { + // Interaction threads are unique per root and expiration time. + return expirationTime * 1000 + root.interactionThreadID; +} + +function schedulePendingInteraction(root, expirationTime) { + // This is called when work is scheduled on a root. It sets up a pending + // interaction, which is completed once the work commits. + if (!enableSchedulerTracing) { + return; + } + + const interactions = __interactionsRef.current; + if (interactions.size > 0) { + const pendingInteractionMap = root.pendingInteractionMap; + const pendingInteractions = pendingInteractionMap.get(expirationTime); + if (pendingInteractions != null) { + interactions.forEach(interaction => { + if (!pendingInteractions.has(interaction)) { + // Update the pending async work count for previously unscheduled interaction. + interaction.__count++; + } + + pendingInteractions.add(interaction); + }); + } else { + pendingInteractionMap.set(expirationTime, new Set(interactions)); + + // Update the pending async work count for the current interactions. + interactions.forEach(interaction => { + interaction.__count++; + }); + } + + const subscriber = __subscriberRef.current; + if (subscriber !== null) { + const threadID = computeThreadID(root, expirationTime); + subscriber.onWorkScheduled(interactions, threadID); + } + } +} + +function startWorkOnPendingInteraction(root, expirationTime) { + // This is called when new work is started on a root. + if (!enableSchedulerTracing) { + return; + } + + // Determine which interactions this batch of work currently includes, So that + // we can accurately attribute time spent working on it, And so that cascading + // work triggered during the render phase will be associated with it. + const interactions: Set = new Set(); + root.pendingInteractionMap.forEach( + (scheduledInteractions, scheduledExpirationTime) => { + if (scheduledExpirationTime >= expirationTime) { + scheduledInteractions.forEach(interaction => + interactions.add(interaction), + ); + } + }, + ); + + // Store the current set of interactions on the FiberRoot for a few reasons: + // We can re-use it in hot functions like renderRoot() without having to + // recalculate it. We will also use it in commitWork() to pass to any Profiler + // onRender() hooks. This also provides DevTools with a way to access it when + // the onCommitRoot() hook is called. + root.memoizedInteractions = interactions; + + if (interactions.size > 0) { + const subscriber = __subscriberRef.current; + if (subscriber !== null) { + const threadID = computeThreadID(root, expirationTime); + try { + subscriber.onWorkStarted(interactions, threadID); + } catch (error) { + // If the subscriber throws, rethrow it in a separate task + scheduleCallback(ImmediatePriority, () => { + throw error; + }); + } + } + } +} + +function finishPendingInteractions(root, committedExpirationTime) { + if (!enableSchedulerTracing) { + return; + } + + const earliestRemainingTimeAfterCommit = root.firstPendingTime; + + let subscriber; + + try { + subscriber = __subscriberRef.current; + if (subscriber !== null && root.memoizedInteractions.size > 0) { + const threadID = computeThreadID(root, committedExpirationTime); + subscriber.onWorkStopped(root.memoizedInteractions, threadID); + } + } catch (error) { + // If the subscriber throws, rethrow it in a separate task + scheduleCallback(ImmediatePriority, () => { + throw error; + }); + } finally { + // Clear completed interactions from the pending Map. + // Unless the render was suspended or cascading work was scheduled, + // In which case– leave pending interactions until the subsequent render. + const pendingInteractionMap = root.pendingInteractionMap; + pendingInteractionMap.forEach( + (scheduledInteractions, scheduledExpirationTime) => { + // Only decrement the pending interaction count if we're done. + // If there's still work at the current priority, + // That indicates that we are waiting for suspense data. + if (scheduledExpirationTime > earliestRemainingTimeAfterCommit) { + pendingInteractionMap.delete(scheduledExpirationTime); + + scheduledInteractions.forEach(interaction => { + interaction.__count--; + + if (subscriber !== null && interaction.__count === 0) { + try { + subscriber.onInteractionScheduledWorkCompleted(interaction); + } catch (error) { + // If the subscriber throws, rethrow it in a separate task + scheduleCallback(ImmediatePriority, () => { + throw error; + }); + } + } + }); + } + }, + ); + } +} diff --git a/packages/react-reconciler/src/ReactFiberScheduler.new.js b/packages/react-reconciler/src/ReactFiberScheduler.new.js deleted file mode 100644 index 670bf220e651e..0000000000000 --- a/packages/react-reconciler/src/ReactFiberScheduler.new.js +++ /dev/null @@ -1,2224 +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. - * - * @flow - */ - -import type {Fiber} from './ReactFiber'; -import type {FiberRoot} from './ReactFiberRoot'; -import type {ExpirationTime} from './ReactFiberExpirationTime'; -import type { - ReactPriorityLevel, - SchedulerCallback, -} from './SchedulerWithReactIntegration'; -import type {Interaction} from 'scheduler/src/Tracing'; - -import { - warnAboutDeprecatedLifecycles, - enableUserTimingAPI, - enableSuspenseServerRenderer, - replayFailedUnitOfWorkWithInvokeGuardedCallback, - enableProfilerTimer, - disableYielding, - enableSchedulerTracing, -} from 'shared/ReactFeatureFlags'; -import ReactSharedInternals from 'shared/ReactSharedInternals'; -import invariant from 'shared/invariant'; -import warning from 'shared/warning'; - -import { - scheduleCallback, - cancelCallback, - getCurrentPriorityLevel, - runWithPriority, - shouldYield, - now, - ImmediatePriority, - UserBlockingPriority, - NormalPriority, - LowPriority, - IdlePriority, - flushImmediateQueue, -} from './SchedulerWithReactIntegration'; - -import {__interactionsRef, __subscriberRef} from 'scheduler/tracing'; - -import { - prepareForCommit, - resetAfterCommit, - scheduleTimeout, - cancelTimeout, - noTimeout, -} from './ReactFiberHostConfig'; - -import {createWorkInProgress, assignFiberPropertiesInDEV} from './ReactFiber'; -import {NoContext, ConcurrentMode, ProfileMode} from './ReactTypeOfMode'; -import { - HostRoot, - ClassComponent, - SuspenseComponent, - DehydratedSuspenseComponent, - FunctionComponent, - ForwardRef, - MemoComponent, - SimpleMemoComponent, -} from 'shared/ReactWorkTags'; -import { - NoEffect, - PerformedWork, - Placement, - Update, - PlacementAndUpdate, - Deletion, - Ref, - ContentReset, - Snapshot, - Callback, - Passive, - Incomplete, - HostEffectMask, -} from 'shared/ReactSideEffectTags'; -import { - NoWork, - Sync, - Never, - msToExpirationTime, - expirationTimeToMs, - computeInteractiveExpiration, - computeAsyncExpiration, - inferPriorityFromExpirationTime, - LOW_PRIORITY_EXPIRATION, -} from './ReactFiberExpirationTime'; -import {beginWork as originalBeginWork} from './ReactFiberBeginWork'; -import {completeWork} from './ReactFiberCompleteWork'; -import { - throwException, - unwindWork, - unwindInterruptedWork, - createRootErrorUpdate, - createClassErrorUpdate, -} from './ReactFiberUnwindWork'; -import { - commitBeforeMutationLifeCycles as commitBeforeMutationEffectOnFiber, - commitLifeCycles as commitLayoutEffectOnFiber, - commitPassiveHookEffects, - commitPlacement, - commitWork, - commitDeletion, - commitDetachRef, - commitAttachRef, - commitResetTextContent, -} from './ReactFiberCommitWork'; -import {enqueueUpdate} from './ReactUpdateQueue'; -// TODO: Ahaha Andrew is bad at spellling -import {resetContextDependences as resetContextDependencies} from './ReactFiberNewContext'; -import {resetHooks, ContextOnlyDispatcher} from './ReactFiberHooks'; -import {createCapturedValue} from './ReactCapturedValue'; - -import { - recordCommitTime, - startProfilerTimer, - stopProfilerTimerIfRunningAndRecordDelta, -} from './ReactProfilerTimer'; - -// DEV stuff -import warningWithoutStack from 'shared/warningWithoutStack'; -import getComponentName from 'shared/getComponentName'; -import ReactStrictModeWarnings from './ReactStrictModeWarnings'; -import { - phase as ReactCurrentDebugFiberPhaseInDEV, - resetCurrentFiber as resetCurrentDebugFiberInDEV, - setCurrentFiber as setCurrentDebugFiberInDEV, - getStackByFiberInDevAndProd, -} from './ReactCurrentFiber'; -import { - recordEffect, - recordScheduleUpdate, - startRequestCallbackTimer, - stopRequestCallbackTimer, - startWorkTimer, - stopWorkTimer, - stopFailedWorkTimer, - startWorkLoopTimer, - stopWorkLoopTimer, - startCommitTimer, - stopCommitTimer, - startCommitSnapshotEffectsTimer, - stopCommitSnapshotEffectsTimer, - startCommitHostEffectsTimer, - stopCommitHostEffectsTimer, - startCommitLifeCyclesTimer, - stopCommitLifeCyclesTimer, -} from './ReactDebugFiberPerf'; -import { - invokeGuardedCallback, - hasCaughtError, - clearCaughtError, -} from 'shared/ReactErrorUtils'; -import {onCommitRoot} from './ReactFiberDevToolsHook'; -import ReactActingUpdatesSigil from './ReactActingUpdatesSigil'; - -const { - ReactCurrentDispatcher, - ReactCurrentOwner, - ReactShouldWarnActingUpdates, -} = ReactSharedInternals; - -type WorkPhase = 0 | 1 | 2 | 3 | 4 | 5; -const NotWorking = 0; -const BatchedPhase = 1; -const LegacyUnbatchedPhase = 2; -const FlushSyncPhase = 3; -const RenderPhase = 4; -const CommitPhase = 5; - -type RootExitStatus = 0 | 1 | 2 | 3; -const RootIncomplete = 0; -const RootErrored = 1; -const RootSuspended = 2; -const RootCompleted = 3; - -export type Thenable = { - then(resolve: () => mixed, reject?: () => mixed): Thenable | void, -}; - -// The phase of work we're currently in -let workPhase: WorkPhase = NotWorking; -// The root we're working on -let workInProgressRoot: FiberRoot | null = null; -// The fiber we're working on -let workInProgress: Fiber | null = null; -// The expiration time we're rendering -let renderExpirationTime: ExpirationTime = NoWork; -// Whether to root completed, errored, suspended, etc. -let workInProgressRootExitStatus: RootExitStatus = RootIncomplete; -let workInProgressRootAbsoluteTimeoutMs: number = -1; - -let nextEffect: Fiber | null = null; -let hasUncaughtError = false; -let firstUncaughtError = null; -let legacyErrorBoundariesThatAlreadyFailed: Set | null = null; - -let rootDoesHavePassiveEffects: boolean = false; -let rootWithPendingPassiveEffects: FiberRoot | null = null; -let pendingPassiveEffectsExpirationTime: ExpirationTime = NoWork; - -let rootsWithPendingDiscreteUpdates: Map< - FiberRoot, - ExpirationTime, -> | null = null; - -// Use these to prevent an infinite loop of nested updates -const NESTED_UPDATE_LIMIT = 50; -let nestedUpdateCount: number = 0; -let rootWithNestedUpdates: FiberRoot | null = null; - -const NESTED_PASSIVE_UPDATE_LIMIT = 50; -let nestedPassiveUpdateCount: number = 0; - -let interruptedBy: Fiber | null = null; - -// Expiration times are computed by adding to the current time (the start -// time). However, if two updates are scheduled within the same event, we -// should treat their start times as simultaneous, even if the actual clock -// time has advanced between the first and second call. - -// In other words, because expiration times determine how updates are batched, -// we want all updates of like priority that occur within the same event to -// receive the same expiration time. Otherwise we get tearing. -let currentEventTime: ExpirationTime = NoWork; - -export function requestCurrentTime() { - if (workPhase === RenderPhase || workPhase === CommitPhase) { - // We're inside React, so it's fine to read the actual time. - return msToExpirationTime(now()); - } - // We're not inside React, so we may be in the middle of a browser event. - if (currentEventTime !== NoWork) { - // Use the same start time for all updates until we enter React again. - return currentEventTime; - } - // This is the first update since React yielded. Compute a new start time. - currentEventTime = msToExpirationTime(now()); - return currentEventTime; -} - -export function computeExpirationForFiber( - currentTime: ExpirationTime, - fiber: Fiber, -): ExpirationTime { - if ((fiber.mode & ConcurrentMode) === NoContext) { - return Sync; - } - - if (workPhase === RenderPhase) { - // Use whatever time we're already rendering - return renderExpirationTime; - } - - // Compute an expiration time based on the Scheduler priority. - let expirationTime; - const priorityLevel = getCurrentPriorityLevel(); - switch (priorityLevel) { - case ImmediatePriority: - expirationTime = Sync; - break; - case UserBlockingPriority: - // TODO: Rename this to computeUserBlockingExpiration - expirationTime = computeInteractiveExpiration(currentTime); - break; - case NormalPriority: - case LowPriority: // TODO: Handle LowPriority - // TODO: Rename this to... something better. - expirationTime = computeAsyncExpiration(currentTime); - break; - case IdlePriority: - expirationTime = Never; - break; - default: - invariant(false, 'Expected a valid priority level'); - } - - // If we're in the middle of rendering a tree, do not update at the same - // expiration time that is already rendering. - if (workInProgressRoot !== null && expirationTime === renderExpirationTime) { - // This is a trick to move this update into a separate batch - expirationTime -= 1; - } - - return expirationTime; -} - -let lastUniqueAsyncExpiration = NoWork; -export function computeUniqueAsyncExpiration(): ExpirationTime { - const currentTime = requestCurrentTime(); - let result = computeAsyncExpiration(currentTime); - if (result <= lastUniqueAsyncExpiration) { - // Since we assume the current time monotonically increases, we only hit - // this branch when computeUniqueAsyncExpiration is fired multiple times - // within a 200ms window (or whatever the async bucket size is). - result -= 1; - } - lastUniqueAsyncExpiration = result; - return result; -} - -export function scheduleUpdateOnFiber( - fiber: Fiber, - expirationTime: ExpirationTime, -) { - checkForNestedUpdates(); - warnAboutInvalidUpdatesOnClassComponentsInDEV(fiber); - - const root = markUpdateTimeFromFiberToRoot(fiber, expirationTime); - if (root === null) { - warnAboutUpdateOnUnmountedFiberInDEV(fiber); - return; - } - - root.pingTime = NoWork; - - checkForInterruption(fiber, expirationTime); - recordScheduleUpdate(); - - if (expirationTime === Sync) { - if (workPhase === LegacyUnbatchedPhase) { - // This is a legacy edge case. The initial mount of a ReactDOM.render-ed - // root inside of batchedUpdates should be synchronous, but layout updates - // should be deferred until the end of the batch. - let callback = renderRoot(root, Sync, true); - while (callback !== null) { - callback = callback(true); - } - } else { - scheduleCallbackForRoot(root, ImmediatePriority, Sync); - if (workPhase === NotWorking) { - // Flush the synchronous work now, wnless we're already working or inside - // a batch. This is intentionally inside scheduleUpdateOnFiber instead of - // scheduleCallbackForFiber to preserve the ability to schedule a callback - // without immediately flushing it. We only do this for user-initated - // updates, to preserve historical behavior of sync mode. - flushImmediateQueue(); - } - } - } else { - // TODO: computeExpirationForFiber also reads the priority. Pass the - // priority as an argument to that function and this one. - const priorityLevel = getCurrentPriorityLevel(); - if (priorityLevel === UserBlockingPriority) { - // This is the result of a discrete event. Track the lowest priority - // discrete update per root so we can flush them early, if needed. - if (rootsWithPendingDiscreteUpdates === null) { - rootsWithPendingDiscreteUpdates = new Map([[root, expirationTime]]); - } else { - const lastDiscreteTime = rootsWithPendingDiscreteUpdates.get(root); - if ( - lastDiscreteTime === undefined || - lastDiscreteTime > expirationTime - ) { - rootsWithPendingDiscreteUpdates.set(root, expirationTime); - } - } - } - scheduleCallbackForRoot(root, priorityLevel, expirationTime); - } -} -export const scheduleWork = scheduleUpdateOnFiber; - -// This is split into a separate function so we can mark a fiber with pending -// work without treating it as a typical update that originates from an event; -// e.g. retrying a Suspense boundary isn't an update, but it does schedule work -// on a fiber. -function markUpdateTimeFromFiberToRoot(fiber, expirationTime) { - // Update the source fiber's expiration time - if (fiber.expirationTime < expirationTime) { - fiber.expirationTime = expirationTime; - } - let alternate = fiber.alternate; - if (alternate !== null && alternate.expirationTime < expirationTime) { - alternate.expirationTime = expirationTime; - } - // Walk the parent path to the root and update the child expiration time. - let node = fiber.return; - let root = null; - if (node === null && fiber.tag === HostRoot) { - root = fiber.stateNode; - } else { - while (node !== null) { - alternate = node.alternate; - if (node.childExpirationTime < expirationTime) { - node.childExpirationTime = expirationTime; - if ( - alternate !== null && - alternate.childExpirationTime < expirationTime - ) { - alternate.childExpirationTime = expirationTime; - } - } else if ( - alternate !== null && - alternate.childExpirationTime < expirationTime - ) { - alternate.childExpirationTime = expirationTime; - } - if (node.return === null && node.tag === HostRoot) { - root = node.stateNode; - break; - } - node = node.return; - } - } - - if (root !== null) { - // Update the first and last pending expiration times in this root - const firstPendingTime = root.firstPendingTime; - if (expirationTime > firstPendingTime) { - root.firstPendingTime = expirationTime; - } - const lastPendingTime = root.lastPendingTime; - if (lastPendingTime === NoWork || expirationTime < lastPendingTime) { - root.lastPendingTime = expirationTime; - } - } - - return root; -} - -// Use this function, along with runRootCallback, to ensure that only a single -// callback per root is scheduled. It's still possible to call renderRoot -// directly, but scheduling via this function helps avoid excessive callbacks. -// It works by storing the callback node and expiration time on the root. When a -// new callback comes in, it compares the expiration time to determine if it -// should cancel the previous one. It also relies on commitRoot scheduling a -// callback to render the next level, because that means we don't need a -// separate callback per expiration time. -function scheduleCallbackForRoot( - root: FiberRoot, - priorityLevel: ReactPriorityLevel, - expirationTime: ExpirationTime, -) { - const existingCallbackExpirationTime = root.callbackExpirationTime; - if (existingCallbackExpirationTime < expirationTime) { - // New callback has higher priority than the existing one. - const existingCallbackNode = root.callbackNode; - if (existingCallbackNode !== null) { - cancelCallback(existingCallbackNode); - } - root.callbackExpirationTime = expirationTime; - const options = - expirationTime === Sync - ? null - : {timeout: expirationTimeToMs(expirationTime)}; - root.callbackNode = scheduleCallback( - priorityLevel, - runRootCallback.bind( - null, - root, - renderRoot.bind(null, root, expirationTime), - ), - options, - ); - if ( - enableUserTimingAPI && - expirationTime !== Sync && - workPhase !== RenderPhase && - workPhase !== CommitPhase - ) { - // Scheduled an async callback, and we're not already working. Add an - // entry to the flamegraph that shows we're waiting for a callback - // to fire. - startRequestCallbackTimer(); - } - } - - const timeoutHandle = root.timeoutHandle; - if (timeoutHandle !== noTimeout) { - // The root previous suspended and scheduled a timeout to commit a fallback - // state. Now that we have additional work, cancel the timeout. - root.timeoutHandle = noTimeout; - // $FlowFixMe Complains noTimeout is not a TimeoutID, despite the check above - cancelTimeout(timeoutHandle); - } - - // Add the current set of interactions to the pending set associated with - // this root. - schedulePendingInteraction(root, expirationTime); -} - -function runRootCallback(root, callback, isSync) { - const prevCallbackNode = root.callbackNode; - let continuation = null; - try { - continuation = callback(isSync); - if (continuation !== null) { - return runRootCallback.bind(null, root, continuation); - } else { - return null; - } - } finally { - // If the callback exits without returning a continuation, remove the - // corresponding callback node from the root. Unless the callback node - // has changed, which implies that it was already cancelled by a high - // priority update. - if (continuation === null && prevCallbackNode === root.callbackNode) { - root.callbackNode = null; - root.callbackExpirationTime = NoWork; - } - } -} - -export function flushRoot(root: FiberRoot, expirationTime: ExpirationTime) { - if (workPhase === RenderPhase || workPhase === CommitPhase) { - invariant( - false, - 'work.commit(): Cannot commit while already rendering. This likely ' + - 'means you attempted to commit from inside a lifecycle method.', - ); - } - scheduleCallback( - ImmediatePriority, - renderRoot.bind(null, root, expirationTime), - ); - flushImmediateQueue(); -} - -export function flushInteractiveUpdates() { - if (workPhase === RenderPhase || workPhase === CommitPhase) { - // Can't synchronously flush interactive updates if React is already - // working. This is currently a no-op. - // TODO: Should we fire a warning? This happens if you synchronously invoke - // an input event inside an effect, like with `element.click()`. - return; - } - flushPendingDiscreteUpdates(); -} - -function resolveLocksOnRoot(root: FiberRoot, expirationTime: ExpirationTime) { - const firstBatch = root.firstBatch; - if ( - firstBatch !== null && - firstBatch._defer && - firstBatch._expirationTime >= expirationTime - ) { - root.finishedWork = root.current.alternate; - root.pendingCommitExpirationTime = expirationTime; - scheduleCallback(NormalPriority, () => { - firstBatch._onComplete(); - return null; - }); - return true; - } else { - return false; - } -} - -export function deferredUpdates(fn: () => A): A { - // TODO: Remove in favor of Scheduler.next - return runWithPriority(NormalPriority, fn); -} - -export function interactiveUpdates( - fn: (A, B, C) => R, - a: A, - b: B, - c: C, -): R { - if (workPhase === NotWorking) { - // TODO: Remove this call. Instead of doing this automatically, the caller - // should explicitly call flushInteractiveUpdates. - flushPendingDiscreteUpdates(); - } - return runWithPriority(UserBlockingPriority, fn.bind(null, a, b, c)); -} - -export function syncUpdates( - fn: (A, B, C) => R, - a: A, - b: B, - c: C, -): R { - return runWithPriority(ImmediatePriority, fn.bind(null, a, b, c)); -} - -function flushPendingDiscreteUpdates() { - if (rootsWithPendingDiscreteUpdates !== null) { - // For each root with pending discrete updates, schedule a callback to - // immediately flush them. - const roots = rootsWithPendingDiscreteUpdates; - rootsWithPendingDiscreteUpdates = null; - roots.forEach((expirationTime, root) => { - scheduleCallback( - ImmediatePriority, - renderRoot.bind(null, root, expirationTime), - ); - }); - // Now flush the immediate queue. - flushImmediateQueue(); - } -} - -export function batchedUpdates(fn: A => R, a: A): R { - if (workPhase !== NotWorking) { - // We're already working, or inside a batch, so batchedUpdates is a no-op. - return fn(a); - } - workPhase = BatchedPhase; - try { - return fn(a); - } finally { - workPhase = NotWorking; - // Flush the immediate callbacks that were scheduled during this batch - flushImmediateQueue(); - } -} - -export function unbatchedUpdates(fn: (a: A) => R, a: A): R { - if (workPhase !== BatchedPhase && workPhase !== FlushSyncPhase) { - // We're not inside batchedUpdates or flushSync, so unbatchedUpdates is - // a no-op. - return fn(a); - } - const prevWorkPhase = workPhase; - workPhase = LegacyUnbatchedPhase; - try { - return fn(a); - } finally { - workPhase = prevWorkPhase; - } -} - -export function flushSync(fn: A => R, a: A): R { - if (workPhase === RenderPhase || workPhase === CommitPhase) { - invariant( - false, - 'flushSync was called from inside a lifecycle method. It cannot be ' + - 'called when React is already rendering.', - ); - } - const prevWorkPhase = workPhase; - workPhase = FlushSyncPhase; - try { - return runWithPriority(ImmediatePriority, fn.bind(null, a)); - } finally { - workPhase = prevWorkPhase; - // Flush the immediate callbacks that were scheduled during this batch. - // Note that this will happen even if batchedUpdates is higher up - // the stack. - flushImmediateQueue(); - } -} - -export function flushControlled(fn: () => mixed): void { - const prevWorkPhase = workPhase; - workPhase = BatchedPhase; - try { - runWithPriority(ImmediatePriority, fn); - } finally { - workPhase = prevWorkPhase; - if (workPhase === NotWorking) { - // Flush the immediate callbacks that were scheduled during this batch - flushImmediateQueue(); - } - } -} - -function prepareFreshStack(root, expirationTime) { - root.pendingCommitExpirationTime = NoWork; - - if (workInProgress !== null) { - let interruptedWork = workInProgress.return; - while (interruptedWork !== null) { - unwindInterruptedWork(interruptedWork); - interruptedWork = interruptedWork.return; - } - } - workInProgressRoot = root; - workInProgress = createWorkInProgress(root.current, null, expirationTime); - renderExpirationTime = expirationTime; - workInProgressRootExitStatus = RootIncomplete; - workInProgressRootAbsoluteTimeoutMs = -1; - - if (__DEV__) { - ReactStrictModeWarnings.discardPendingWarnings(); - } -} - -function renderRoot( - root: FiberRoot, - expirationTime: ExpirationTime, - isSync: boolean, -): SchedulerCallback | null { - invariant( - workPhase !== RenderPhase && workPhase !== CommitPhase, - 'Should not already be working.', - ); - - if (enableUserTimingAPI && expirationTime !== Sync) { - const didExpire = isSync; - const timeoutMs = expirationTimeToMs(expirationTime); - stopRequestCallbackTimer(didExpire, timeoutMs); - } - - if (root.firstPendingTime < expirationTime) { - // If there's no work left at this expiration time, exit immediately. This - // happens when multiple callbacks are scheduled for a single root, but an - // earlier callback flushes the work of a later one. - return null; - } - - if (root.pendingCommitExpirationTime === expirationTime) { - // There's already a pending commit at this expiration time. - root.pendingCommitExpirationTime = NoWork; - return commitRoot.bind(null, root, expirationTime); - } - - flushPassiveEffects(); - - // If the root or expiration time have changed, throw out the existing stack - // and prepare a fresh one. Otherwise we'll continue where we left off. - if (root !== workInProgressRoot || expirationTime !== renderExpirationTime) { - prepareFreshStack(root, expirationTime); - startWorkOnPendingInteraction(root, expirationTime); - } - - // If we have a work-in-progress fiber, it means there's still work to do - // in this root. - if (workInProgress !== null) { - const prevWorkPhase = workPhase; - workPhase = RenderPhase; - let prevDispatcher = ReactCurrentDispatcher.current; - if (prevDispatcher === null) { - // The React isomorphic package does not include a default dispatcher. - // Instead the first renderer will lazily attach one, in order to give - // nicer error messages. - prevDispatcher = ContextOnlyDispatcher; - } - ReactCurrentDispatcher.current = ContextOnlyDispatcher; - let prevInteractions: Set | null = null; - if (enableSchedulerTracing) { - prevInteractions = __interactionsRef.current; - __interactionsRef.current = root.memoizedInteractions; - } - - startWorkLoopTimer(workInProgress); - do { - try { - if (isSync) { - if (expirationTime !== Sync) { - // An async update expired. There may be other expired updates on - // this root. We should render all the expired work in a - // single batch. - const currentTime = requestCurrentTime(); - if (currentTime < expirationTime) { - // Restart at the current time. - workPhase = prevWorkPhase; - ReactCurrentDispatcher.current = prevDispatcher; - return renderRoot.bind(null, root, currentTime); - } - } - workLoopSync(); - } else { - // Since we know we're in a React event, we can clear the current - // event time. The next update will compute a new event time. - currentEventTime = NoWork; - workLoop(); - } - break; - } catch (thrownValue) { - // Reset module-level state that was set during the render phase. - resetContextDependencies(); - resetHooks(); - - const sourceFiber = workInProgress; - if (sourceFiber === null || sourceFiber.return === null) { - // Expected to be working on a non-root fiber. This is a fatal error - // because there's no ancestor that can handle it; the root is - // supposed to capture all errors that weren't caught by an error - // boundary. - prepareFreshStack(root, expirationTime); - workPhase = prevWorkPhase; - throw thrownValue; - } - - if (enableProfilerTimer && sourceFiber.mode & ProfileMode) { - // Record the time spent rendering before an error was thrown. This - // avoids inaccurate Profiler durations in the case of a - // suspended render. - stopProfilerTimerIfRunningAndRecordDelta(sourceFiber, true); - } - - const returnFiber = sourceFiber.return; - throwException( - root, - returnFiber, - sourceFiber, - thrownValue, - renderExpirationTime, - ); - workInProgress = completeUnitOfWork(sourceFiber); - } - } while (true); - - workPhase = prevWorkPhase; - resetContextDependencies(); - ReactCurrentDispatcher.current = prevDispatcher; - if (enableSchedulerTracing) { - __interactionsRef.current = ((prevInteractions: any): Set); - } - - if (workInProgress !== null) { - // There's still work left over. Return a continuation. - stopInterruptedWorkLoopTimer(); - if (expirationTime !== Sync) { - startRequestCallbackTimer(); - } - return renderRoot.bind(null, root, expirationTime); - } - } - - // We now have a consistent tree. The next step is either to commit it, or, if - // something suspended, wait to commit it after a timeout. - stopFinishedWorkLoopTimer(); - - const isLocked = resolveLocksOnRoot(root, expirationTime); - if (isLocked) { - // This root has a lock that prevents it from committing. Exit. If we begin - // work on the root again, without any intervening updates, it will finish - // without doing additional work. - return null; - } - - // Set this to null to indicate there's no in-progress render. - workInProgressRoot = null; - - switch (workInProgressRootExitStatus) { - case RootIncomplete: { - invariant(false, 'Should have a work-in-progress.'); - } - // Flow knows about invariant, so it compains if I add a break statement, - // but eslint doesn't know about invariant, so it complains if I do. - // eslint-disable-next-line no-fallthrough - case RootErrored: { - // An error was thrown. First check if there is lower priority work - // scheduled on this root. - const lastPendingTime = root.lastPendingTime; - if (root.lastPendingTime < expirationTime) { - // There's lower priority work. Before raising the error, try rendering - // at the lower priority to see if it fixes it. Use a continuation to - // maintain the existing priority and position in the queue. - return renderRoot.bind(null, root, lastPendingTime); - } - if (!isSync) { - // If we're rendering asynchronously, it's possible the error was - // caused by tearing due to a mutation during an event. Try rendering - // one more time without yiedling to events. - prepareFreshStack(root, expirationTime); - scheduleCallback( - ImmediatePriority, - renderRoot.bind(null, root, expirationTime), - ); - return null; - } - // If we're already rendering synchronously, commit the root in its - // errored state. - return commitRoot.bind(null, root, expirationTime); - } - case RootSuspended: { - const lastPendingTime = root.lastPendingTime; - if (root.lastPendingTime < expirationTime) { - // There's lower priority work. It might be unsuspended. Try rendering - // at that level. - return renderRoot.bind(null, root, lastPendingTime); - } - if (!isSync) { - const msUntilTimeout = computeMsUntilTimeout( - root, - workInProgressRootAbsoluteTimeoutMs, - ); - if (msUntilTimeout > 0) { - // The render is suspended, it hasn't timed out, and there's no lower - // priority work to do. Instead of committing the fallback - // immediately, wait for more data to arrive. - root.timeoutHandle = scheduleTimeout( - commitRoot.bind(null, root, expirationTime), - msUntilTimeout, - ); - return null; - } - } - // The work expired. Commit immediately. - return commitRoot.bind(null, root, expirationTime); - } - case RootCompleted: { - // The work completed. Ready to commit. - return commitRoot.bind(null, root, expirationTime); - } - default: { - invariant(false, 'Unknown root exit status.'); - } - } -} - -export function renderDidSuspend( - root: FiberRoot, - absoluteTimeoutMs: number, - // TODO: Don't need this argument anymore - suspendedTime: ExpirationTime, -) { - if ( - absoluteTimeoutMs >= 0 && - workInProgressRootAbsoluteTimeoutMs < absoluteTimeoutMs - ) { - workInProgressRootAbsoluteTimeoutMs = absoluteTimeoutMs; - if (workInProgressRootExitStatus === RootIncomplete) { - workInProgressRootExitStatus = RootSuspended; - } - } -} - -export function renderDidError() { - if ( - workInProgressRootExitStatus === RootIncomplete || - workInProgressRootExitStatus === RootSuspended - ) { - workInProgressRootExitStatus = RootErrored; - } -} - -function workLoopSync() { - // Already timed out, so perform work without checking if we need to yield. - while (workInProgress !== null) { - workInProgress = performUnitOfWork(workInProgress); - } -} - -function workLoop() { - // Perform work until Scheduler asks us to yield - while (workInProgress !== null && !shouldYield()) { - workInProgress = performUnitOfWork(workInProgress); - } -} - -function performUnitOfWork(unitOfWork: Fiber): Fiber | null { - // The current, flushed, state of this fiber is the alternate. Ideally - // nothing should rely on this, but relying on it here means that we don't - // need an additional field on the work in progress. - const current = unitOfWork.alternate; - - startWorkTimer(unitOfWork); - setCurrentDebugFiberInDEV(unitOfWork); - - let next; - if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoContext) { - startProfilerTimer(unitOfWork); - next = beginWork(current, unitOfWork, renderExpirationTime); - stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true); - } else { - next = beginWork(current, unitOfWork, renderExpirationTime); - } - - resetCurrentDebugFiberInDEV(); - unitOfWork.memoizedProps = unitOfWork.pendingProps; - if (next === null) { - // If this doesn't spawn new work, complete the current work. - next = completeUnitOfWork(unitOfWork); - } - - ReactCurrentOwner.current = null; - return next; -} - -function completeUnitOfWork(unitOfWork: Fiber): Fiber | null { - // Attempt to complete the current unit of work, then move to the next - // sibling. If there are no more siblings, return to the parent fiber. - workInProgress = unitOfWork; - do { - // The current, flushed, state of this fiber is the alternate. Ideally - // nothing should rely on this, but relying on it here means that we don't - // need an additional field on the work in progress. - const current = workInProgress.alternate; - const returnFiber = workInProgress.return; - - // Check if the work completed or if something threw. - if ((workInProgress.effectTag & Incomplete) === NoEffect) { - setCurrentDebugFiberInDEV(workInProgress); - let next; - if ( - !enableProfilerTimer || - (workInProgress.mode & ProfileMode) === NoContext - ) { - next = completeWork(current, workInProgress, renderExpirationTime); - } else { - startProfilerTimer(workInProgress); - next = completeWork(current, workInProgress, renderExpirationTime); - // Update render duration assuming we didn't error. - stopProfilerTimerIfRunningAndRecordDelta(workInProgress, false); - } - stopWorkTimer(workInProgress); - resetCurrentDebugFiberInDEV(); - resetChildExpirationTime(workInProgress); - - if (next !== null) { - // Completing this fiber spawned new work. Work on that next. - return next; - } - - if ( - returnFiber !== null && - // Do not append effects to parents if a sibling failed to complete - (returnFiber.effectTag & Incomplete) === NoEffect - ) { - // Append all the effects of the subtree and this fiber onto the effect - // list of the parent. The completion order of the children affects the - // side-effect order. - if (returnFiber.firstEffect === null) { - returnFiber.firstEffect = workInProgress.firstEffect; - } - if (workInProgress.lastEffect !== null) { - if (returnFiber.lastEffect !== null) { - returnFiber.lastEffect.nextEffect = workInProgress.firstEffect; - } - returnFiber.lastEffect = workInProgress.lastEffect; - } - - // If this fiber had side-effects, we append it AFTER the children's - // side-effects. We can perform certain side-effects earlier if needed, - // by doing multiple passes over the effect list. We don't want to - // schedule our own side-effect on our own list because if end up - // reusing children we'll schedule this effect onto itself since we're - // at the end. - const effectTag = workInProgress.effectTag; - - // Skip both NoWork and PerformedWork tags when creating the effect - // list. PerformedWork effect is read by React DevTools but shouldn't be - // committed. - if (effectTag > PerformedWork) { - if (returnFiber.lastEffect !== null) { - returnFiber.lastEffect.nextEffect = workInProgress; - } else { - returnFiber.firstEffect = workInProgress; - } - returnFiber.lastEffect = workInProgress; - } - } - } else { - // This fiber did not complete because something threw. Pop values off - // the stack without entering the complete phase. If this is a boundary, - // capture values if possible. - const next = unwindWork(workInProgress, renderExpirationTime); - - // Because this fiber did not complete, don't reset its expiration time. - - if ( - enableProfilerTimer && - (workInProgress.mode & ProfileMode) !== NoContext - ) { - // Record the render duration for the fiber that errored. - stopProfilerTimerIfRunningAndRecordDelta(workInProgress, false); - - // Include the time spent working on failed children before continuing. - let actualDuration = workInProgress.actualDuration; - let child = workInProgress.child; - while (child !== null) { - actualDuration += child.actualDuration; - child = child.sibling; - } - workInProgress.actualDuration = actualDuration; - } - - if (next !== null) { - // If completing this work spawned new work, do that next. We'll come - // back here again. - // Since we're restarting, remove anything that is not a host effect - // from the effect tag. - // TODO: The name stopFailedWorkTimer is misleading because Suspense - // also captures and restarts. - stopFailedWorkTimer(workInProgress); - next.effectTag &= HostEffectMask; - return next; - } - stopWorkTimer(workInProgress); - - if (returnFiber !== null) { - // Mark the parent fiber as incomplete and clear its effect list. - returnFiber.firstEffect = returnFiber.lastEffect = null; - returnFiber.effectTag |= Incomplete; - } - } - - const siblingFiber = workInProgress.sibling; - if (siblingFiber !== null) { - // If there is more work to do in this returnFiber, do that next. - return siblingFiber; - } - // Otherwise, return to the parent - workInProgress = returnFiber; - } while (workInProgress !== null); - - // We've reached the root. - if (workInProgressRootExitStatus === RootIncomplete) { - workInProgressRootExitStatus = RootCompleted; - } - return null; -} - -function resetChildExpirationTime(completedWork: Fiber) { - if ( - renderExpirationTime !== Never && - completedWork.childExpirationTime === Never - ) { - // The children of this component are hidden. Don't bubble their - // expiration times. - return; - } - - let newChildExpirationTime = NoWork; - - // Bubble up the earliest expiration time. - if (enableProfilerTimer && (completedWork.mode & ProfileMode) !== NoContext) { - // In profiling mode, resetChildExpirationTime is also used to reset - // profiler durations. - let actualDuration = completedWork.actualDuration; - let treeBaseDuration = completedWork.selfBaseDuration; - - // When a fiber is cloned, its actualDuration is reset to 0. This value will - // only be updated if work is done on the fiber (i.e. it doesn't bailout). - // When work is done, it should bubble to the parent's actualDuration. If - // the fiber has not been cloned though, (meaning no work was done), then - // this value will reflect the amount of time spent working on a previous - // render. In that case it should not bubble. We determine whether it was - // cloned by comparing the child pointer. - const shouldBubbleActualDurations = - completedWork.alternate === null || - completedWork.child !== completedWork.alternate.child; - - let child = completedWork.child; - while (child !== null) { - const childUpdateExpirationTime = child.expirationTime; - const childChildExpirationTime = child.childExpirationTime; - if (childUpdateExpirationTime > newChildExpirationTime) { - newChildExpirationTime = childUpdateExpirationTime; - } - if (childChildExpirationTime > newChildExpirationTime) { - newChildExpirationTime = childChildExpirationTime; - } - if (shouldBubbleActualDurations) { - actualDuration += child.actualDuration; - } - treeBaseDuration += child.treeBaseDuration; - child = child.sibling; - } - completedWork.actualDuration = actualDuration; - completedWork.treeBaseDuration = treeBaseDuration; - } else { - let child = completedWork.child; - while (child !== null) { - const childUpdateExpirationTime = child.expirationTime; - const childChildExpirationTime = child.childExpirationTime; - if (childUpdateExpirationTime > newChildExpirationTime) { - newChildExpirationTime = childUpdateExpirationTime; - } - if (childChildExpirationTime > newChildExpirationTime) { - newChildExpirationTime = childChildExpirationTime; - } - child = child.sibling; - } - } - - completedWork.childExpirationTime = newChildExpirationTime; -} - -function commitRoot(root, expirationTime) { - runWithPriority( - ImmediatePriority, - commitRootImpl.bind(null, root, expirationTime), - ); - // If there are passive effects, schedule a callback to flush them. This goes - // outside commitRootImpl so that it inherits the priority of the render. - if (rootWithPendingPassiveEffects !== null) { - const priorityLevel = getCurrentPriorityLevel(); - scheduleCallback(priorityLevel, () => { - flushPassiveEffects(); - return null; - }); - } - return null; -} - -function commitRootImpl(root, expirationTime) { - flushPassiveEffects(); - flushRenderPhaseStrictModeWarningsInDEV(); - - invariant( - workPhase !== RenderPhase && workPhase !== CommitPhase, - 'Should not already be working.', - ); - const finishedWork = root.current.alternate; - invariant(finishedWork !== null, 'Should have a work-in-progress root.'); - - // commitRoot never returns a continuation; it always finishes synchronously. - // So we can clear these now to allow a new callback to be scheduled. - root.callbackNode = null; - root.callbackExpirationTime = NoWork; - - startCommitTimer(); - - // Update the first and last pending times on this root. The new first - // pending time is whatever is left on the root fiber. - const updateExpirationTimeBeforeCommit = finishedWork.expirationTime; - const childExpirationTimeBeforeCommit = finishedWork.childExpirationTime; - const firstPendingTimeBeforeCommit = - childExpirationTimeBeforeCommit > updateExpirationTimeBeforeCommit - ? childExpirationTimeBeforeCommit - : updateExpirationTimeBeforeCommit; - root.firstPendingTime = firstPendingTimeBeforeCommit; - if (firstPendingTimeBeforeCommit < root.lastPendingTime) { - // This usually means we've finished all the work, but it can also happen - // when something gets downprioritized during render, like a hidden tree. - root.lastPendingTime = firstPendingTimeBeforeCommit; - } - - if (root === workInProgressRoot) { - // We can reset these now that they are finished. - workInProgressRoot = null; - workInProgress = null; - renderExpirationTime = NoWork; - } else { - // This indicates that the last root we worked on is not the same one that - // we're committing now. This most commonly happens when a suspended root - // times out. - } - - // Get the list of effects. - let firstEffect; - if (finishedWork.effectTag > PerformedWork) { - // A fiber's effect list consists only of its children, not itself. So if - // the root has an effect, we need to add it to the end of the list. The - // resulting list is the set that would belong to the root's parent, if it - // had one; that is, all the effects in the tree including the root. - if (finishedWork.lastEffect !== null) { - finishedWork.lastEffect.nextEffect = finishedWork; - firstEffect = finishedWork.firstEffect; - } else { - firstEffect = finishedWork; - } - } else { - // There is no effect on the root. - firstEffect = finishedWork.firstEffect; - } - - if (firstEffect !== null) { - const prevWorkPhase = workPhase; - workPhase = CommitPhase; - let prevInteractions: Set | null = null; - if (enableSchedulerTracing) { - prevInteractions = __interactionsRef.current; - __interactionsRef.current = root.memoizedInteractions; - } - - // Reset this to null before calling lifecycles - ReactCurrentOwner.current = null; - - // The commit phase is broken into several sub-phases. We do a separate pass - // of the effect list for each phase: all mutation effects come before all - // layout effects, and so on. - - // The first phase a "before mutation" phase. We use this phase to read the - // state of the host tree right before we mutate it. This is where - // getSnapshotBeforeUpdate is called. - startCommitSnapshotEffectsTimer(); - prepareForCommit(root.containerInfo); - nextEffect = firstEffect; - do { - if (__DEV__) { - invokeGuardedCallback(null, commitBeforeMutationEffects, null); - if (hasCaughtError()) { - invariant(nextEffect !== null, 'Should be working on an effect.'); - const error = clearCaughtError(); - captureCommitPhaseError(nextEffect, error); - nextEffect = nextEffect.nextEffect; - } - } else { - try { - commitBeforeMutationEffects(); - } catch (error) { - invariant(nextEffect !== null, 'Should be working on an effect.'); - captureCommitPhaseError(nextEffect, error); - nextEffect = nextEffect.nextEffect; - } - } - } while (nextEffect !== null); - stopCommitSnapshotEffectsTimer(); - - if (enableProfilerTimer) { - // Mark the current commit time to be shared by all Profilers in this - // batch. This enables them to be grouped later. - recordCommitTime(); - } - - // The next phase is the mutation phase, where we mutate the host tree. - startCommitHostEffectsTimer(); - nextEffect = firstEffect; - do { - if (__DEV__) { - invokeGuardedCallback(null, commitMutationEffects, null); - if (hasCaughtError()) { - invariant(nextEffect !== null, 'Should be working on an effect.'); - const error = clearCaughtError(); - captureCommitPhaseError(nextEffect, error); - nextEffect = nextEffect.nextEffect; - } - } else { - try { - commitMutationEffects(); - } catch (error) { - invariant(nextEffect !== null, 'Should be working on an effect.'); - captureCommitPhaseError(nextEffect, error); - nextEffect = nextEffect.nextEffect; - } - } - } while (nextEffect !== null); - stopCommitHostEffectsTimer(); - resetAfterCommit(root.containerInfo); - - // The work-in-progress tree is now the current tree. This must come after - // the mutation phase, so that the previous tree is still current during - // componentWillUnmount, but before the layout phase, so that the finished - // work is current during componentDidMount/Update. - root.current = finishedWork; - - // The next phase is the layout phase, where we call effects that read - // the host tree after it's been mutated. The idiomatic use case for this is - // layout, but class component lifecycles also fire here for legacy reasons. - startCommitLifeCyclesTimer(); - nextEffect = firstEffect; - do { - if (__DEV__) { - invokeGuardedCallback( - null, - commitLayoutEffects, - null, - root, - expirationTime, - ); - if (hasCaughtError()) { - invariant(nextEffect !== null, 'Should be working on an effect.'); - const error = clearCaughtError(); - captureCommitPhaseError(nextEffect, error); - nextEffect = nextEffect.nextEffect; - } - } else { - try { - commitLayoutEffects(root, expirationTime); - } catch (error) { - invariant(nextEffect !== null, 'Should be working on an effect.'); - captureCommitPhaseError(nextEffect, error); - nextEffect = nextEffect.nextEffect; - } - } - } while (nextEffect !== null); - stopCommitLifeCyclesTimer(); - - nextEffect = null; - - if (enableSchedulerTracing) { - __interactionsRef.current = ((prevInteractions: any): Set); - } - workPhase = prevWorkPhase; - } else { - // No effects. - root.current = finishedWork; - // Measure these anyway so the flamegraph explicitly shows that there were - // no effects. - // TODO: Maybe there's a better way to report this. - startCommitSnapshotEffectsTimer(); - stopCommitSnapshotEffectsTimer(); - if (enableProfilerTimer) { - recordCommitTime(); - } - startCommitHostEffectsTimer(); - stopCommitHostEffectsTimer(); - startCommitLifeCyclesTimer(); - stopCommitLifeCyclesTimer(); - } - - stopCommitTimer(); - - if (rootDoesHavePassiveEffects) { - // This commit has passive effects. Stash a reference to them. But don't - // schedule a callback until after flushing layout work. - rootDoesHavePassiveEffects = false; - rootWithPendingPassiveEffects = root; - pendingPassiveEffectsExpirationTime = expirationTime; - } else { - if (enableSchedulerTracing) { - // If there are no passive effects, then we can complete the pending - // interactions. Otherwise, we'll wait until after the passive effects - // are flushed. - finishPendingInteractions(root, expirationTime); - } - } - - // Check if there's remaining work on this root - const remainingExpirationTime = root.firstPendingTime; - if (remainingExpirationTime !== NoWork) { - const currentTime = requestCurrentTime(); - const priorityLevel = inferPriorityFromExpirationTime( - currentTime, - remainingExpirationTime, - ); - scheduleCallbackForRoot(root, priorityLevel, remainingExpirationTime); - } else { - // If there's no remaining work, we can clear the set of already failed - // error boundaries. - legacyErrorBoundariesThatAlreadyFailed = null; - } - - onCommitRoot(finishedWork.stateNode); - - if (remainingExpirationTime === Sync) { - // Count the number of times the root synchronously re-renders without - // finishing. If there are too many, it indicates an infinite update loop. - if (root === rootWithNestedUpdates) { - nestedUpdateCount++; - } else { - nestedUpdateCount = 0; - rootWithNestedUpdates = root; - } - } else { - nestedUpdateCount = 0; - } - - if (hasUncaughtError) { - hasUncaughtError = false; - const error = firstUncaughtError; - firstUncaughtError = null; - throw error; - } - - if (workPhase === LegacyUnbatchedPhase) { - // This is a legacy edge case. We just committed the initial mount of - // a ReactDOM.render-ed root inside of batchedUpdates. The commit fired - // synchronously, but layout updates should be deferred until the end - // of the batch. - return null; - } - - // If layout work was scheduled, flush it now. - flushImmediateQueue(); - return null; -} - -function commitBeforeMutationEffects() { - while (nextEffect !== null) { - if ((nextEffect.effectTag & Snapshot) !== NoEffect) { - setCurrentDebugFiberInDEV(nextEffect); - recordEffect(); - - const current = nextEffect.alternate; - commitBeforeMutationEffectOnFiber(current, nextEffect); - - resetCurrentDebugFiberInDEV(); - } - nextEffect = nextEffect.nextEffect; - } -} - -function commitMutationEffects() { - // TODO: Should probably move the bulk of this function to commitWork. - while (nextEffect !== null) { - setCurrentDebugFiberInDEV(nextEffect); - - const effectTag = nextEffect.effectTag; - - if (effectTag & ContentReset) { - commitResetTextContent(nextEffect); - } - - if (effectTag & Ref) { - const current = nextEffect.alternate; - if (current !== null) { - commitDetachRef(current); - } - } - - // The following switch statement is only concerned about placement, - // updates, and deletions. To avoid needing to add a case for every possible - // bitmap value, we remove the secondary effects from the effect tag and - // switch on that value. - let primaryEffectTag = effectTag & (Placement | Update | Deletion); - switch (primaryEffectTag) { - case Placement: { - commitPlacement(nextEffect); - // Clear the "placement" from effect tag so that we know that this is - // inserted, before any life-cycles like componentDidMount gets called. - // TODO: findDOMNode doesn't rely on this any more but isMounted does - // and isMounted is deprecated anyway so we should be able to kill this. - nextEffect.effectTag &= ~Placement; - break; - } - case PlacementAndUpdate: { - // Placement - commitPlacement(nextEffect); - // Clear the "placement" from effect tag so that we know that this is - // inserted, before any life-cycles like componentDidMount gets called. - nextEffect.effectTag &= ~Placement; - - // Update - const current = nextEffect.alternate; - commitWork(current, nextEffect); - break; - } - case Update: { - const current = nextEffect.alternate; - commitWork(current, nextEffect); - break; - } - case Deletion: { - commitDeletion(nextEffect); - break; - } - } - - // TODO: Only record a mutation effect if primaryEffectTag is non-zero. - recordEffect(); - - resetCurrentDebugFiberInDEV(); - nextEffect = nextEffect.nextEffect; - } -} - -function commitLayoutEffects( - root: FiberRoot, - committedExpirationTime: ExpirationTime, -) { - // TODO: Should probably move the bulk of this function to commitWork. - while (nextEffect !== null) { - setCurrentDebugFiberInDEV(nextEffect); - - const effectTag = nextEffect.effectTag; - - if (effectTag & (Update | Callback)) { - recordEffect(); - const current = nextEffect.alternate; - commitLayoutEffectOnFiber( - root, - current, - nextEffect, - committedExpirationTime, - ); - } - - if (effectTag & Ref) { - recordEffect(); - commitAttachRef(nextEffect); - } - - if (effectTag & Passive) { - rootDoesHavePassiveEffects = true; - } - - resetCurrentDebugFiberInDEV(); - nextEffect = nextEffect.nextEffect; - } -} - -export function flushPassiveEffects() { - if (rootWithPendingPassiveEffects === null) { - return false; - } - const root = rootWithPendingPassiveEffects; - const expirationTime = pendingPassiveEffectsExpirationTime; - rootWithPendingPassiveEffects = null; - pendingPassiveEffectsExpirationTime = NoWork; - - let prevInteractions: Set | null = null; - if (enableSchedulerTracing) { - prevInteractions = __interactionsRef.current; - __interactionsRef.current = root.memoizedInteractions; - } - - invariant( - workPhase !== RenderPhase && workPhase !== CommitPhase, - 'Cannot flush passive effects while already rendering.', - ); - const prevWorkPhase = workPhase; - workPhase = CommitPhase; - - // Note: This currently assumes there are no passive effects on the root - // fiber, because the root is not part of its own effect list. This could - // change in the future. - let effect = root.current.firstEffect; - while (effect !== null) { - if (__DEV__) { - setCurrentDebugFiberInDEV(effect); - invokeGuardedCallback(null, commitPassiveHookEffects, null, effect); - if (hasCaughtError()) { - invariant(effect !== null, 'Should be working on an effect.'); - const error = clearCaughtError(); - captureCommitPhaseError(effect, error); - } - resetCurrentDebugFiberInDEV(); - } else { - try { - commitPassiveHookEffects(effect); - } catch (error) { - invariant(effect !== null, 'Should be working on an effect.'); - captureCommitPhaseError(effect, error); - } - } - effect = effect.nextEffect; - } - - if (enableSchedulerTracing) { - __interactionsRef.current = ((prevInteractions: any): Set); - finishPendingInteractions(root, expirationTime); - } - - workPhase = prevWorkPhase; - flushImmediateQueue(); - - // If additional passive effects were scheduled, increment a counter. If this - // exceeds the limit, we'll fire a warning. - nestedPassiveUpdateCount = - rootWithPendingPassiveEffects === null ? 0 : nestedPassiveUpdateCount + 1; - - return true; -} - -export function isAlreadyFailedLegacyErrorBoundary(instance: mixed): boolean { - return ( - legacyErrorBoundariesThatAlreadyFailed !== null && - legacyErrorBoundariesThatAlreadyFailed.has(instance) - ); -} - -export function markLegacyErrorBoundaryAsFailed(instance: mixed) { - if (legacyErrorBoundariesThatAlreadyFailed === null) { - legacyErrorBoundariesThatAlreadyFailed = new Set([instance]); - } else { - legacyErrorBoundariesThatAlreadyFailed.add(instance); - } -} - -function prepareToThrowUncaughtError(error: mixed) { - if (!hasUncaughtError) { - hasUncaughtError = true; - firstUncaughtError = error; - } -} -export const onUncaughtError = prepareToThrowUncaughtError; - -function captureCommitPhaseErrorOnRoot( - rootFiber: Fiber, - sourceFiber: Fiber, - error: mixed, -) { - const errorInfo = createCapturedValue(error, sourceFiber); - const update = createRootErrorUpdate(rootFiber, errorInfo, Sync); - enqueueUpdate(rootFiber, update); - const root = markUpdateTimeFromFiberToRoot(rootFiber, Sync); - if (root !== null) { - scheduleCallbackForRoot(root, ImmediatePriority, Sync); - } -} - -export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) { - if (sourceFiber.tag === HostRoot) { - // Error was thrown at the root. There is no parent, so the root - // itself should capture it. - captureCommitPhaseErrorOnRoot(sourceFiber, sourceFiber, error); - return; - } - - let fiber = sourceFiber.return; - while (fiber !== null) { - if (fiber.tag === HostRoot) { - captureCommitPhaseErrorOnRoot(fiber, sourceFiber, error); - return; - } else if (fiber.tag === ClassComponent) { - const ctor = fiber.type; - const instance = fiber.stateNode; - if ( - typeof ctor.getDerivedStateFromError === 'function' || - (typeof instance.componentDidCatch === 'function' && - !isAlreadyFailedLegacyErrorBoundary(instance)) - ) { - const errorInfo = createCapturedValue(error, sourceFiber); - const update = createClassErrorUpdate( - fiber, - errorInfo, - // TODO: This is always sync - Sync, - ); - enqueueUpdate(fiber, update); - const root = markUpdateTimeFromFiberToRoot(fiber, Sync); - if (root !== null) { - scheduleCallbackForRoot(root, ImmediatePriority, Sync); - } - return; - } - } - fiber = fiber.return; - } -} - -export function pingSuspendedRoot( - root: FiberRoot, - thenable: Thenable, - suspendedTime: ExpirationTime, -) { - const pingCache = root.pingCache; - if (pingCache !== null) { - // The thenable resolved, so we no longer need to memoize, because it will - // never be thrown again. - pingCache.delete(thenable); - } - - if (workInProgressRoot === root && renderExpirationTime === suspendedTime) { - // Received a ping at the same priority level at which we're currently - // rendering. Restart from the root. Don't need to schedule a ping because - // we're already working on this tree. - prepareFreshStack(root, renderExpirationTime); - return; - } - - const lastPendingTime = root.lastPendingTime; - if (lastPendingTime < suspendedTime) { - // The root is no longer suspended at this time. - return; - } - - const pingTime = root.pingTime; - if (pingTime !== NoWork && pingTime < suspendedTime) { - // There's already a lower priority ping scheduled. - return; - } - - // Mark the time at which this ping was scheduled. - root.pingTime = suspendedTime; - - const currentTime = requestCurrentTime(); - const priorityLevel = inferPriorityFromExpirationTime( - currentTime, - suspendedTime, - ); - scheduleCallbackForRoot(root, priorityLevel, suspendedTime); -} - -export function retryTimedOutBoundary(boundaryFiber: Fiber) { - // The boundary fiber (a Suspense component) previously timed out and was - // rendered in its fallback state. One of the promises that suspended it has - // resolved, which means at least part of the tree was likely unblocked. Try - // rendering again, at a new expiration time. - const currentTime = requestCurrentTime(); - const retryTime = computeExpirationForFiber(currentTime, boundaryFiber); - // TODO: Special case idle priority? - const priorityLevel = inferPriorityFromExpirationTime(currentTime, retryTime); - const root = markUpdateTimeFromFiberToRoot(boundaryFiber, retryTime); - if (root !== null) { - scheduleCallbackForRoot(root, priorityLevel, retryTime); - } -} - -export function resolveRetryThenable(boundaryFiber: Fiber, thenable: Thenable) { - let retryCache: WeakSet | Set | null; - if (enableSuspenseServerRenderer) { - switch (boundaryFiber.tag) { - case SuspenseComponent: - retryCache = boundaryFiber.stateNode; - break; - case DehydratedSuspenseComponent: - retryCache = boundaryFiber.memoizedState; - break; - default: - invariant( - false, - 'Pinged unknown suspense boundary type. ' + - 'This is probably a bug in React.', - ); - } - } else { - retryCache = boundaryFiber.stateNode; - } - - if (retryCache !== null) { - // The thenable resolved, so we no longer need to memoize, because it will - // never be thrown again. - retryCache.delete(thenable); - } - - retryTimedOutBoundary(boundaryFiber); -} - -export function inferStartTimeFromExpirationTime( - root: FiberRoot, - expirationTime: ExpirationTime, -) { - // We don't know exactly when the update was scheduled, but we can infer an - // approximate start time from the expiration time. - const earliestExpirationTimeMs = expirationTimeToMs(root.firstPendingTime); - // TODO: Track this on the root instead. It's more accurate, doesn't rely on - // assumptions about priority, and isn't coupled to Scheduler details. - return earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION; -} - -function computeMsUntilTimeout(root, absoluteTimeoutMs) { - if (disableYielding) { - // Timeout immediately when yielding is disabled. - return 0; - } - - // Find the earliest uncommitted expiration time in the tree, including - // work that is suspended. The timeout threshold cannot be longer than - // the overall expiration. - const earliestExpirationTimeMs = expirationTimeToMs(root.firstPendingTime); - if (earliestExpirationTimeMs < absoluteTimeoutMs) { - absoluteTimeoutMs = earliestExpirationTimeMs; - } - - // Subtract the current time from the absolute timeout to get the number - // of milliseconds until the timeout. In other words, convert an absolute - // timestamp to a relative time. This is the value that is passed - // to `setTimeout`. - let msUntilTimeout = absoluteTimeoutMs - now(); - return msUntilTimeout < 0 ? 0 : msUntilTimeout; -} - -function checkForNestedUpdates() { - if (nestedUpdateCount > NESTED_UPDATE_LIMIT) { - nestedUpdateCount = 0; - rootWithNestedUpdates = null; - invariant( - false, - 'Maximum update depth exceeded. This can happen when a component ' + - 'repeatedly calls setState inside componentWillUpdate or ' + - 'componentDidUpdate. React limits the number of nested updates to ' + - 'prevent infinite loops.', - ); - } - - if (__DEV__) { - if (nestedPassiveUpdateCount > NESTED_PASSIVE_UPDATE_LIMIT) { - nestedPassiveUpdateCount = 0; - warning( - false, - 'Maximum update depth exceeded. This can happen when a component ' + - "calls setState inside useEffect, but useEffect either doesn't " + - 'have a dependency array, or one of the dependencies changes on ' + - 'every render.', - ); - } - } -} - -function flushRenderPhaseStrictModeWarningsInDEV() { - if (__DEV__) { - ReactStrictModeWarnings.flushPendingUnsafeLifecycleWarnings(); - ReactStrictModeWarnings.flushLegacyContextWarning(); - - if (warnAboutDeprecatedLifecycles) { - ReactStrictModeWarnings.flushPendingDeprecationWarnings(); - } - } -} - -function stopFinishedWorkLoopTimer() { - const didCompleteRoot = true; - stopWorkLoopTimer(interruptedBy, didCompleteRoot); - interruptedBy = null; -} - -function stopInterruptedWorkLoopTimer() { - // TODO: Track which fiber caused the interruption. - const didCompleteRoot = false; - stopWorkLoopTimer(interruptedBy, didCompleteRoot); - interruptedBy = null; -} - -function checkForInterruption( - fiberThatReceivedUpdate: Fiber, - updateExpirationTime: ExpirationTime, -) { - if ( - enableUserTimingAPI && - workInProgressRoot !== null && - updateExpirationTime > renderExpirationTime - ) { - interruptedBy = fiberThatReceivedUpdate; - } -} - -let didWarnStateUpdateForUnmountedComponent: Set | null = null; -function warnAboutUpdateOnUnmountedFiberInDEV(fiber) { - if (__DEV__) { - const tag = fiber.tag; - if ( - tag !== HostRoot && - tag !== ClassComponent && - tag !== FunctionComponent && - tag !== ForwardRef && - tag !== MemoComponent && - tag !== SimpleMemoComponent - ) { - // Only warn for user-defined components, not internal ones like Suspense. - return; - } - // We show the whole stack but dedupe on the top component's name because - // the problematic code almost always lies inside that component. - const componentName = getComponentName(fiber.type) || 'ReactComponent'; - if (didWarnStateUpdateForUnmountedComponent !== null) { - if (didWarnStateUpdateForUnmountedComponent.has(componentName)) { - return; - } - didWarnStateUpdateForUnmountedComponent.add(componentName); - } else { - didWarnStateUpdateForUnmountedComponent = new Set([componentName]); - } - warningWithoutStack( - false, - "Can't perform a React state update on an unmounted component. This " + - 'is a no-op, but it indicates a memory leak in your application. To ' + - 'fix, cancel all subscriptions and asynchronous tasks in %s.%s', - tag === ClassComponent - ? 'the componentWillUnmount method' - : 'a useEffect cleanup function', - getStackByFiberInDevAndProd(fiber), - ); - } -} - -let beginWork; -if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { - let dummyFiber = null; - beginWork = (current, unitOfWork, expirationTime) => { - // If a component throws an error, we replay it again in a synchronously - // dispatched event, so that the debugger will treat it as an uncaught - // error See ReactErrorUtils for more information. - - // Before entering the begin phase, copy the work-in-progress onto a dummy - // fiber. If beginWork throws, we'll use this to reset the state. - const originalWorkInProgressCopy = assignFiberPropertiesInDEV( - dummyFiber, - unitOfWork, - ); - try { - return originalBeginWork(current, unitOfWork, expirationTime); - } catch (originalError) { - if ( - originalError !== null && - typeof originalError === 'object' && - typeof originalError.then === 'function' - ) { - // Don't replay promises. Treat everything else like an error. - throw originalError; - } - - // Keep this code in sync with renderRoot; any changes here must have - // corresponding changes there. - resetContextDependencies(); - resetHooks(); - - // Unwind the failed stack frame - unwindInterruptedWork(unitOfWork); - - // Restore the original properties of the fiber. - assignFiberPropertiesInDEV(unitOfWork, originalWorkInProgressCopy); - - if (enableProfilerTimer && unitOfWork.mode & ProfileMode) { - // Reset the profiler timer. - startProfilerTimer(unitOfWork); - } - - // Run beginWork again. - invokeGuardedCallback( - null, - originalBeginWork, - null, - current, - unitOfWork, - expirationTime, - ); - - if (hasCaughtError()) { - const replayError = clearCaughtError(); - // `invokeGuardedCallback` sometimes sets an expando `_suppressLogging`. - // Rethrow this error instead of the original one. - throw replayError; - } else { - // This branch is reachable if the render phase is impure. - throw originalError; - } - } - }; -} else { - beginWork = originalBeginWork; -} - -let didWarnAboutUpdateInRender = false; -let didWarnAboutUpdateInGetChildContext = false; -function warnAboutInvalidUpdatesOnClassComponentsInDEV(fiber) { - if (__DEV__) { - if (fiber.tag === ClassComponent) { - switch (ReactCurrentDebugFiberPhaseInDEV) { - case 'getChildContext': - if (didWarnAboutUpdateInGetChildContext) { - return; - } - warningWithoutStack( - false, - 'setState(...): Cannot call setState() inside getChildContext()', - ); - didWarnAboutUpdateInGetChildContext = true; - break; - case 'render': - if (didWarnAboutUpdateInRender) { - return; - } - warningWithoutStack( - false, - 'Cannot update during an existing state transition (such as ' + - 'within `render`). Render methods should be a pure function of ' + - 'props and state.', - ); - didWarnAboutUpdateInRender = true; - break; - } - } - } -} - -export function warnIfNotScopedWithMatchingAct(fiber: Fiber): void { - if (__DEV__) { - if ( - ReactShouldWarnActingUpdates.current !== null && - ReactShouldWarnActingUpdates.current !== ReactActingUpdatesSigil - ) { - // it looks like we're using the wrong matching act(), so log a warning - warningWithoutStack( - false, - "It looks like you're using the wrong act() around your interactions.\n" + - 'Be sure to use the matching version of act() corresponding to your renderer. e.g. -\n' + - "for react-dom, import {act} from 'react-test-utils';\n" + - 'for react-test-renderer, const {act} = TestRenderer.' + - '%s', - getStackByFiberInDevAndProd(fiber), - ); - } - } -} - -// in a test-like environment, we want to warn if dispatchAction() is -// called outside of a TestUtils.act(...)/batchedUpdates/render call. -// so we have a a step counter for when we descend/ascend from -// act() calls, and test on it for when to warn -// It's a tuple with a single value. Look into ReactTestUtilsAct as an -// example of how we change the value - -function warnIfNotCurrentlyActingUpdatesInDEV(fiber: Fiber): void { - if (__DEV__) { - if ( - workPhase === NotWorking && - ReactShouldWarnActingUpdates.current !== ReactActingUpdatesSigil - ) { - warningWithoutStack( - false, - 'An update to %s inside a test was not wrapped in act(...).\n\n' + - 'When testing, code that causes React state updates should be ' + - 'wrapped into act(...):\n\n' + - 'act(() => {\n' + - ' /* fire events that update state */\n' + - '});\n' + - '/* assert on the output */\n\n' + - "This ensures that you're testing the behavior the user would see " + - 'in the browser.' + - ' Learn more at https://fb.me/react-wrap-tests-with-act' + - '%s', - getComponentName(fiber.type), - getStackByFiberInDevAndProd(fiber), - ); - } - } -} - -export const warnIfNotCurrentlyActingUpdatesInDev = warnIfNotCurrentlyActingUpdatesInDEV; - -function computeThreadID(root, expirationTime) { - // Interaction threads are unique per root and expiration time. - return expirationTime * 1000 + root.interactionThreadID; -} - -function schedulePendingInteraction(root, expirationTime) { - // This is called when work is scheduled on a root. It sets up a pending - // interaction, which is completed once the work commits. - if (!enableSchedulerTracing) { - return; - } - - const interactions = __interactionsRef.current; - if (interactions.size > 0) { - const pendingInteractionMap = root.pendingInteractionMap; - const pendingInteractions = pendingInteractionMap.get(expirationTime); - if (pendingInteractions != null) { - interactions.forEach(interaction => { - if (!pendingInteractions.has(interaction)) { - // Update the pending async work count for previously unscheduled interaction. - interaction.__count++; - } - - pendingInteractions.add(interaction); - }); - } else { - pendingInteractionMap.set(expirationTime, new Set(interactions)); - - // Update the pending async work count for the current interactions. - interactions.forEach(interaction => { - interaction.__count++; - }); - } - - const subscriber = __subscriberRef.current; - if (subscriber !== null) { - const threadID = computeThreadID(root, expirationTime); - subscriber.onWorkScheduled(interactions, threadID); - } - } -} - -function startWorkOnPendingInteraction(root, expirationTime) { - // This is called when new work is started on a root. - if (!enableSchedulerTracing) { - return; - } - - // Determine which interactions this batch of work currently includes, So that - // we can accurately attribute time spent working on it, And so that cascading - // work triggered during the render phase will be associated with it. - const interactions: Set = new Set(); - root.pendingInteractionMap.forEach( - (scheduledInteractions, scheduledExpirationTime) => { - if (scheduledExpirationTime >= expirationTime) { - scheduledInteractions.forEach(interaction => - interactions.add(interaction), - ); - } - }, - ); - - // Store the current set of interactions on the FiberRoot for a few reasons: - // We can re-use it in hot functions like renderRoot() without having to - // recalculate it. We will also use it in commitWork() to pass to any Profiler - // onRender() hooks. This also provides DevTools with a way to access it when - // the onCommitRoot() hook is called. - root.memoizedInteractions = interactions; - - if (interactions.size > 0) { - const subscriber = __subscriberRef.current; - if (subscriber !== null) { - const threadID = computeThreadID(root, expirationTime); - try { - subscriber.onWorkStarted(interactions, threadID); - } catch (error) { - // If the subscriber throws, rethrow it in a separate task - scheduleCallback(ImmediatePriority, () => { - throw error; - }); - } - } - } -} - -function finishPendingInteractions(root, committedExpirationTime) { - if (!enableSchedulerTracing) { - return; - } - - const earliestRemainingTimeAfterCommit = root.firstPendingTime; - - let subscriber; - - try { - subscriber = __subscriberRef.current; - if (subscriber !== null && root.memoizedInteractions.size > 0) { - const threadID = computeThreadID(root, committedExpirationTime); - subscriber.onWorkStopped(root.memoizedInteractions, threadID); - } - } catch (error) { - // If the subscriber throws, rethrow it in a separate task - scheduleCallback(ImmediatePriority, () => { - throw error; - }); - } finally { - // Clear completed interactions from the pending Map. - // Unless the render was suspended or cascading work was scheduled, - // In which case– leave pending interactions until the subsequent render. - const pendingInteractionMap = root.pendingInteractionMap; - pendingInteractionMap.forEach( - (scheduledInteractions, scheduledExpirationTime) => { - // Only decrement the pending interaction count if we're done. - // If there's still work at the current priority, - // That indicates that we are waiting for suspense data. - if (scheduledExpirationTime > earliestRemainingTimeAfterCommit) { - pendingInteractionMap.delete(scheduledExpirationTime); - - scheduledInteractions.forEach(interaction => { - interaction.__count--; - - if (subscriber !== null && interaction.__count === 0) { - try { - subscriber.onInteractionScheduledWorkCompleted(interaction); - } catch (error) { - // If the subscriber throws, rethrow it in a separate task - scheduleCallback(ImmediatePriority, () => { - throw error; - }); - } - } - }); - } - }, - ); - } -} diff --git a/packages/react-reconciler/src/ReactFiberScheduler.old.js b/packages/react-reconciler/src/ReactFiberScheduler.old.js deleted file mode 100644 index ec1f5f46cd0a2..0000000000000 --- a/packages/react-reconciler/src/ReactFiberScheduler.old.js +++ /dev/null @@ -1,2736 +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. - * - * @flow - */ - -import type {Fiber} from './ReactFiber'; -import type {Batch, FiberRoot} from './ReactFiberRoot'; -import type {ExpirationTime} from './ReactFiberExpirationTime'; -import type {Interaction} from 'scheduler/src/Tracing'; - -// Intentionally not named imports because Rollup would use dynamic dispatch for -// CommonJS interop named imports. -import * as Scheduler from 'scheduler'; -import { - __interactionsRef, - __subscriberRef, - unstable_wrap as Scheduler_tracing_wrap, -} from 'scheduler/tracing'; -import { - invokeGuardedCallback, - hasCaughtError, - clearCaughtError, -} from 'shared/ReactErrorUtils'; -import ReactSharedInternals from 'shared/ReactSharedInternals'; -import ReactStrictModeWarnings from './ReactStrictModeWarnings'; -import { - NoEffect, - PerformedWork, - Placement, - Update, - Snapshot, - PlacementAndUpdate, - Deletion, - ContentReset, - Callback, - DidCapture, - Ref, - Incomplete, - HostEffectMask, - Passive, -} from 'shared/ReactSideEffectTags'; -import { - ClassComponent, - HostComponent, - ContextProvider, - ForwardRef, - FunctionComponent, - HostPortal, - HostRoot, - MemoComponent, - SimpleMemoComponent, - SuspenseComponent, - DehydratedSuspenseComponent, -} from 'shared/ReactWorkTags'; -import { - enableSchedulerTracing, - enableProfilerTimer, - enableUserTimingAPI, - replayFailedUnitOfWorkWithInvokeGuardedCallback, - warnAboutDeprecatedLifecycles, - enableSuspenseServerRenderer, - disableYielding, -} from 'shared/ReactFeatureFlags'; -import getComponentName from 'shared/getComponentName'; -import invariant from 'shared/invariant'; -import warning from 'shared/warning'; -import warningWithoutStack from 'shared/warningWithoutStack'; - -import ReactFiberInstrumentation from './ReactFiberInstrumentation'; -import { - getStackByFiberInDevAndProd, - phase as ReactCurrentFiberPhase, - resetCurrentFiber, - setCurrentFiber, -} from './ReactCurrentFiber'; -import { - prepareForCommit, - resetAfterCommit, - scheduleTimeout, - cancelTimeout, - noTimeout, -} from './ReactFiberHostConfig'; -import { - markPendingPriorityLevel, - markCommittedPriorityLevels, - markSuspendedPriorityLevel, - markPingedPriorityLevel, - hasLowerPriorityWork, - isPriorityLevelSuspended, - findEarliestOutstandingPriorityLevel, - didExpireAtExpirationTime, -} from './ReactFiberPendingPriority'; -import { - recordEffect, - recordScheduleUpdate, - startRequestCallbackTimer, - stopRequestCallbackTimer, - startWorkTimer, - stopWorkTimer, - stopFailedWorkTimer, - startWorkLoopTimer, - stopWorkLoopTimer, - startCommitTimer, - stopCommitTimer, - startCommitSnapshotEffectsTimer, - stopCommitSnapshotEffectsTimer, - startCommitHostEffectsTimer, - stopCommitHostEffectsTimer, - startCommitLifeCyclesTimer, - stopCommitLifeCyclesTimer, -} from './ReactDebugFiberPerf'; -import {createWorkInProgress, assignFiberPropertiesInDEV} from './ReactFiber'; -import {onCommitRoot} from './ReactFiberDevToolsHook'; -import { - NoWork, - Sync, - Never, - msToExpirationTime, - expirationTimeToMs, - computeAsyncExpiration, - computeInteractiveExpiration, - LOW_PRIORITY_EXPIRATION, -} from './ReactFiberExpirationTime'; -import {ConcurrentMode, ProfileMode} from './ReactTypeOfMode'; -import {enqueueUpdate, resetCurrentlyProcessingQueue} from './ReactUpdateQueue'; -import {createCapturedValue} from './ReactCapturedValue'; -import { - isContextProvider as isLegacyContextProvider, - popTopLevelContextObject as popTopLevelLegacyContextObject, - popContext as popLegacyContext, -} from './ReactFiberContext'; -import {popProvider, resetContextDependences} from './ReactFiberNewContext'; -import {resetHooks} from './ReactFiberHooks'; -import {popHostContext, popHostContainer} from './ReactFiberHostContext'; -import { - recordCommitTime, - startProfilerTimer, - stopProfilerTimerIfRunningAndRecordDelta, -} from './ReactProfilerTimer'; -import { - checkThatStackIsEmpty, - resetStackAfterFatalErrorInDev, -} from './ReactFiberStack'; -import {beginWork} from './ReactFiberBeginWork'; -import {completeWork} from './ReactFiberCompleteWork'; -import { - throwException, - unwindWork, - unwindInterruptedWork, - createRootErrorUpdate, - createClassErrorUpdate, -} from './ReactFiberUnwindWork'; -import { - commitBeforeMutationLifeCycles, - commitResetTextContent, - commitPlacement, - commitDeletion, - commitWork, - commitLifeCycles, - commitAttachRef, - commitDetachRef, - commitPassiveHookEffects, -} from './ReactFiberCommitWork'; -import {ContextOnlyDispatcher} from './ReactFiberHooks'; -import ReactActingUpdatesSigil from './ReactActingUpdatesSigil'; - -// Intentionally not named imports because Rollup would use dynamic dispatch for -// CommonJS interop named imports. -const { - unstable_scheduleCallback: scheduleCallback, - unstable_cancelCallback: cancelCallback, - unstable_shouldYield: shouldYield, - unstable_now: now, - unstable_getCurrentPriorityLevel: getCurrentPriorityLevel, - unstable_NormalPriority: NormalPriority, -} = Scheduler; - -export type Thenable = { - then(resolve: () => mixed, reject?: () => mixed): void | Thenable, -}; - -const { - ReactCurrentDispatcher, - ReactCurrentOwner, - ReactShouldWarnActingUpdates, -} = ReactSharedInternals; - -let didWarnAboutStateTransition; -let didWarnSetStateChildContext; -let warnAboutUpdateOnUnmounted; -let warnAboutInvalidUpdates; - -if (enableSchedulerTracing) { - // Provide explicit error message when production+profiling bundle of e.g. react-dom - // is used with production (non-profiling) bundle of scheduler/tracing - invariant( - __interactionsRef != null && __interactionsRef.current != null, - 'It is not supported to run the profiling version of a renderer (for example, `react-dom/profiling`) ' + - 'without also replacing the `scheduler/tracing` module with `scheduler/tracing-profiling`. ' + - 'Your bundler might have a setting for aliasing both modules. ' + - 'Learn more at http://fb.me/react-profiling', - ); -} - -if (__DEV__) { - didWarnAboutStateTransition = false; - didWarnSetStateChildContext = false; - const didWarnStateUpdateForUnmountedComponent = {}; - - warnAboutUpdateOnUnmounted = function(fiber: Fiber, isClass: boolean) { - // We show the whole stack but dedupe on the top component's name because - // the problematic code almost always lies inside that component. - const componentName = getComponentName(fiber.type) || 'ReactComponent'; - if (didWarnStateUpdateForUnmountedComponent[componentName]) { - return; - } - warningWithoutStack( - false, - "Can't perform a React state update on an unmounted component. This " + - 'is a no-op, but it indicates a memory leak in your application. To ' + - 'fix, cancel all subscriptions and asynchronous tasks in %s.%s', - isClass - ? 'the componentWillUnmount method' - : 'a useEffect cleanup function', - getStackByFiberInDevAndProd(fiber), - ); - didWarnStateUpdateForUnmountedComponent[componentName] = true; - }; - - warnAboutInvalidUpdates = function(instance: React$Component) { - switch (ReactCurrentFiberPhase) { - case 'getChildContext': - if (didWarnSetStateChildContext) { - return; - } - warningWithoutStack( - false, - 'setState(...): Cannot call setState() inside getChildContext()', - ); - didWarnSetStateChildContext = true; - break; - case 'render': - if (didWarnAboutStateTransition) { - return; - } - warningWithoutStack( - false, - 'Cannot update during an existing state transition (such as within ' + - '`render`). Render methods should be a pure function of props and state.', - ); - didWarnAboutStateTransition = true; - break; - } - }; -} - -// Used to ensure computeUniqueAsyncExpiration is monotonically decreasing. -let lastUniqueAsyncExpiration: number = Sync - 1; - -// Represents the expiration time that incoming updates should use. (If this -// is NoWork, use the default strategy: async updates in async mode, sync -// updates in sync mode.) -let expirationContext: ExpirationTime = NoWork; - -let isWorking: boolean = false; - -// The next work in progress fiber that we're currently working on. -let nextUnitOfWork: Fiber | null = null; -let nextRoot: FiberRoot | null = null; -// The time at which we're currently rendering work. -let nextRenderExpirationTime: ExpirationTime = NoWork; -let nextLatestAbsoluteTimeoutMs: number = -1; -let nextRenderDidError: boolean = false; - -// The next fiber with an effect that we're currently committing. -let nextEffect: Fiber | null = null; - -let isCommitting: boolean = false; -let rootWithPendingPassiveEffects: FiberRoot | null = null; -let passiveEffectCallbackHandle: * = null; -let passiveEffectCallback: * = null; - -let legacyErrorBoundariesThatAlreadyFailed: Set | null = null; - -// Used for performance tracking. -let interruptedBy: Fiber | null = null; - -let stashedWorkInProgressProperties; -let replayUnitOfWork; -let mayReplayFailedUnitOfWork; -let isReplayingFailedUnitOfWork; -let originalReplayError; -let rethrowOriginalError; -if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { - stashedWorkInProgressProperties = null; - mayReplayFailedUnitOfWork = true; - isReplayingFailedUnitOfWork = false; - originalReplayError = null; - replayUnitOfWork = ( - failedUnitOfWork: Fiber, - thrownValue: mixed, - isYieldy: boolean, - ) => { - if ( - thrownValue !== null && - typeof thrownValue === 'object' && - typeof thrownValue.then === 'function' - ) { - // Don't replay promises. Treat everything else like an error. - // TODO: Need to figure out a different strategy if/when we add - // support for catching other types. - return; - } - - // Restore the original state of the work-in-progress - if (stashedWorkInProgressProperties === null) { - // This should never happen. Don't throw because this code is DEV-only. - warningWithoutStack( - false, - 'Could not replay rendering after an error. This is likely a bug in React. ' + - 'Please file an issue.', - ); - return; - } - assignFiberPropertiesInDEV( - failedUnitOfWork, - stashedWorkInProgressProperties, - ); - - switch (failedUnitOfWork.tag) { - case HostRoot: - popHostContainer(failedUnitOfWork); - popTopLevelLegacyContextObject(failedUnitOfWork); - break; - case HostComponent: - popHostContext(failedUnitOfWork); - break; - case ClassComponent: { - const Component = failedUnitOfWork.type; - if (isLegacyContextProvider(Component)) { - popLegacyContext(failedUnitOfWork); - } - break; - } - case HostPortal: - popHostContainer(failedUnitOfWork); - break; - case ContextProvider: - popProvider(failedUnitOfWork); - break; - } - // Replay the begin phase. - isReplayingFailedUnitOfWork = true; - originalReplayError = thrownValue; - invokeGuardedCallback(null, workLoop, null, isYieldy); - isReplayingFailedUnitOfWork = false; - originalReplayError = null; - if (hasCaughtError()) { - const replayError = clearCaughtError(); - if (replayError != null && thrownValue != null) { - try { - // Reading the expando property is intentionally - // inside `try` because it might be a getter or Proxy. - if (replayError._suppressLogging) { - // Also suppress logging for the original error. - (thrownValue: any)._suppressLogging = true; - } - } catch (inner) { - // Ignore. - } - } - } else { - // If the begin phase did not fail the second time, set this pointer - // back to the original value. - nextUnitOfWork = failedUnitOfWork; - } - }; - rethrowOriginalError = () => { - throw originalReplayError; - }; -} - -function resetStack() { - if (nextUnitOfWork !== null) { - let interruptedWork = nextUnitOfWork.return; - while (interruptedWork !== null) { - unwindInterruptedWork(interruptedWork); - interruptedWork = interruptedWork.return; - } - } - - if (__DEV__) { - ReactStrictModeWarnings.discardPendingWarnings(); - checkThatStackIsEmpty(); - } - - nextRoot = null; - nextRenderExpirationTime = NoWork; - nextLatestAbsoluteTimeoutMs = -1; - nextRenderDidError = false; - nextUnitOfWork = null; -} - -function commitAllHostEffects() { - while (nextEffect !== null) { - if (__DEV__) { - setCurrentFiber(nextEffect); - } - recordEffect(); - - const effectTag = nextEffect.effectTag; - - if (effectTag & ContentReset) { - commitResetTextContent(nextEffect); - } - - if (effectTag & Ref) { - const current = nextEffect.alternate; - if (current !== null) { - commitDetachRef(current); - } - } - - // The following switch statement is only concerned about placement, - // updates, and deletions. To avoid needing to add a case for every - // possible bitmap value, we remove the secondary effects from the - // effect tag and switch on that value. - let primaryEffectTag = effectTag & (Placement | Update | Deletion); - switch (primaryEffectTag) { - case Placement: { - commitPlacement(nextEffect); - // Clear the "placement" from effect tag so that we know that this is inserted, before - // any life-cycles like componentDidMount gets called. - // TODO: findDOMNode doesn't rely on this any more but isMounted - // does and isMounted is deprecated anyway so we should be able - // to kill this. - nextEffect.effectTag &= ~Placement; - break; - } - case PlacementAndUpdate: { - // Placement - commitPlacement(nextEffect); - // Clear the "placement" from effect tag so that we know that this is inserted, before - // any life-cycles like componentDidMount gets called. - nextEffect.effectTag &= ~Placement; - - // Update - const current = nextEffect.alternate; - commitWork(current, nextEffect); - break; - } - case Update: { - const current = nextEffect.alternate; - commitWork(current, nextEffect); - break; - } - case Deletion: { - commitDeletion(nextEffect); - break; - } - } - nextEffect = nextEffect.nextEffect; - } - - if (__DEV__) { - resetCurrentFiber(); - } -} - -function commitBeforeMutationLifecycles() { - while (nextEffect !== null) { - if (__DEV__) { - setCurrentFiber(nextEffect); - } - - const effectTag = nextEffect.effectTag; - if (effectTag & Snapshot) { - recordEffect(); - const current = nextEffect.alternate; - commitBeforeMutationLifeCycles(current, nextEffect); - } - - nextEffect = nextEffect.nextEffect; - } - - if (__DEV__) { - resetCurrentFiber(); - } -} - -function commitAllLifeCycles( - finishedRoot: FiberRoot, - committedExpirationTime: ExpirationTime, -) { - if (__DEV__) { - ReactStrictModeWarnings.flushPendingUnsafeLifecycleWarnings(); - ReactStrictModeWarnings.flushLegacyContextWarning(); - - if (warnAboutDeprecatedLifecycles) { - ReactStrictModeWarnings.flushPendingDeprecationWarnings(); - } - } - while (nextEffect !== null) { - if (__DEV__) { - setCurrentFiber(nextEffect); - } - const effectTag = nextEffect.effectTag; - - if (effectTag & (Update | Callback)) { - recordEffect(); - const current = nextEffect.alternate; - commitLifeCycles( - finishedRoot, - current, - nextEffect, - committedExpirationTime, - ); - } - - if (effectTag & Ref) { - recordEffect(); - commitAttachRef(nextEffect); - } - - if (effectTag & Passive) { - rootWithPendingPassiveEffects = finishedRoot; - } - - nextEffect = nextEffect.nextEffect; - } - if (__DEV__) { - resetCurrentFiber(); - } -} - -function commitPassiveEffects(root: FiberRoot, firstEffect: Fiber): void { - rootWithPendingPassiveEffects = null; - passiveEffectCallbackHandle = null; - passiveEffectCallback = null; - - // Set this to true to prevent re-entrancy - const previousIsRendering = isRendering; - isRendering = true; - - let effect = firstEffect; - do { - if (__DEV__) { - setCurrentFiber(effect); - } - - if (effect.effectTag & Passive) { - let didError = false; - let error; - if (__DEV__) { - isInPassiveEffectDEV = true; - invokeGuardedCallback(null, commitPassiveHookEffects, null, effect); - isInPassiveEffectDEV = false; - if (hasCaughtError()) { - didError = true; - error = clearCaughtError(); - } - } else { - try { - commitPassiveHookEffects(effect); - } catch (e) { - didError = true; - error = e; - } - } - if (didError) { - captureCommitPhaseError(effect, error); - } - } - effect = effect.nextEffect; - } while (effect !== null); - if (__DEV__) { - resetCurrentFiber(); - } - - isRendering = previousIsRendering; - - // Check if work was scheduled by one of the effects - const rootExpirationTime = root.expirationTime; - if (rootExpirationTime !== NoWork) { - requestWork(root, rootExpirationTime); - } - // Flush any sync work that was scheduled by effects - if (!isBatchingUpdates && !isRendering) { - performSyncWork(); - } - - if (__DEV__) { - if (rootWithPendingPassiveEffects === root) { - nestedPassiveEffectCountDEV++; - } else { - nestedPassiveEffectCountDEV = 0; - } - } -} - -function isAlreadyFailedLegacyErrorBoundary(instance: mixed): boolean { - return ( - legacyErrorBoundariesThatAlreadyFailed !== null && - legacyErrorBoundariesThatAlreadyFailed.has(instance) - ); -} - -function markLegacyErrorBoundaryAsFailed(instance: mixed) { - if (legacyErrorBoundariesThatAlreadyFailed === null) { - legacyErrorBoundariesThatAlreadyFailed = new Set([instance]); - } else { - legacyErrorBoundariesThatAlreadyFailed.add(instance); - } -} - -function flushPassiveEffects() { - const didFlushEffects = passiveEffectCallback !== null; - if (passiveEffectCallbackHandle !== null) { - cancelCallback(passiveEffectCallbackHandle); - } - if (passiveEffectCallback !== null) { - // We call the scheduled callback instead of commitPassiveEffects directly - // to ensure tracing works correctly. - passiveEffectCallback(); - } - return didFlushEffects; -} - -function commitRoot(root: FiberRoot, finishedWork: Fiber): void { - isWorking = true; - isCommitting = true; - startCommitTimer(); - - invariant( - root.current !== finishedWork, - 'Cannot commit the same tree as before. This is probably a bug ' + - 'related to the return field. This error is likely caused by a bug ' + - 'in React. Please file an issue.', - ); - const committedExpirationTime = root.pendingCommitExpirationTime; - invariant( - committedExpirationTime !== NoWork, - 'Cannot commit an incomplete root. This error is likely caused by a ' + - 'bug in React. Please file an issue.', - ); - root.pendingCommitExpirationTime = NoWork; - - // Update the pending priority levels to account for the work that we are - // about to commit. This needs to happen before calling the lifecycles, since - // they may schedule additional updates. - const updateExpirationTimeBeforeCommit = finishedWork.expirationTime; - const childExpirationTimeBeforeCommit = finishedWork.childExpirationTime; - const earliestRemainingTimeBeforeCommit = - childExpirationTimeBeforeCommit > updateExpirationTimeBeforeCommit - ? childExpirationTimeBeforeCommit - : updateExpirationTimeBeforeCommit; - markCommittedPriorityLevels(root, earliestRemainingTimeBeforeCommit); - - let prevInteractions: Set = (null: any); - if (enableSchedulerTracing) { - // Restore any pending interactions at this point, - // So that cascading work triggered during the render phase will be accounted for. - prevInteractions = __interactionsRef.current; - __interactionsRef.current = root.memoizedInteractions; - } - - // Reset this to null before calling lifecycles - ReactCurrentOwner.current = null; - - let firstEffect; - if (finishedWork.effectTag > PerformedWork) { - // A fiber's effect list consists only of its children, not itself. So if - // the root has an effect, we need to add it to the end of the list. The - // resulting list is the set that would belong to the root's parent, if - // it had one; that is, all the effects in the tree including the root. - if (finishedWork.lastEffect !== null) { - finishedWork.lastEffect.nextEffect = finishedWork; - firstEffect = finishedWork.firstEffect; - } else { - firstEffect = finishedWork; - } - } else { - // There is no effect on the root. - firstEffect = finishedWork.firstEffect; - } - - prepareForCommit(root.containerInfo); - - // Invoke instances of getSnapshotBeforeUpdate before mutation. - nextEffect = firstEffect; - startCommitSnapshotEffectsTimer(); - while (nextEffect !== null) { - let didError = false; - let error; - if (__DEV__) { - invokeGuardedCallback(null, commitBeforeMutationLifecycles, null); - if (hasCaughtError()) { - didError = true; - error = clearCaughtError(); - } - } else { - try { - commitBeforeMutationLifecycles(); - } catch (e) { - didError = true; - error = e; - } - } - if (didError) { - invariant( - nextEffect !== null, - 'Should have next effect. This error is likely caused by a bug ' + - 'in React. Please file an issue.', - ); - captureCommitPhaseError(nextEffect, error); - // Clean-up - if (nextEffect !== null) { - nextEffect = nextEffect.nextEffect; - } - } - } - stopCommitSnapshotEffectsTimer(); - - if (enableProfilerTimer) { - // Mark the current commit time to be shared by all Profilers in this batch. - // This enables them to be grouped later. - recordCommitTime(); - } - - // Commit all the side-effects within a tree. We'll do this in two passes. - // The first pass performs all the host insertions, updates, deletions and - // ref unmounts. - nextEffect = firstEffect; - startCommitHostEffectsTimer(); - while (nextEffect !== null) { - let didError = false; - let error; - if (__DEV__) { - invokeGuardedCallback(null, commitAllHostEffects, null); - if (hasCaughtError()) { - didError = true; - error = clearCaughtError(); - } - } else { - try { - commitAllHostEffects(); - } catch (e) { - didError = true; - error = e; - } - } - if (didError) { - invariant( - nextEffect !== null, - 'Should have next effect. This error is likely caused by a bug ' + - 'in React. Please file an issue.', - ); - captureCommitPhaseError(nextEffect, error); - // Clean-up - if (nextEffect !== null) { - nextEffect = nextEffect.nextEffect; - } - } - } - stopCommitHostEffectsTimer(); - - resetAfterCommit(root.containerInfo); - - // The work-in-progress tree is now the current tree. This must come after - // the first pass of the commit phase, so that the previous tree is still - // current during componentWillUnmount, but before the second pass, so that - // the finished work is current during componentDidMount/Update. - root.current = finishedWork; - - // In the second pass we'll perform all life-cycles and ref callbacks. - // Life-cycles happen as a separate pass so that all placements, updates, - // and deletions in the entire tree have already been invoked. - // This pass also triggers any renderer-specific initial effects. - nextEffect = firstEffect; - startCommitLifeCyclesTimer(); - while (nextEffect !== null) { - let didError = false; - let error; - if (__DEV__) { - invokeGuardedCallback( - null, - commitAllLifeCycles, - null, - root, - committedExpirationTime, - ); - if (hasCaughtError()) { - didError = true; - error = clearCaughtError(); - } - } else { - try { - commitAllLifeCycles(root, committedExpirationTime); - } catch (e) { - didError = true; - error = e; - } - } - if (didError) { - invariant( - nextEffect !== null, - 'Should have next effect. This error is likely caused by a bug ' + - 'in React. Please file an issue.', - ); - captureCommitPhaseError(nextEffect, error); - if (nextEffect !== null) { - nextEffect = nextEffect.nextEffect; - } - } - } - - if (firstEffect !== null && rootWithPendingPassiveEffects !== null) { - // This commit included a passive effect. These do not need to fire until - // after the next paint. Schedule an callback to fire them in an async - // event. To ensure serial execution, the callback will be flushed early if - // we enter rootWithPendingPassiveEffects commit phase before then. - let callback = commitPassiveEffects.bind(null, root, firstEffect); - if (enableSchedulerTracing) { - // TODO: Avoid this extra callback by mutating the tracing ref directly, - // like we do at the beginning of commitRoot. I've opted not to do that - // here because that code is still in flux. - callback = Scheduler_tracing_wrap(callback); - } - passiveEffectCallbackHandle = scheduleCallback(NormalPriority, callback); - passiveEffectCallback = callback; - } - - isCommitting = false; - isWorking = false; - stopCommitLifeCyclesTimer(); - stopCommitTimer(); - onCommitRoot(finishedWork.stateNode); - if (__DEV__ && ReactFiberInstrumentation.debugTool) { - ReactFiberInstrumentation.debugTool.onCommitWork(finishedWork); - } - - const updateExpirationTimeAfterCommit = finishedWork.expirationTime; - const childExpirationTimeAfterCommit = finishedWork.childExpirationTime; - const earliestRemainingTimeAfterCommit = - childExpirationTimeAfterCommit > updateExpirationTimeAfterCommit - ? childExpirationTimeAfterCommit - : updateExpirationTimeAfterCommit; - if (earliestRemainingTimeAfterCommit === NoWork) { - // If there's no remaining work, we can clear the set of already failed - // error boundaries. - legacyErrorBoundariesThatAlreadyFailed = null; - } - onCommit(root, earliestRemainingTimeAfterCommit); - - if (enableSchedulerTracing) { - __interactionsRef.current = prevInteractions; - - let subscriber; - - try { - subscriber = __subscriberRef.current; - if (subscriber !== null && root.memoizedInteractions.size > 0) { - const threadID = computeThreadID( - committedExpirationTime, - root.interactionThreadID, - ); - subscriber.onWorkStopped(root.memoizedInteractions, threadID); - } - } catch (error) { - // It's not safe for commitRoot() to throw. - // Store the error for now and we'll re-throw in finishRendering(). - if (!hasUnhandledError) { - hasUnhandledError = true; - unhandledError = error; - } - } finally { - // Clear completed interactions from the pending Map. - // Unless the render was suspended or cascading work was scheduled, - // In which case– leave pending interactions until the subsequent render. - const pendingInteractionMap = root.pendingInteractionMap; - pendingInteractionMap.forEach( - (scheduledInteractions, scheduledExpirationTime) => { - // Only decrement the pending interaction count if we're done. - // If there's still work at the current priority, - // That indicates that we are waiting for suspense data. - if (scheduledExpirationTime > earliestRemainingTimeAfterCommit) { - pendingInteractionMap.delete(scheduledExpirationTime); - - scheduledInteractions.forEach(interaction => { - interaction.__count--; - - if (subscriber !== null && interaction.__count === 0) { - try { - subscriber.onInteractionScheduledWorkCompleted(interaction); - } catch (error) { - // It's not safe for commitRoot() to throw. - // Store the error for now and we'll re-throw in finishRendering(). - if (!hasUnhandledError) { - hasUnhandledError = true; - unhandledError = error; - } - } - } - }); - } - }, - ); - } - } -} - -function resetChildExpirationTime( - workInProgress: Fiber, - renderTime: ExpirationTime, -) { - if (renderTime !== Never && workInProgress.childExpirationTime === Never) { - // The children of this component are hidden. Don't bubble their - // expiration times. - return; - } - - let newChildExpirationTime = NoWork; - - // Bubble up the earliest expiration time. - if (enableProfilerTimer && workInProgress.mode & ProfileMode) { - // We're in profiling mode. - // Let's use this same traversal to update the render durations. - let actualDuration = workInProgress.actualDuration; - let treeBaseDuration = workInProgress.selfBaseDuration; - - // When a fiber is cloned, its actualDuration is reset to 0. - // This value will only be updated if work is done on the fiber (i.e. it doesn't bailout). - // When work is done, it should bubble to the parent's actualDuration. - // If the fiber has not been cloned though, (meaning no work was done), - // Then this value will reflect the amount of time spent working on a previous render. - // In that case it should not bubble. - // We determine whether it was cloned by comparing the child pointer. - const shouldBubbleActualDurations = - workInProgress.alternate === null || - workInProgress.child !== workInProgress.alternate.child; - - let child = workInProgress.child; - while (child !== null) { - const childUpdateExpirationTime = child.expirationTime; - const childChildExpirationTime = child.childExpirationTime; - if (childUpdateExpirationTime > newChildExpirationTime) { - newChildExpirationTime = childUpdateExpirationTime; - } - if (childChildExpirationTime > newChildExpirationTime) { - newChildExpirationTime = childChildExpirationTime; - } - if (shouldBubbleActualDurations) { - actualDuration += child.actualDuration; - } - treeBaseDuration += child.treeBaseDuration; - child = child.sibling; - } - workInProgress.actualDuration = actualDuration; - workInProgress.treeBaseDuration = treeBaseDuration; - } else { - let child = workInProgress.child; - while (child !== null) { - const childUpdateExpirationTime = child.expirationTime; - const childChildExpirationTime = child.childExpirationTime; - if (childUpdateExpirationTime > newChildExpirationTime) { - newChildExpirationTime = childUpdateExpirationTime; - } - if (childChildExpirationTime > newChildExpirationTime) { - newChildExpirationTime = childChildExpirationTime; - } - child = child.sibling; - } - } - - workInProgress.childExpirationTime = newChildExpirationTime; -} - -function completeUnitOfWork(workInProgress: Fiber): Fiber | null { - // Attempt to complete the current unit of work, then move to the - // next sibling. If there are no more siblings, return to the - // parent fiber. - while (true) { - // The current, flushed, state of this fiber is the alternate. - // Ideally nothing should rely on this, but relying on it here - // means that we don't need an additional field on the work in - // progress. - const current = workInProgress.alternate; - if (__DEV__) { - setCurrentFiber(workInProgress); - } - - const returnFiber = workInProgress.return; - const siblingFiber = workInProgress.sibling; - - if ((workInProgress.effectTag & Incomplete) === NoEffect) { - if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { - // Don't replay if it fails during completion phase. - mayReplayFailedUnitOfWork = false; - } - // This fiber completed. - // Remember we're completing this unit so we can find a boundary if it fails. - nextUnitOfWork = workInProgress; - if (enableProfilerTimer) { - if (workInProgress.mode & ProfileMode) { - startProfilerTimer(workInProgress); - } - nextUnitOfWork = completeWork( - current, - workInProgress, - nextRenderExpirationTime, - ); - if (workInProgress.mode & ProfileMode) { - // Update render duration assuming we didn't error. - stopProfilerTimerIfRunningAndRecordDelta(workInProgress, false); - } - } else { - nextUnitOfWork = completeWork( - current, - workInProgress, - nextRenderExpirationTime, - ); - } - if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { - // We're out of completion phase so replaying is fine now. - mayReplayFailedUnitOfWork = true; - } - stopWorkTimer(workInProgress); - resetChildExpirationTime(workInProgress, nextRenderExpirationTime); - if (__DEV__) { - resetCurrentFiber(); - } - - if (nextUnitOfWork !== null) { - // Completing this fiber spawned new work. Work on that next. - return nextUnitOfWork; - } - - if ( - returnFiber !== null && - // Do not append effects to parents if a sibling failed to complete - (returnFiber.effectTag & Incomplete) === NoEffect - ) { - // Append all the effects of the subtree and this fiber onto the effect - // list of the parent. The completion order of the children affects the - // side-effect order. - if (returnFiber.firstEffect === null) { - returnFiber.firstEffect = workInProgress.firstEffect; - } - if (workInProgress.lastEffect !== null) { - if (returnFiber.lastEffect !== null) { - returnFiber.lastEffect.nextEffect = workInProgress.firstEffect; - } - returnFiber.lastEffect = workInProgress.lastEffect; - } - - // If this fiber had side-effects, we append it AFTER the children's - // side-effects. We can perform certain side-effects earlier if - // needed, by doing multiple passes over the effect list. We don't want - // to schedule our own side-effect on our own list because if end up - // reusing children we'll schedule this effect onto itself since we're - // at the end. - const effectTag = workInProgress.effectTag; - // Skip both NoWork and PerformedWork tags when creating the effect list. - // PerformedWork effect is read by React DevTools but shouldn't be committed. - if (effectTag > PerformedWork) { - if (returnFiber.lastEffect !== null) { - returnFiber.lastEffect.nextEffect = workInProgress; - } else { - returnFiber.firstEffect = workInProgress; - } - returnFiber.lastEffect = workInProgress; - } - } - - if (__DEV__ && ReactFiberInstrumentation.debugTool) { - ReactFiberInstrumentation.debugTool.onCompleteWork(workInProgress); - } - - if (siblingFiber !== null) { - // If there is more work to do in this returnFiber, do that next. - return siblingFiber; - } else if (returnFiber !== null) { - // If there's no more work in this returnFiber. Complete the returnFiber. - workInProgress = returnFiber; - continue; - } else { - // We've reached the root. - return null; - } - } else { - if (enableProfilerTimer && workInProgress.mode & ProfileMode) { - // Record the render duration for the fiber that errored. - stopProfilerTimerIfRunningAndRecordDelta(workInProgress, false); - - // Include the time spent working on failed children before continuing. - let actualDuration = workInProgress.actualDuration; - let child = workInProgress.child; - while (child !== null) { - actualDuration += child.actualDuration; - child = child.sibling; - } - workInProgress.actualDuration = actualDuration; - } - - // This fiber did not complete because something threw. Pop values off - // the stack without entering the complete phase. If this is a boundary, - // capture values if possible. - const next = unwindWork(workInProgress, nextRenderExpirationTime); - // Because this fiber did not complete, don't reset its expiration time. - if (workInProgress.effectTag & DidCapture) { - // Restarting an error boundary - stopFailedWorkTimer(workInProgress); - } else { - stopWorkTimer(workInProgress); - } - - if (__DEV__) { - resetCurrentFiber(); - } - - if (next !== null) { - stopWorkTimer(workInProgress); - if (__DEV__ && ReactFiberInstrumentation.debugTool) { - ReactFiberInstrumentation.debugTool.onCompleteWork(workInProgress); - } - - // If completing this work spawned new work, do that next. We'll come - // back here again. - // Since we're restarting, remove anything that is not a host effect - // from the effect tag. - next.effectTag &= HostEffectMask; - return next; - } - - if (returnFiber !== null) { - // Mark the parent fiber as incomplete and clear its effect list. - returnFiber.firstEffect = returnFiber.lastEffect = null; - returnFiber.effectTag |= Incomplete; - } - - if (__DEV__ && ReactFiberInstrumentation.debugTool) { - ReactFiberInstrumentation.debugTool.onCompleteWork(workInProgress); - } - - if (siblingFiber !== null) { - // If there is more work to do in this returnFiber, do that next. - return siblingFiber; - } else if (returnFiber !== null) { - // If there's no more work in this returnFiber. Complete the returnFiber. - workInProgress = returnFiber; - continue; - } else { - return null; - } - } - } - - // Without this explicit null return Flow complains of invalid return type - // TODO Remove the above while(true) loop - // eslint-disable-next-line no-unreachable - return null; -} - -function performUnitOfWork(workInProgress: Fiber): Fiber | null { - // The current, flushed, state of this fiber is the alternate. - // Ideally nothing should rely on this, but relying on it here - // means that we don't need an additional field on the work in - // progress. - const current = workInProgress.alternate; - - // See if beginning this work spawns more work. - startWorkTimer(workInProgress); - if (__DEV__) { - setCurrentFiber(workInProgress); - } - - if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { - stashedWorkInProgressProperties = assignFiberPropertiesInDEV( - stashedWorkInProgressProperties, - workInProgress, - ); - } - - let next; - if (enableProfilerTimer) { - if (workInProgress.mode & ProfileMode) { - startProfilerTimer(workInProgress); - } - - next = beginWork(current, workInProgress, nextRenderExpirationTime); - workInProgress.memoizedProps = workInProgress.pendingProps; - - if (workInProgress.mode & ProfileMode) { - // Record the render duration assuming we didn't bailout (or error). - stopProfilerTimerIfRunningAndRecordDelta(workInProgress, true); - } - } else { - next = beginWork(current, workInProgress, nextRenderExpirationTime); - workInProgress.memoizedProps = workInProgress.pendingProps; - } - - if (__DEV__) { - resetCurrentFiber(); - if (isReplayingFailedUnitOfWork) { - // Currently replaying a failed unit of work. This should be unreachable, - // because the render phase is meant to be idempotent, and it should - // have thrown again. Since it didn't, rethrow the original error, so - // React's internal stack is not misaligned. - rethrowOriginalError(); - } - } - if (__DEV__ && ReactFiberInstrumentation.debugTool) { - ReactFiberInstrumentation.debugTool.onBeginWork(workInProgress); - } - - if (next === null) { - // If this doesn't spawn new work, complete the current work. - next = completeUnitOfWork(workInProgress); - } - - ReactCurrentOwner.current = null; - - return next; -} - -function workLoop(isYieldy) { - if (!isYieldy) { - // Flush work without yielding - while (nextUnitOfWork !== null) { - nextUnitOfWork = performUnitOfWork(nextUnitOfWork); - } - } else { - // Flush asynchronous work until there's a higher priority event - while (nextUnitOfWork !== null && !shouldYield()) { - nextUnitOfWork = performUnitOfWork(nextUnitOfWork); - } - } -} - -function renderRoot(root: FiberRoot, isYieldy: boolean): void { - invariant( - !isWorking, - 'renderRoot was called recursively. This error is likely caused ' + - 'by a bug in React. Please file an issue.', - ); - - flushPassiveEffects(); - - isWorking = true; - const previousDispatcher = ReactCurrentDispatcher.current; - ReactCurrentDispatcher.current = ContextOnlyDispatcher; - - const expirationTime = root.nextExpirationTimeToWorkOn; - - // Check if we're starting from a fresh stack, or if we're resuming from - // previously yielded work. - if ( - expirationTime !== nextRenderExpirationTime || - root !== nextRoot || - nextUnitOfWork === null - ) { - // Reset the stack and start working from the root. - resetStack(); - nextRoot = root; - nextRenderExpirationTime = expirationTime; - nextUnitOfWork = createWorkInProgress( - nextRoot.current, - null, - nextRenderExpirationTime, - ); - root.pendingCommitExpirationTime = NoWork; - - if (enableSchedulerTracing) { - // Determine which interactions this batch of work currently includes, - // So that we can accurately attribute time spent working on it, - // And so that cascading work triggered during the render phase will be associated with it. - const interactions: Set = new Set(); - root.pendingInteractionMap.forEach( - (scheduledInteractions, scheduledExpirationTime) => { - if (scheduledExpirationTime >= expirationTime) { - scheduledInteractions.forEach(interaction => - interactions.add(interaction), - ); - } - }, - ); - - // Store the current set of interactions on the FiberRoot for a few reasons: - // We can re-use it in hot functions like renderRoot() without having to recalculate it. - // We will also use it in commitWork() to pass to any Profiler onRender() hooks. - // This also provides DevTools with a way to access it when the onCommitRoot() hook is called. - root.memoizedInteractions = interactions; - - if (interactions.size > 0) { - const subscriber = __subscriberRef.current; - if (subscriber !== null) { - const threadID = computeThreadID( - expirationTime, - root.interactionThreadID, - ); - try { - subscriber.onWorkStarted(interactions, threadID); - } catch (error) { - // Work thrown by an interaction tracing subscriber should be rethrown, - // But only once it's safe (to avoid leaving the scheduler in an invalid state). - // Store the error for now and we'll re-throw in finishRendering(). - if (!hasUnhandledError) { - hasUnhandledError = true; - unhandledError = error; - } - } - } - } - } - } - - let prevInteractions: Set = (null: any); - if (enableSchedulerTracing) { - // We're about to start new traced work. - // Restore pending interactions so cascading work triggered during the render phase will be accounted for. - prevInteractions = __interactionsRef.current; - __interactionsRef.current = root.memoizedInteractions; - } - - let didFatal = false; - - startWorkLoopTimer(nextUnitOfWork); - - do { - try { - workLoop(isYieldy); - } catch (thrownValue) { - resetContextDependences(); - resetHooks(); - - // Reset in case completion throws. - // This is only used in DEV and when replaying is on. - let mayReplay; - if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { - mayReplay = mayReplayFailedUnitOfWork; - mayReplayFailedUnitOfWork = true; - } - - if (nextUnitOfWork === null) { - // This is a fatal error. - didFatal = true; - onUncaughtError(thrownValue); - } else { - if (enableProfilerTimer && nextUnitOfWork.mode & ProfileMode) { - // Record the time spent rendering before an error was thrown. - // This avoids inaccurate Profiler durations in the case of a suspended render. - stopProfilerTimerIfRunningAndRecordDelta(nextUnitOfWork, true); - } - - if (__DEV__) { - // Reset global debug state - // We assume this is defined in DEV - (resetCurrentlyProcessingQueue: any)(); - } - - if (__DEV__ && replayFailedUnitOfWorkWithInvokeGuardedCallback) { - if (mayReplay) { - const failedUnitOfWork: Fiber = nextUnitOfWork; - replayUnitOfWork(failedUnitOfWork, thrownValue, isYieldy); - } - } - - // TODO: we already know this isn't true in some cases. - // At least this shows a nicer error message until we figure out the cause. - // https://github.com/facebook/react/issues/12449#issuecomment-386727431 - invariant( - nextUnitOfWork !== null, - 'Failed to replay rendering after an error. This ' + - 'is likely caused by a bug in React. Please file an issue ' + - 'with a reproducing case to help us find it.', - ); - - const sourceFiber: Fiber = nextUnitOfWork; - let returnFiber = sourceFiber.return; - if (returnFiber === null) { - // This is the root. The root could capture its own errors. However, - // we don't know if it errors before or after we pushed the host - // context. This information is needed to avoid a stack mismatch. - // Because we're not sure, treat this as a fatal error. We could track - // which phase it fails in, but doesn't seem worth it. At least - // for now. - didFatal = true; - onUncaughtError(thrownValue); - } else { - throwException( - root, - returnFiber, - sourceFiber, - thrownValue, - nextRenderExpirationTime, - ); - nextUnitOfWork = completeUnitOfWork(sourceFiber); - continue; - } - } - } - break; - } while (true); - - if (enableSchedulerTracing) { - // Traced work is done for now; restore the previous interactions. - __interactionsRef.current = prevInteractions; - } - - // We're done performing work. Time to clean up. - isWorking = false; - ReactCurrentDispatcher.current = previousDispatcher; - resetContextDependences(); - resetHooks(); - - // Yield back to main thread. - if (didFatal) { - const didCompleteRoot = false; - stopWorkLoopTimer(interruptedBy, didCompleteRoot); - interruptedBy = null; - // There was a fatal error. - if (__DEV__) { - resetStackAfterFatalErrorInDev(); - } - // `nextRoot` points to the in-progress root. A non-null value indicates - // that we're in the middle of an async render. Set it to null to indicate - // there's no more work to be done in the current batch. - nextRoot = null; - onFatal(root); - return; - } - - if (nextUnitOfWork !== null) { - // There's still remaining async work in this tree, but we ran out of time - // in the current frame. Yield back to the renderer. Unless we're - // interrupted by a higher priority update, we'll continue later from where - // we left off. - const didCompleteRoot = false; - stopWorkLoopTimer(interruptedBy, didCompleteRoot); - interruptedBy = null; - onYield(root); - return; - } - - // We completed the whole tree. - const didCompleteRoot = true; - stopWorkLoopTimer(interruptedBy, didCompleteRoot); - const rootWorkInProgress = root.current.alternate; - invariant( - rootWorkInProgress !== null, - 'Finished root should have a work-in-progress. This error is likely ' + - 'caused by a bug in React. Please file an issue.', - ); - - // `nextRoot` points to the in-progress root. A non-null value indicates - // that we're in the middle of an async render. Set it to null to indicate - // there's no more work to be done in the current batch. - nextRoot = null; - interruptedBy = null; - - if (nextRenderDidError) { - // There was an error - if (hasLowerPriorityWork(root, expirationTime)) { - // There's lower priority work. If so, it may have the effect of fixing - // the exception that was just thrown. Exit without committing. This is - // similar to a suspend, but without a timeout because we're not waiting - // for a promise to resolve. React will restart at the lower - // priority level. - markSuspendedPriorityLevel(root, expirationTime); - const suspendedExpirationTime = expirationTime; - const rootExpirationTime = root.expirationTime; - onSuspend( - root, - rootWorkInProgress, - suspendedExpirationTime, - rootExpirationTime, - -1, // Indicates no timeout - ); - return; - } else if ( - // There's no lower priority work, but we're rendering asynchronously. - // Synchronously attempt to render the same level one more time. This is - // similar to a suspend, but without a timeout because we're not waiting - // for a promise to resolve. - !root.didError && - isYieldy - ) { - root.didError = true; - const suspendedExpirationTime = (root.nextExpirationTimeToWorkOn = expirationTime); - const rootExpirationTime = (root.expirationTime = Sync); - onSuspend( - root, - rootWorkInProgress, - suspendedExpirationTime, - rootExpirationTime, - -1, // Indicates no timeout - ); - return; - } - } - - if (isYieldy && nextLatestAbsoluteTimeoutMs !== -1) { - // The tree was suspended. - const suspendedExpirationTime = expirationTime; - markSuspendedPriorityLevel(root, suspendedExpirationTime); - - // Find the earliest uncommitted expiration time in the tree, including - // work that is suspended. The timeout threshold cannot be longer than - // the overall expiration. - const earliestExpirationTime = findEarliestOutstandingPriorityLevel( - root, - expirationTime, - ); - const earliestExpirationTimeMs = expirationTimeToMs(earliestExpirationTime); - if (earliestExpirationTimeMs < nextLatestAbsoluteTimeoutMs) { - nextLatestAbsoluteTimeoutMs = earliestExpirationTimeMs; - } - - // Subtract the current time from the absolute timeout to get the number - // of milliseconds until the timeout. In other words, convert an absolute - // timestamp to a relative time. This is the value that is passed - // to `setTimeout`. - const currentTimeMs = expirationTimeToMs(requestCurrentTime()); - let msUntilTimeout = nextLatestAbsoluteTimeoutMs - currentTimeMs; - msUntilTimeout = msUntilTimeout < 0 ? 0 : msUntilTimeout; - - // TODO: Account for the Just Noticeable Difference - - const rootExpirationTime = root.expirationTime; - onSuspend( - root, - rootWorkInProgress, - suspendedExpirationTime, - rootExpirationTime, - msUntilTimeout, - ); - return; - } - - // Ready to commit. - onComplete(root, rootWorkInProgress, expirationTime); -} - -function captureCommitPhaseError(sourceFiber: Fiber, value: mixed) { - const expirationTime = Sync; - let fiber = sourceFiber.return; - while (fiber !== null) { - switch (fiber.tag) { - case ClassComponent: - const ctor = fiber.type; - const instance = fiber.stateNode; - if ( - typeof ctor.getDerivedStateFromError === 'function' || - (typeof instance.componentDidCatch === 'function' && - !isAlreadyFailedLegacyErrorBoundary(instance)) - ) { - const errorInfo = createCapturedValue(value, sourceFiber); - const update = createClassErrorUpdate( - fiber, - errorInfo, - expirationTime, - ); - enqueueUpdate(fiber, update); - scheduleWork(fiber, expirationTime); - return; - } - break; - case HostRoot: { - const errorInfo = createCapturedValue(value, sourceFiber); - const update = createRootErrorUpdate(fiber, errorInfo, expirationTime); - enqueueUpdate(fiber, update); - scheduleWork(fiber, expirationTime); - return; - } - } - fiber = fiber.return; - } - - if (sourceFiber.tag === HostRoot) { - // Error was thrown at the root. There is no parent, so the root - // itself should capture it. - const rootFiber = sourceFiber; - const errorInfo = createCapturedValue(value, rootFiber); - const update = createRootErrorUpdate(rootFiber, errorInfo, expirationTime); - enqueueUpdate(rootFiber, update); - scheduleWork(rootFiber, expirationTime); - } -} - -function computeThreadID( - expirationTime: ExpirationTime, - interactionThreadID: number, -): number { - // Interaction threads are unique per root and expiration time. - return expirationTime * 1000 + interactionThreadID; -} - -// Creates a unique async expiration time. -function computeUniqueAsyncExpiration(): ExpirationTime { - const currentTime = requestCurrentTime(); - let result = computeAsyncExpiration(currentTime); - if (result >= lastUniqueAsyncExpiration) { - // Since we assume the current time monotonically increases, we only hit - // this branch when computeUniqueAsyncExpiration is fired multiple times - // within a 200ms window (or whatever the async bucket size is). - result = lastUniqueAsyncExpiration - 1; - } - lastUniqueAsyncExpiration = result; - return lastUniqueAsyncExpiration; -} - -function computeExpirationForFiber(currentTime: ExpirationTime, fiber: Fiber) { - let expirationTime; - if (expirationContext !== NoWork) { - // An explicit expiration context was set; - expirationTime = expirationContext; - } else if (isWorking) { - if (isCommitting) { - // Updates that occur during the commit phase should have sync priority - // by default. - expirationTime = Sync; - } else { - // Updates during the render phase should expire at the same time as - // the work that is being rendered. - expirationTime = nextRenderExpirationTime; - } - } else { - // No explicit expiration context was set, and we're not currently - // performing work. Calculate a new expiration time. - if (fiber.mode & ConcurrentMode) { - if (isBatchingInteractiveUpdates) { - // This is an interactive update - expirationTime = computeInteractiveExpiration(currentTime); - } else { - // This is an async update - expirationTime = computeAsyncExpiration(currentTime); - } - // If we're in the middle of rendering a tree, do not update at the same - // expiration time that is already rendering. - if (nextRoot !== null && expirationTime === nextRenderExpirationTime) { - expirationTime -= 1; - } - } else { - // This is a sync update - expirationTime = Sync; - } - } - if (isBatchingInteractiveUpdates) { - // This is an interactive update. Keep track of the lowest pending - // interactive expiration time. This allows us to synchronously flush - // all interactive updates when needed. - if ( - lowestPriorityPendingInteractiveExpirationTime === NoWork || - expirationTime < lowestPriorityPendingInteractiveExpirationTime - ) { - lowestPriorityPendingInteractiveExpirationTime = expirationTime; - } - } - return expirationTime; -} - -function renderDidSuspend( - root: FiberRoot, - absoluteTimeoutMs: number, - suspendedTime: ExpirationTime, -) { - // Schedule the timeout. - if ( - absoluteTimeoutMs >= 0 && - nextLatestAbsoluteTimeoutMs < absoluteTimeoutMs - ) { - nextLatestAbsoluteTimeoutMs = absoluteTimeoutMs; - } -} - -function renderDidError() { - nextRenderDidError = true; -} - -function inferStartTimeFromExpirationTime( - root: FiberRoot, - expirationTime: ExpirationTime, -) { - // We don't know exactly when the update was scheduled, but we can infer an - // approximate start time from the expiration time. First, find the earliest - // uncommitted expiration time in the tree, including work that is suspended. - // Then subtract the offset used to compute an async update's expiration time. - // This will cause high priority (interactive) work to expire earlier than - // necessary, but we can account for this by adjusting for the Just - // Noticeable Difference. - const earliestExpirationTime = findEarliestOutstandingPriorityLevel( - root, - expirationTime, - ); - const earliestExpirationTimeMs = expirationTimeToMs(earliestExpirationTime); - return earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION; -} - -function pingSuspendedRoot( - root: FiberRoot, - thenable: Thenable, - pingTime: ExpirationTime, -) { - // A promise that previously suspended React from committing has resolved. - // If React is still suspended, try again at the previous level (pingTime). - - const pingCache = root.pingCache; - if (pingCache !== null) { - // The thenable resolved, so we no longer need to memoize, because it will - // never be thrown again. - pingCache.delete(thenable); - } - - if (nextRoot !== null && nextRenderExpirationTime === pingTime) { - // Received a ping at the same priority level at which we're currently - // rendering. Restart from the root. - nextRoot = null; - } else { - // Confirm that the root is still suspended at this level. Otherwise exit. - if (isPriorityLevelSuspended(root, pingTime)) { - // Ping at the original level - markPingedPriorityLevel(root, pingTime); - const rootExpirationTime = root.expirationTime; - if (rootExpirationTime !== NoWork) { - requestWork(root, rootExpirationTime); - } - } - } -} - -function retryTimedOutBoundary(boundaryFiber: Fiber) { - const currentTime = requestCurrentTime(); - const retryTime = computeExpirationForFiber(currentTime, boundaryFiber); - const root = scheduleWorkToRoot(boundaryFiber, retryTime); - if (root !== null) { - markPendingPriorityLevel(root, retryTime); - const rootExpirationTime = root.expirationTime; - if (rootExpirationTime !== NoWork) { - requestWork(root, rootExpirationTime); - } - } -} - -function resolveRetryThenable(boundaryFiber: Fiber, thenable: Thenable) { - // The boundary fiber (a Suspense component) previously timed out and was - // rendered in its fallback state. One of the promises that suspended it has - // resolved, which means at least part of the tree was likely unblocked. Try - // rendering again, at a new expiration time. - - let retryCache: WeakSet | Set | null; - if (enableSuspenseServerRenderer) { - switch (boundaryFiber.tag) { - case SuspenseComponent: - retryCache = boundaryFiber.stateNode; - break; - case DehydratedSuspenseComponent: - retryCache = boundaryFiber.memoizedState; - break; - default: - invariant( - false, - 'Pinged unknown suspense boundary type. ' + - 'This is probably a bug in React.', - ); - } - } else { - retryCache = boundaryFiber.stateNode; - } - if (retryCache !== null) { - // The thenable resolved, so we no longer need to memoize, because it will - // never be thrown again. - retryCache.delete(thenable); - } - - retryTimedOutBoundary(boundaryFiber); -} - -function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null { - recordScheduleUpdate(); - - if (__DEV__) { - if (fiber.tag === ClassComponent) { - const instance = fiber.stateNode; - warnAboutInvalidUpdates(instance); - } - } - - // Update the source fiber's expiration time - if (fiber.expirationTime < expirationTime) { - fiber.expirationTime = expirationTime; - } - let alternate = fiber.alternate; - if (alternate !== null && alternate.expirationTime < expirationTime) { - alternate.expirationTime = expirationTime; - } - // Walk the parent path to the root and update the child expiration time. - let node = fiber.return; - let root = null; - if (node === null && fiber.tag === HostRoot) { - root = fiber.stateNode; - } else { - while (node !== null) { - alternate = node.alternate; - if (node.childExpirationTime < expirationTime) { - node.childExpirationTime = expirationTime; - if ( - alternate !== null && - alternate.childExpirationTime < expirationTime - ) { - alternate.childExpirationTime = expirationTime; - } - } else if ( - alternate !== null && - alternate.childExpirationTime < expirationTime - ) { - alternate.childExpirationTime = expirationTime; - } - if (node.return === null && node.tag === HostRoot) { - root = node.stateNode; - break; - } - node = node.return; - } - } - - if (enableSchedulerTracing) { - if (root !== null) { - const interactions = __interactionsRef.current; - if (interactions.size > 0) { - const pendingInteractionMap = root.pendingInteractionMap; - const pendingInteractions = pendingInteractionMap.get(expirationTime); - if (pendingInteractions != null) { - interactions.forEach(interaction => { - if (!pendingInteractions.has(interaction)) { - // Update the pending async work count for previously unscheduled interaction. - interaction.__count++; - } - - pendingInteractions.add(interaction); - }); - } else { - pendingInteractionMap.set(expirationTime, new Set(interactions)); - - // Update the pending async work count for the current interactions. - interactions.forEach(interaction => { - interaction.__count++; - }); - } - - const subscriber = __subscriberRef.current; - if (subscriber !== null) { - const threadID = computeThreadID( - expirationTime, - root.interactionThreadID, - ); - subscriber.onWorkScheduled(interactions, threadID); - } - } - } - } - return root; -} - -export function warnIfNotScopedWithMatchingAct(fiber: Fiber): void { - if (__DEV__) { - if ( - ReactShouldWarnActingUpdates.current !== null && - ReactShouldWarnActingUpdates.current !== ReactActingUpdatesSigil - ) { - // it looks like we're using the wrong matching act(), so log a warning - warningWithoutStack( - false, - "It looks like you're using the wrong act() around your interactions.\n" + - 'Be sure to use the matching version of act() corresponding to your renderer. e.g. -\n' + - "for react-dom, import {act} from 'react-dom/test-utils';\n" + - 'for react-test-renderer, const {act} = TestRenderer.' + - '%s', - getStackByFiberInDevAndProd(fiber), - ); - } - } -} - -// in a test-like environment, we want to warn if dispatchAction() is -// called outside of a TestUtils.act(...)/batchedUpdates/render call. -// so we have a a step counter for when we descend/ascend from -// act() calls, and test on it for when to warn -// It's a tuple with a single value. Look into ReactTestUtilsAct as an -// example of how we change the value - -export function warnIfNotCurrentlyActingUpdatesInDev(fiber: Fiber): void { - if (__DEV__) { - if ( - isBatchingUpdates === false && - isRendering === false && - ReactShouldWarnActingUpdates.current !== ReactActingUpdatesSigil - ) { - warningWithoutStack( - false, - 'An update to %s inside a test was not wrapped in act(...).\n\n' + - 'When testing, code that causes React state updates should be wrapped into act(...):\n\n' + - 'act(() => {\n' + - ' /* fire events that update state */\n' + - '});\n' + - '/* assert on the output */\n\n' + - "This ensures that you're testing the behavior the user would see in the browser." + - ' Learn more at https://fb.me/react-wrap-tests-with-act' + - '%s', - getComponentName(fiber.type), - getStackByFiberInDevAndProd(fiber), - ); - } - } -} - -function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) { - const root = scheduleWorkToRoot(fiber, expirationTime); - if (root === null) { - if (__DEV__) { - switch (fiber.tag) { - case ClassComponent: - warnAboutUpdateOnUnmounted(fiber, true); - break; - case FunctionComponent: - case ForwardRef: - case MemoComponent: - case SimpleMemoComponent: - warnAboutUpdateOnUnmounted(fiber, false); - break; - } - } - return; - } - - if ( - !isWorking && - nextRenderExpirationTime !== NoWork && - expirationTime > nextRenderExpirationTime - ) { - // This is an interruption. (Used for performance tracking.) - interruptedBy = fiber; - resetStack(); - } - markPendingPriorityLevel(root, expirationTime); - if ( - // If we're in the render phase, we don't need to schedule this root - // for an update, because we'll do it before we exit... - !isWorking || - isCommitting || - // ...unless this is a different root than the one we're rendering. - nextRoot !== root - ) { - const rootExpirationTime = root.expirationTime; - requestWork(root, rootExpirationTime); - } - if (nestedUpdateCount > NESTED_UPDATE_LIMIT) { - // Reset this back to zero so subsequent updates don't throw. - nestedUpdateCount = 0; - invariant( - false, - 'Maximum update depth exceeded. This can happen when a ' + - 'component repeatedly calls setState inside ' + - 'componentWillUpdate or componentDidUpdate. React limits ' + - 'the number of nested updates to prevent infinite loops.', - ); - } - if (__DEV__) { - if ( - isInPassiveEffectDEV && - nestedPassiveEffectCountDEV > NESTED_PASSIVE_UPDATE_LIMIT - ) { - nestedPassiveEffectCountDEV = 0; - warning( - false, - 'Maximum update depth exceeded. This can happen when a ' + - 'component calls setState inside useEffect, but ' + - "useEffect either doesn't have a dependency array, or " + - 'one of the dependencies changes on every render.', - ); - } - } -} - -function deferredUpdates(fn: () => A): A { - const currentTime = requestCurrentTime(); - const previousExpirationContext = expirationContext; - const previousIsBatchingInteractiveUpdates = isBatchingInteractiveUpdates; - expirationContext = computeAsyncExpiration(currentTime); - isBatchingInteractiveUpdates = false; - try { - return fn(); - } finally { - expirationContext = previousExpirationContext; - isBatchingInteractiveUpdates = previousIsBatchingInteractiveUpdates; - } -} - -function syncUpdates( - fn: (A, B, C0, D) => R, - a: A, - b: B, - c: C0, - d: D, -): R { - const previousExpirationContext = expirationContext; - expirationContext = Sync; - try { - return fn(a, b, c, d); - } finally { - expirationContext = previousExpirationContext; - } -} - -// TODO: Everything below this is written as if it has been lifted to the -// renderers. I'll do this in a follow-up. - -// Linked-list of roots -let firstScheduledRoot: FiberRoot | null = null; -let lastScheduledRoot: FiberRoot | null = null; - -let callbackExpirationTime: ExpirationTime = NoWork; -let callbackID: *; -let isRendering: boolean = false; -let nextFlushedRoot: FiberRoot | null = null; -let nextFlushedExpirationTime: ExpirationTime = NoWork; -let lowestPriorityPendingInteractiveExpirationTime: ExpirationTime = NoWork; -let hasUnhandledError: boolean = false; -let unhandledError: mixed | null = null; - -let isBatchingUpdates: boolean = false; -let isUnbatchingUpdates: boolean = false; -let isBatchingInteractiveUpdates: boolean = false; - -let completedBatches: Array | null = null; - -let originalStartTimeMs: number = now(); -let currentRendererTime: ExpirationTime = msToExpirationTime( - originalStartTimeMs, -); -let currentSchedulerTime: ExpirationTime = currentRendererTime; - -// Use these to prevent an infinite loop of nested updates -const NESTED_UPDATE_LIMIT = 50; -let nestedUpdateCount: number = 0; -let lastCommittedRootDuringThisBatch: FiberRoot | null = null; - -// Similar, but for useEffect infinite loops. These are DEV-only. -const NESTED_PASSIVE_UPDATE_LIMIT = 50; -let nestedPassiveEffectCountDEV; -let isInPassiveEffectDEV; -if (__DEV__) { - nestedPassiveEffectCountDEV = 0; - isInPassiveEffectDEV = false; -} - -function recomputeCurrentRendererTime() { - const currentTimeMs = now() - originalStartTimeMs; - currentRendererTime = msToExpirationTime(currentTimeMs); -} - -function scheduleCallbackWithExpirationTime( - root: FiberRoot, - expirationTime: ExpirationTime, -) { - if (callbackExpirationTime !== NoWork) { - // A callback is already scheduled. Check its expiration time (timeout). - if (expirationTime < callbackExpirationTime) { - // Existing callback has sufficient timeout. Exit. - return; - } else { - if (callbackID !== null) { - // Existing callback has insufficient timeout. Cancel and schedule a - // new one. - cancelCallback(callbackID); - } - } - // The request callback timer is already running. Don't start a new one. - } else { - startRequestCallbackTimer(); - } - - callbackExpirationTime = expirationTime; - const currentMs = now() - originalStartTimeMs; - const expirationTimeMs = expirationTimeToMs(expirationTime); - const timeout = expirationTimeMs - currentMs; - const priorityLevel = getCurrentPriorityLevel(); - callbackID = scheduleCallback(priorityLevel, performAsyncWork, {timeout}); -} - -// For every call to renderRoot, one of onFatal, onComplete, onSuspend, and -// onYield is called upon exiting. We use these in lieu of returning a tuple. -// I've also chosen not to inline them into renderRoot because these will -// eventually be lifted into the renderer. -function onFatal(root) { - root.finishedWork = null; -} - -function onComplete( - root: FiberRoot, - finishedWork: Fiber, - expirationTime: ExpirationTime, -) { - root.pendingCommitExpirationTime = expirationTime; - root.finishedWork = finishedWork; -} - -function onSuspend( - root: FiberRoot, - finishedWork: Fiber, - suspendedExpirationTime: ExpirationTime, - rootExpirationTime: ExpirationTime, - msUntilTimeout: number, -): void { - root.expirationTime = rootExpirationTime; - if (msUntilTimeout === 0 && (disableYielding || !shouldYield())) { - // Don't wait an additional tick. Commit the tree immediately. - root.pendingCommitExpirationTime = suspendedExpirationTime; - root.finishedWork = finishedWork; - } else if (msUntilTimeout > 0) { - // Wait `msUntilTimeout` milliseconds before committing. - root.timeoutHandle = scheduleTimeout( - onTimeout.bind(null, root, finishedWork, suspendedExpirationTime), - msUntilTimeout, - ); - } -} - -function onYield(root) { - root.finishedWork = null; -} - -function onTimeout(root, finishedWork, suspendedExpirationTime) { - // The root timed out. Commit it. - root.pendingCommitExpirationTime = suspendedExpirationTime; - root.finishedWork = finishedWork; - // Read the current time before entering the commit phase. We can be - // certain this won't cause tearing related to batching of event updates - // because we're at the top of a timer event. - recomputeCurrentRendererTime(); - currentSchedulerTime = currentRendererTime; - flushRoot(root, suspendedExpirationTime); -} - -function onCommit(root, expirationTime) { - root.expirationTime = expirationTime; - root.finishedWork = null; -} - -function requestCurrentTime() { - // requestCurrentTime is called by the scheduler to compute an expiration - // time. - // - // Expiration times are computed by adding to the current time (the start - // time). However, if two updates are scheduled within the same event, we - // should treat their start times as simultaneous, even if the actual clock - // time has advanced between the first and second call. - - // In other words, because expiration times determine how updates are batched, - // we want all updates of like priority that occur within the same event to - // receive the same expiration time. Otherwise we get tearing. - // - // We keep track of two separate times: the current "renderer" time and the - // current "scheduler" time. The renderer time can be updated whenever; it - // only exists to minimize the calls performance.now. - // - // But the scheduler time can only be updated if there's no pending work, or - // if we know for certain that we're not in the middle of an event. - - if (isRendering) { - // We're already rendering. Return the most recently read time. - return currentSchedulerTime; - } - // Check if there's pending work. - findHighestPriorityRoot(); - if ( - nextFlushedExpirationTime === NoWork || - nextFlushedExpirationTime === Never - ) { - // If there's no pending work, or if the pending work is offscreen, we can - // read the current time without risk of tearing. - recomputeCurrentRendererTime(); - currentSchedulerTime = currentRendererTime; - return currentSchedulerTime; - } - // There's already pending work. We might be in the middle of a browser - // event. If we were to read the current time, it could cause multiple updates - // within the same event to receive different expiration times, leading to - // tearing. Return the last read time. During the next idle callback, the - // time will be updated. - return currentSchedulerTime; -} - -// requestWork is called by the scheduler whenever a root receives an update. -// It's up to the renderer to call renderRoot at some point in the future. -function requestWork(root: FiberRoot, expirationTime: ExpirationTime) { - addRootToSchedule(root, expirationTime); - if (isRendering) { - // Prevent reentrancy. Remaining work will be scheduled at the end of - // the currently rendering batch. - return; - } - - if (isBatchingUpdates) { - // Flush work at the end of the batch. - if (isUnbatchingUpdates) { - // ...unless we're inside unbatchedUpdates, in which case we should - // flush it now. - nextFlushedRoot = root; - nextFlushedExpirationTime = Sync; - performWorkOnRoot(root, Sync, false); - } - return; - } - - // TODO: Get rid of Sync and use current time? - if (expirationTime === Sync) { - performSyncWork(); - } else { - scheduleCallbackWithExpirationTime(root, expirationTime); - } -} - -function addRootToSchedule(root: FiberRoot, expirationTime: ExpirationTime) { - // Add the root to the schedule. - // Check if this root is already part of the schedule. - if (root.nextScheduledRoot === null) { - // This root is not already scheduled. Add it. - root.expirationTime = expirationTime; - if (lastScheduledRoot === null) { - firstScheduledRoot = lastScheduledRoot = root; - root.nextScheduledRoot = root; - } else { - lastScheduledRoot.nextScheduledRoot = root; - lastScheduledRoot = root; - lastScheduledRoot.nextScheduledRoot = firstScheduledRoot; - } - } else { - // This root is already scheduled, but its priority may have increased. - const remainingExpirationTime = root.expirationTime; - if (expirationTime > remainingExpirationTime) { - // Update the priority. - root.expirationTime = expirationTime; - } - } -} - -function findHighestPriorityRoot() { - let highestPriorityWork = NoWork; - let highestPriorityRoot = null; - if (lastScheduledRoot !== null) { - let previousScheduledRoot = lastScheduledRoot; - let root = firstScheduledRoot; - while (root !== null) { - const remainingExpirationTime = root.expirationTime; - if (remainingExpirationTime === NoWork) { - // This root no longer has work. Remove it from the scheduler. - - // TODO: This check is redudant, but Flow is confused by the branch - // below where we set lastScheduledRoot to null, even though we break - // from the loop right after. - invariant( - previousScheduledRoot !== null && lastScheduledRoot !== null, - 'Should have a previous and last root. This error is likely ' + - 'caused by a bug in React. Please file an issue.', - ); - if (root === root.nextScheduledRoot) { - // This is the only root in the list. - root.nextScheduledRoot = null; - firstScheduledRoot = lastScheduledRoot = null; - break; - } else if (root === firstScheduledRoot) { - // This is the first root in the list. - const next = root.nextScheduledRoot; - firstScheduledRoot = next; - lastScheduledRoot.nextScheduledRoot = next; - root.nextScheduledRoot = null; - } else if (root === lastScheduledRoot) { - // This is the last root in the list. - lastScheduledRoot = previousScheduledRoot; - lastScheduledRoot.nextScheduledRoot = firstScheduledRoot; - root.nextScheduledRoot = null; - break; - } else { - previousScheduledRoot.nextScheduledRoot = root.nextScheduledRoot; - root.nextScheduledRoot = null; - } - root = previousScheduledRoot.nextScheduledRoot; - } else { - if (remainingExpirationTime > highestPriorityWork) { - // Update the priority, if it's higher - highestPriorityWork = remainingExpirationTime; - highestPriorityRoot = root; - } - if (root === lastScheduledRoot) { - break; - } - if (highestPriorityWork === Sync) { - // Sync is highest priority by definition so - // we can stop searching. - break; - } - previousScheduledRoot = root; - root = root.nextScheduledRoot; - } - } - } - - nextFlushedRoot = highestPriorityRoot; - nextFlushedExpirationTime = highestPriorityWork; -} - -function performAsyncWork(didTimeout) { - if (didTimeout) { - // The callback timed out. That means at least one update has expired. - // Iterate through the root schedule. If they contain expired work, set - // the next render expiration time to the current time. This has the effect - // of flushing all expired work in a single batch, instead of flushing each - // level one at a time. - if (firstScheduledRoot !== null) { - recomputeCurrentRendererTime(); - let root: FiberRoot = firstScheduledRoot; - do { - didExpireAtExpirationTime(root, currentRendererTime); - // The root schedule is circular, so this is never null. - root = (root.nextScheduledRoot: any); - } while (root !== firstScheduledRoot); - } - } - - // Keep working on roots until there's no more work, or until there's a higher - // priority event. - findHighestPriorityRoot(); - - if (disableYielding) { - // Just do it all - while (nextFlushedRoot !== null && nextFlushedExpirationTime !== NoWork) { - performWorkOnRoot(nextFlushedRoot, nextFlushedExpirationTime, false); - findHighestPriorityRoot(); - } - } else { - recomputeCurrentRendererTime(); - currentSchedulerTime = currentRendererTime; - - if (enableUserTimingAPI) { - const didExpire = nextFlushedExpirationTime > currentRendererTime; - const timeout = expirationTimeToMs(nextFlushedExpirationTime); - stopRequestCallbackTimer(didExpire, timeout); - } - - while ( - nextFlushedRoot !== null && - nextFlushedExpirationTime !== NoWork && - !(shouldYield() && currentRendererTime > nextFlushedExpirationTime) - ) { - performWorkOnRoot( - nextFlushedRoot, - nextFlushedExpirationTime, - currentRendererTime > nextFlushedExpirationTime, - ); - findHighestPriorityRoot(); - recomputeCurrentRendererTime(); - currentSchedulerTime = currentRendererTime; - } - } - - // We're done flushing work. Either we ran out of time in this callback, - // or there's no more work left with sufficient priority. - - // If we're inside a callback, set this to false since we just completed it. - callbackExpirationTime = NoWork; - callbackID = null; - - // If there's work left over, schedule a new callback. - if (nextFlushedExpirationTime !== NoWork) { - scheduleCallbackWithExpirationTime( - ((nextFlushedRoot: any): FiberRoot), - nextFlushedExpirationTime, - ); - } - - // Clean-up. - finishRendering(); -} - -function performSyncWork() { - performWork(Sync); -} - -function performWork(minExpirationTime: ExpirationTime) { - // Keep working on roots until there's no more work, or until there's a higher - // priority event. - findHighestPriorityRoot(); - - while ( - nextFlushedRoot !== null && - nextFlushedExpirationTime !== NoWork && - minExpirationTime <= nextFlushedExpirationTime - ) { - performWorkOnRoot(nextFlushedRoot, nextFlushedExpirationTime, false); - findHighestPriorityRoot(); - } - - // We're done flushing work. Either we ran out of time in this callback, - // or there's no more work left with sufficient priority. - - // If there's work left over, schedule a new callback. - if (nextFlushedExpirationTime !== NoWork) { - scheduleCallbackWithExpirationTime( - ((nextFlushedRoot: any): FiberRoot), - nextFlushedExpirationTime, - ); - } - - // Clean-up. - finishRendering(); -} - -function flushRoot(root: FiberRoot, expirationTime: ExpirationTime) { - invariant( - !isRendering, - 'work.commit(): Cannot commit while already rendering. This likely ' + - 'means you attempted to commit from inside a lifecycle method.', - ); - // Perform work on root as if the given expiration time is the current time. - // This has the effect of synchronously flushing all work up to and - // including the given time. - nextFlushedRoot = root; - nextFlushedExpirationTime = expirationTime; - performWorkOnRoot(root, expirationTime, false); - // Flush any sync work that was scheduled by lifecycles - performSyncWork(); -} - -function finishRendering() { - nestedUpdateCount = 0; - lastCommittedRootDuringThisBatch = null; - - if (__DEV__) { - if (rootWithPendingPassiveEffects === null) { - nestedPassiveEffectCountDEV = 0; - } - } - - if (completedBatches !== null) { - const batches = completedBatches; - completedBatches = null; - for (let i = 0; i < batches.length; i++) { - const batch = batches[i]; - try { - batch._onComplete(); - } catch (error) { - if (!hasUnhandledError) { - hasUnhandledError = true; - unhandledError = error; - } - } - } - } - - if (hasUnhandledError) { - const error = unhandledError; - unhandledError = null; - hasUnhandledError = false; - throw error; - } -} - -function performWorkOnRoot( - root: FiberRoot, - expirationTime: ExpirationTime, - isYieldy: boolean, -) { - invariant( - !isRendering, - 'performWorkOnRoot was called recursively. This error is likely caused ' + - 'by a bug in React. Please file an issue.', - ); - - isRendering = true; - - // Check if this is async work or sync/expired work. - if (!isYieldy) { - // Flush work without yielding. - // TODO: Non-yieldy work does not necessarily imply expired work. A renderer - // may want to perform some work without yielding, but also without - // requiring the root to complete (by triggering placeholders). - - let finishedWork = root.finishedWork; - if (finishedWork !== null) { - // This root is already complete. We can commit it. - completeRoot(root, finishedWork, expirationTime); - } else { - root.finishedWork = null; - // If this root previously suspended, clear its existing timeout, since - // we're about to try rendering again. - const timeoutHandle = root.timeoutHandle; - if (timeoutHandle !== noTimeout) { - root.timeoutHandle = noTimeout; - // $FlowFixMe Complains noTimeout is not a TimeoutID, despite the check above - cancelTimeout(timeoutHandle); - } - renderRoot(root, isYieldy); - finishedWork = root.finishedWork; - if (finishedWork !== null) { - // We've completed the root. Commit it. - completeRoot(root, finishedWork, expirationTime); - } - } - } else { - // Flush async work. - let finishedWork = root.finishedWork; - if (finishedWork !== null) { - // This root is already complete. We can commit it. - completeRoot(root, finishedWork, expirationTime); - } else { - root.finishedWork = null; - // If this root previously suspended, clear its existing timeout, since - // we're about to try rendering again. - const timeoutHandle = root.timeoutHandle; - if (timeoutHandle !== noTimeout) { - root.timeoutHandle = noTimeout; - // $FlowFixMe Complains noTimeout is not a TimeoutID, despite the check above - cancelTimeout(timeoutHandle); - } - renderRoot(root, isYieldy); - finishedWork = root.finishedWork; - if (finishedWork !== null) { - // We've completed the root. Check the if we should yield one more time - // before committing. - if (!shouldYield()) { - // Still time left. Commit the root. - completeRoot(root, finishedWork, expirationTime); - } else { - // There's no time left. Mark this root as complete. We'll come - // back and commit it later. - root.finishedWork = finishedWork; - } - } - } - } - - isRendering = false; -} - -function completeRoot( - root: FiberRoot, - finishedWork: Fiber, - expirationTime: ExpirationTime, -): void { - // Check if there's a batch that matches this expiration time. - const firstBatch = root.firstBatch; - if (firstBatch !== null && firstBatch._expirationTime >= expirationTime) { - if (completedBatches === null) { - completedBatches = [firstBatch]; - } else { - completedBatches.push(firstBatch); - } - if (firstBatch._defer) { - // This root is blocked from committing by a batch. Unschedule it until - // we receive another update. - root.finishedWork = finishedWork; - root.expirationTime = NoWork; - return; - } - } - - // Commit the root. - root.finishedWork = null; - - // Check if this is a nested update (a sync update scheduled during the - // commit phase). - if (root === lastCommittedRootDuringThisBatch) { - // If the next root is the same as the previous root, this is a nested - // update. To prevent an infinite loop, increment the nested update count. - nestedUpdateCount++; - } else { - // Reset whenever we switch roots. - lastCommittedRootDuringThisBatch = root; - nestedUpdateCount = 0; - } - commitRoot(root, finishedWork); -} - -function onUncaughtError(error: mixed) { - invariant( - nextFlushedRoot !== null, - 'Should be working on a root. This error is likely caused by a bug in ' + - 'React. Please file an issue.', - ); - // Unschedule this root so we don't work on it again until there's - // another update. - nextFlushedRoot.expirationTime = NoWork; - if (!hasUnhandledError) { - hasUnhandledError = true; - unhandledError = error; - } -} - -// TODO: Batching should be implemented at the renderer level, not inside -// the reconciler. -function batchedUpdates(fn: (a: A) => R, a: A): R { - const previousIsBatchingUpdates = isBatchingUpdates; - isBatchingUpdates = true; - try { - return fn(a); - } finally { - isBatchingUpdates = previousIsBatchingUpdates; - if (!isBatchingUpdates && !isRendering) { - performSyncWork(); - } - } -} - -// TODO: Batching should be implemented at the renderer level, not inside -// the reconciler. -function unbatchedUpdates(fn: (a: A) => R, a: A): R { - if (isBatchingUpdates && !isUnbatchingUpdates) { - isUnbatchingUpdates = true; - try { - return fn(a); - } finally { - isUnbatchingUpdates = false; - } - } - return fn(a); -} - -// TODO: Batching should be implemented at the renderer level, not within -// the reconciler. -function flushSync(fn: (a: A) => R, a: A): R { - invariant( - !isRendering, - 'flushSync was called from inside a lifecycle method. It cannot be ' + - 'called when React is already rendering.', - ); - const previousIsBatchingUpdates = isBatchingUpdates; - isBatchingUpdates = true; - try { - return syncUpdates(fn, a); - } finally { - isBatchingUpdates = previousIsBatchingUpdates; - performSyncWork(); - } -} - -function interactiveUpdates( - fn: (A, B, C) => R, - a: A, - b: B, - c: C, -): R { - if (isBatchingInteractiveUpdates) { - return fn(a, b, c); - } - // If there are any pending interactive updates, synchronously flush them. - // This needs to happen before we read any handlers, because the effect of - // the previous event may influence which handlers are called during - // this event. - if ( - !isBatchingUpdates && - !isRendering && - lowestPriorityPendingInteractiveExpirationTime !== NoWork - ) { - // Synchronously flush pending interactive updates. - performWork(lowestPriorityPendingInteractiveExpirationTime); - lowestPriorityPendingInteractiveExpirationTime = NoWork; - } - const previousIsBatchingInteractiveUpdates = isBatchingInteractiveUpdates; - const previousIsBatchingUpdates = isBatchingUpdates; - isBatchingInteractiveUpdates = true; - isBatchingUpdates = true; - try { - return fn(a, b, c); - } finally { - isBatchingInteractiveUpdates = previousIsBatchingInteractiveUpdates; - isBatchingUpdates = previousIsBatchingUpdates; - if (!isBatchingUpdates && !isRendering) { - performSyncWork(); - } - } -} - -function flushInteractiveUpdates() { - if ( - !isRendering && - lowestPriorityPendingInteractiveExpirationTime !== NoWork - ) { - // Synchronously flush pending interactive updates. - performWork(lowestPriorityPendingInteractiveExpirationTime); - lowestPriorityPendingInteractiveExpirationTime = NoWork; - } -} - -function flushControlled(fn: () => mixed): void { - const previousIsBatchingUpdates = isBatchingUpdates; - isBatchingUpdates = true; - try { - syncUpdates(fn); - } finally { - isBatchingUpdates = previousIsBatchingUpdates; - if (!isBatchingUpdates && !isRendering) { - performSyncWork(); - } - } -} - -export { - requestCurrentTime, - computeExpirationForFiber, - captureCommitPhaseError, - onUncaughtError, - renderDidSuspend, - renderDidError, - pingSuspendedRoot, - retryTimedOutBoundary, - resolveRetryThenable, - markLegacyErrorBoundaryAsFailed, - isAlreadyFailedLegacyErrorBoundary, - scheduleWork, - flushRoot, - batchedUpdates, - unbatchedUpdates, - flushSync, - flushControlled, - deferredUpdates, - syncUpdates, - interactiveUpdates, - flushInteractiveUpdates, - computeUniqueAsyncExpiration, - flushPassiveEffects, - inferStartTimeFromExpirationTime, -}; diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js index 33c19e5ac1e5a..c3d9cfecb7950 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js @@ -11,7 +11,7 @@ import type {Fiber} from './ReactFiber'; import type {ExpirationTime} from './ReactFiberExpirationTime'; export type SuspenseState = {| - timedOutAt: ExpirationTime, + fallbackExpirationTime: ExpirationTime, |}; export function shouldCaptureSuspense(workInProgress: Fiber): boolean { diff --git a/packages/react-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js index f2c3e37b699ec..734dead68cf6f 100644 --- a/packages/react-reconciler/src/ReactFiberTreeReflection.js +++ b/packages/react-reconciler/src/ReactFiberTreeReflection.js @@ -113,15 +113,28 @@ export function findCurrentFiberUsingSlowPath(fiber: Fiber): Fiber | null { // If we have two possible branches, we'll walk backwards up to the root // to see what path the root points to. On the way we may hit one of the // special cases and we'll deal with them. - let a = fiber; - let b = alternate; + let a: Fiber = fiber; + let b: Fiber = alternate; while (true) { let parentA = a.return; - let parentB = parentA ? parentA.alternate : null; - if (!parentA || !parentB) { + if (parentA === null) { // We're at the root. break; } + let parentB = parentA.alternate; + if (parentB === null) { + // There is no alternate. This is an unusual case. Currently, it only + // happens when a Suspense component is hidden. An extra fragment fiber + // is inserted in between the Suspense fiber and its children. Skip + // over this extra fragment fiber and proceed to the next parent. + const nextParent = parentA.return; + if (nextParent !== null) { + a = b = nextParent; + continue; + } + // If there's no parent, we're at the root. + break; + } // If both copies of the parent fiber point to the same child, we can // assume that the child is current. This happens when we bailout on low diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.js b/packages/react-reconciler/src/ReactFiberUnwindWork.js index 4f5340857552a..c6c8276733037 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.js @@ -13,7 +13,6 @@ import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {CapturedValue} from './ReactCapturedValue'; import type {Update} from './ReactUpdateQueue'; import type {Thenable} from './ReactFiberScheduler'; -import type {SuspenseState} from './ReactFiberSuspenseComponent'; import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing'; import getComponentName from 'shared/getComponentName'; @@ -42,7 +41,7 @@ import { enableSuspenseServerRenderer, enableEventAPI, } from 'shared/ReactFeatureFlags'; -import {ConcurrentMode} from './ReactTypeOfMode'; +import {ConcurrentMode, NoContext} from './ReactTypeOfMode'; import {shouldCaptureSuspense} from './ReactFiberSuspenseComponent'; import {createCapturedValue} from './ReactCapturedValue'; @@ -63,19 +62,17 @@ import { } from './ReactFiberContext'; import {popProvider} from './ReactFiberNewContext'; import { - renderDidSuspend, renderDidError, onUncaughtError, markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, pingSuspendedRoot, resolveRetryThenable, - inferStartTimeFromExpirationTime, } from './ReactFiberScheduler'; import invariant from 'shared/invariant'; -import maxSigned31BitInt from './maxSigned31BitInt'; -import {Sync, expirationTimeToMs} from './ReactFiberExpirationTime'; + +import {Sync} from './ReactFiberExpirationTime'; const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; @@ -206,44 +203,8 @@ function throwException( // This is a thenable. const thenable: Thenable = (value: any); - // Find the earliest timeout threshold of all the placeholders in the - // ancestor path. We could avoid this traversal by storing the thresholds on - // the stack, but we choose not to because we only hit this path if we're - // IO-bound (i.e. if something suspends). Whereas the stack is used even in - // the non-IO- bound case. - let workInProgress = returnFiber; - let earliestTimeoutMs = -1; - let startTimeMs = -1; - do { - if (workInProgress.tag === SuspenseComponent) { - const current = workInProgress.alternate; - if (current !== null) { - const currentState: SuspenseState | null = current.memoizedState; - if (currentState !== null) { - // Reached a boundary that already timed out. Do not search - // any further. - const timedOutAt = currentState.timedOutAt; - startTimeMs = expirationTimeToMs(timedOutAt); - // Do not search any further. - break; - } - } - const defaultSuspenseTimeout = 150; - if ( - earliestTimeoutMs === -1 || - defaultSuspenseTimeout < earliestTimeoutMs - ) { - earliestTimeoutMs = defaultSuspenseTimeout; - } - } - // If there is a DehydratedSuspenseComponent we don't have to do anything because - // if something suspends inside it, we will simply leave that as dehydrated. It - // will never timeout. - workInProgress = workInProgress.return; - } while (workInProgress !== null); - // Schedule the nearest Suspense to re-render the timed out view. - workInProgress = returnFiber; + let workInProgress = returnFiber; do { if ( workInProgress.tag === SuspenseComponent && @@ -270,7 +231,7 @@ function throwException( // Note: It doesn't matter whether the component that suspended was // inside a concurrent mode tree. If the Suspense is outside of it, we // should *not* suspend the commit. - if ((workInProgress.mode & ConcurrentMode) === NoEffect) { + if ((workInProgress.mode & ConcurrentMode) === NoContext) { workInProgress.effectTag |= DidCapture; // We're going to commit this fiber even though it didn't complete. @@ -308,32 +269,6 @@ function throwException( attachPingListener(root, renderExpirationTime, thenable); - let absoluteTimeoutMs; - if (earliestTimeoutMs === -1) { - // If no explicit threshold is given, default to an arbitrarily large - // value. The actual size doesn't matter because the threshold for the - // whole tree will be clamped to the expiration time. - absoluteTimeoutMs = maxSigned31BitInt; - } else { - if (startTimeMs === -1) { - // This suspend happened outside of any already timed-out - // placeholders. We don't know exactly when the update was - // scheduled, but we can infer an approximate start time based on - // the expiration time and the priority. - startTimeMs = inferStartTimeFromExpirationTime( - root, - renderExpirationTime, - ); - } - absoluteTimeoutMs = startTimeMs + earliestTimeoutMs; - } - - // Mark the earliest timeout in the suspended fiber's ancestor path. - // After completing the root, we'll take the largest of all the - // suspended fiber's timeouts and use it to compute a timeout for the - // whole tree. - renderDidSuspend(root, absoluteTimeoutMs, renderExpirationTime); - workInProgress.effectTag |= ShouldCapture; workInProgress.expirationTime = renderExpirationTime; return; diff --git a/packages/react-reconciler/src/ReactUpdateQueue.js b/packages/react-reconciler/src/ReactUpdateQueue.js index 64fc6a8a5840c..aecbc4f678823 100644 --- a/packages/react-reconciler/src/ReactUpdateQueue.js +++ b/packages/react-reconciler/src/ReactUpdateQueue.js @@ -101,6 +101,7 @@ import { } from 'shared/ReactFeatureFlags'; import {StrictMode} from './ReactTypeOfMode'; +import {markRenderEventTime} from './ReactFiberScheduler'; import invariant from 'shared/invariant'; import warningWithoutStack from 'shared/warningWithoutStack'; @@ -454,8 +455,17 @@ export function processUpdateQueue( newExpirationTime = updateExpirationTime; } } else { - // This update does have sufficient priority. Process it and compute - // a new result. + // This update does have sufficient priority. + + // Mark the event time of this update as relevant to this render pass. + // TODO: This should ideally use the true event time of this update rather than + // its priority which is a derived and not reverseable value. + // TODO: We should skip this update if it was already committed but currently + // we have no way of detecting the difference between a committed and suspended + // update here. + markRenderEventTime(updateExpirationTime); + + // Process it and compute a new result. resultState = getStateFromUpdate( workInProgress, queue, diff --git a/packages/react-reconciler/src/SchedulerWithReactIntegration.js b/packages/react-reconciler/src/SchedulerWithReactIntegration.js index 323ab5528fdbf..3a30a2ea2e48a 100644 --- a/packages/react-reconciler/src/SchedulerWithReactIntegration.js +++ b/packages/react-reconciler/src/SchedulerWithReactIntegration.js @@ -10,8 +10,11 @@ // Intentionally not named imports because Rollup would use dynamic dispatch for // CommonJS interop named imports. import * as Scheduler from 'scheduler'; - -import {disableYielding} from 'shared/ReactFeatureFlags'; +import {__interactionsRef} from 'scheduler/tracing'; +import { + disableYielding, + enableSchedulerTracing, +} from 'shared/ReactFeatureFlags'; import invariant from 'shared/invariant'; const { @@ -28,6 +31,20 @@ const { unstable_IdlePriority: Scheduler_IdlePriority, } = Scheduler; +if (enableSchedulerTracing) { + // Provide explicit error message when production+profiling bundle of e.g. + // react-dom is used with production (non-profiling) bundle of + // scheduler/tracing + invariant( + __interactionsRef != null && __interactionsRef.current != null, + 'It is not supported to run the profiling version of a renderer (for ' + + 'example, `react-dom/profiling`) without also replacing the ' + + '`scheduler/tracing` module with `scheduler/tracing-profiling`. Your ' + + 'bundler might have a setting for aliasing both modules. Learn more at ' + + 'http://fb.me/react-profiling', + ); +} + export opaque type ReactPriorityLevel = 99 | 98 | 97 | 96 | 95 | 90; export type SchedulerCallback = (isSync: boolean) => SchedulerCallback | null; diff --git a/packages/react-reconciler/src/__tests__/ReactExpiration-test.internal.js b/packages/react-reconciler/src/__tests__/ReactExpiration-test.internal.js index 8001a8d2c96c6..9a9402ecb90d2 100644 --- a/packages/react-reconciler/src/__tests__/ReactExpiration-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactExpiration-test.internal.js @@ -245,4 +245,31 @@ describe('ReactExpiration', () => { '1 [D] [render]', ]); }); + + it('should measure expiration times relative to module initialization', () => { + // Tests an implementation detail where expiration times are computed using + // bitwise operations. + + jest.resetModules(); + Scheduler = require('scheduler'); + // Before importing the renderer, advance the current time by a number + // larger than the maximum allowed for bitwise operations. + const maxSigned31BitInt = 1073741823; + Scheduler.advanceTime(maxSigned31BitInt * 100); + + // Now import the renderer. On module initialization, it will read the + // current time. + ReactNoop = require('react-noop-renderer'); + + ReactNoop.render('Hi'); + + // The update should not have expired yet. + expect(Scheduler).toFlushExpired([]); + expect(ReactNoop).toMatchRenderedOutput(null); + + // Advance the time some more to expire the update. + Scheduler.advanceTime(10000); + expect(Scheduler).toFlushExpired([]); + expect(ReactNoop).toMatchRenderedOutput('Hi'); + }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactFiberEvents-test-internal.js b/packages/react-reconciler/src/__tests__/ReactFiberEvents-test-internal.js index 73dcc8d4a01e5..5516f783731a7 100644 --- a/packages/react-reconciler/src/__tests__/ReactFiberEvents-test-internal.js +++ b/packages/react-reconciler/src/__tests__/ReactFiberEvents-test-internal.js @@ -127,9 +127,9 @@ describe('ReactFiberEvents', () => { it('should render a simple event component with a single event target', () => { const Test = () => ( - -
Hello world
-
+
+ Hello world +
); @@ -148,10 +148,7 @@ describe('ReactFiberEvents', () => { expect(() => { ReactNoop.render(); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: React event targets cannot have text DOM nodes as children. ' + - 'Wrap the child text "Hello world" in an element.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); }); it('should warn when an event target has a direct text child #2', () => { @@ -167,19 +164,15 @@ describe('ReactFiberEvents', () => { expect(() => { ReactNoop.render(); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: React event targets cannot have text DOM nodes as children. ' + - 'Wrap the child text "Hello world" in an element.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); }); it('should not warn if an event target is not a direct child of an event component', () => { const Test = () => (
- - Child 1 - + + Child 1
); @@ -207,9 +200,7 @@ describe('ReactFiberEvents', () => { expect(() => { ReactNoop.render(); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: React event targets must not have event components as children.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); }); it('should handle event components correctly with error boundaries', () => { @@ -219,11 +210,9 @@ describe('ReactFiberEvents', () => { const Test = () => ( - - - - - + + + ); @@ -268,11 +257,9 @@ describe('ReactFiberEvents', () => { const Parent = () => ( - -
- -
-
+
+ +
); @@ -321,9 +308,7 @@ describe('ReactFiberEvents', () => { const Parent = () => ( - - - + ); @@ -341,7 +326,7 @@ describe('ReactFiberEvents', () => { }); expect(Scheduler).toFlushWithoutYielding(); }).toWarnDev( - 'Warning: validateDOMNesting: React event targets cannot have text DOM nodes as children. ' + + 'Warning: validateDOMNesting: React event components cannot have text DOM nodes as children. ' + 'Wrap the child text "Text!" in an element.', ); }); @@ -355,11 +340,7 @@ describe('ReactFiberEvents', () => { _updateCounter = updateCounter; if (counter === 1) { - return ( - -
Child
-
- ); + return 123; } return ( @@ -370,18 +351,20 @@ describe('ReactFiberEvents', () => { } const Parent = () => ( - - +
+ - - + +
); ReactNoop.render(); expect(Scheduler).toFlushWithoutYielding(); expect(ReactNoop).toMatchRenderedOutput(
- Child - 0 +
+ Child - 0 +
, ); @@ -390,9 +373,7 @@ describe('ReactFiberEvents', () => { _updateCounter(counter => counter + 1); }); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: React event targets must not have event components as children.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); }); it('should error with a component stack contains the names of the event components and event targets', () => { @@ -404,11 +385,9 @@ describe('ReactFiberEvents', () => { const Test = () => ( - - - - - + + + ); @@ -437,7 +416,6 @@ describe('ReactFiberEvents', () => { expect(componentStackMessage.includes('ErrorComponent')).toBe(true); expect(componentStackMessage.includes('span')).toBe(true); - expect(componentStackMessage.includes('TestEventTarget')).toBe(true); expect(componentStackMessage.includes('TestEventComponent')).toBe(true); expect(componentStackMessage.includes('Test')).toBe(true); expect(componentStackMessage.includes('Wrapper')).toBe(true); @@ -498,9 +476,9 @@ describe('ReactFiberEvents', () => { it('should render a simple event component with a single event target', () => { const Test = () => ( - -
Hello world
-
+
+ Hello world +
); @@ -511,9 +489,8 @@ describe('ReactFiberEvents', () => { const Test2 = () => ( - - I am now a span - + + I am now a span ); @@ -533,10 +510,7 @@ describe('ReactFiberEvents', () => { expect(() => { root.update(); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: React event targets cannot have text DOM nodes as children. ' + - 'Wrap the child text "Hello world" in an element.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); }); it('should warn when an event target has a direct text child #2', () => { @@ -553,19 +527,15 @@ describe('ReactFiberEvents', () => { expect(() => { root.update(); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: React event targets cannot have text DOM nodes as children. ' + - 'Wrap the child text "Hello world" in an element.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); }); it('should not warn if an event target is not a direct child of an event component', () => { const Test = () => (
- - Child 1 - + + Child 1
); @@ -595,9 +565,7 @@ describe('ReactFiberEvents', () => { expect(() => { root.update(); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: React event targets must not have event components as children.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); }); it('should handle event components correctly with error boundaries', () => { @@ -607,11 +575,9 @@ describe('ReactFiberEvents', () => { const Test = () => ( - - - - - + + + ); @@ -620,7 +586,7 @@ describe('ReactFiberEvents', () => { error: null, }; - componentDidCatch(error, errStack) { + componentDidCatch(error) { this.setState({ error, }); @@ -657,11 +623,9 @@ describe('ReactFiberEvents', () => { const Parent = () => ( - -
- -
-
+
+ +
); @@ -710,9 +674,7 @@ describe('ReactFiberEvents', () => { const Parent = () => ( - - - + ); @@ -730,7 +692,7 @@ describe('ReactFiberEvents', () => { _updateCounter(counter => counter + 1); }); }).toWarnDev( - 'Warning: validateDOMNesting: React event targets cannot have text DOM nodes as children. ' + + 'Warning: validateDOMNesting: React event components cannot have text DOM nodes as children. ' + 'Wrap the child text "Text!" in an element.', ); }); @@ -744,11 +706,7 @@ describe('ReactFiberEvents', () => { _updateCounter = updateCounter; if (counter === 1) { - return ( - -
Child
-
- ); + return 123; } return ( @@ -759,11 +717,11 @@ describe('ReactFiberEvents', () => { } const Parent = () => ( - - +
+ - - + +
); const root = ReactTestRenderer.create(null); @@ -771,7 +729,9 @@ describe('ReactFiberEvents', () => { expect(Scheduler).toFlushWithoutYielding(); expect(root).toMatchRenderedOutput(
- Child - 0 +
+ Child - 0 +
, ); @@ -780,9 +740,7 @@ describe('ReactFiberEvents', () => { _updateCounter(counter => counter + 1); }); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: React event targets must not have event components as children.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); }); it('should error with a component stack contains the names of the event components and event targets', () => { @@ -794,11 +752,9 @@ describe('ReactFiberEvents', () => { const Test = () => ( - - - - - + + + ); @@ -828,7 +784,6 @@ describe('ReactFiberEvents', () => { expect(componentStackMessage.includes('ErrorComponent')).toBe(true); expect(componentStackMessage.includes('span')).toBe(true); - expect(componentStackMessage.includes('TestEventTarget')).toBe(true); expect(componentStackMessage.includes('TestEventComponent')).toBe(true); expect(componentStackMessage.includes('Test')).toBe(true); expect(componentStackMessage.includes('Wrapper')).toBe(true); @@ -888,9 +843,9 @@ describe('ReactFiberEvents', () => { it('should render a simple event component with a single event target', () => { const Test = () => ( - -
Hello world
-
+
+ Hello world +
); @@ -901,9 +856,8 @@ describe('ReactFiberEvents', () => { const Test2 = () => ( - - I am now a span - + + I am now a span ); @@ -923,10 +877,7 @@ describe('ReactFiberEvents', () => { const container = document.createElement('div'); ReactDOM.render(, container); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: React event targets cannot have text DOM nodes as children. ' + - 'Wrap the child text "Hello world" in an element.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); }); it('should warn when an event target has a direct text child #2', () => { @@ -943,19 +894,15 @@ describe('ReactFiberEvents', () => { const container = document.createElement('div'); ReactDOM.render(, container); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: React event targets cannot have text DOM nodes as children. ' + - 'Wrap the child text "Hello world" in an element.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); }); it('should not warn if an event target is not a direct child of an event component', () => { const Test = () => (
- - Child 1 - + + Child 1
); @@ -981,9 +928,7 @@ describe('ReactFiberEvents', () => { const container = document.createElement('div'); ReactDOM.render(, container); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: React event targets must not have event components as children.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); }); it('should handle event components correctly with error boundaries', () => { @@ -993,11 +938,9 @@ describe('ReactFiberEvents', () => { const Test = () => ( - - - - - + + + ); @@ -1043,11 +986,9 @@ describe('ReactFiberEvents', () => { const Parent = () => ( - -
- -
-
+
+ +
); @@ -1087,9 +1028,7 @@ describe('ReactFiberEvents', () => { const Parent = () => ( - - - + ); @@ -1103,7 +1042,7 @@ describe('ReactFiberEvents', () => { }); expect(Scheduler).toFlushWithoutYielding(); }).toWarnDev( - 'Warning: validateDOMNesting: React event targets cannot have text DOM nodes as children. ' + + 'Warning: validateDOMNesting: React event components cannot have text DOM nodes as children. ' + 'Wrap the child text "Text!" in an element.', ); }); @@ -1117,11 +1056,7 @@ describe('ReactFiberEvents', () => { _updateCounter = updateCounter; if (counter === 1) { - return ( - -
Child
-
- ); + return 123; } return ( @@ -1132,25 +1067,25 @@ describe('ReactFiberEvents', () => { } const Parent = () => ( - - +
+ - - + +
); const container = document.createElement('div'); ReactDOM.render(, container); - expect(container.innerHTML).toBe('
Child - 0
'); + expect(container.innerHTML).toBe( + '
Child - 0
', + ); expect(() => { ReactTestUtils.act(() => { _updateCounter(counter => counter + 1); }); expect(Scheduler).toFlushWithoutYielding(); - }).toWarnDev( - 'Warning: validateDOMNesting: React event targets must not have event components as children.', - ); + }).toWarnDev('Warning: Event targets should not have children.'); }); it('should error with a component stack contains the names of the event components and event targets', () => { @@ -1162,11 +1097,9 @@ describe('ReactFiberEvents', () => { const Test = () => ( - - - - - + + + ); @@ -1195,7 +1128,6 @@ describe('ReactFiberEvents', () => { expect(componentStackMessage.includes('ErrorComponent')).toBe(true); expect(componentStackMessage.includes('span')).toBe(true); - expect(componentStackMessage.includes('TestEventTarget')).toBe(true); expect(componentStackMessage.includes('TestEventComponent')).toBe(true); expect(componentStackMessage.includes('Test')).toBe(true); expect(componentStackMessage.includes('Wrapper')).toBe(true); @@ -1222,9 +1154,9 @@ describe('ReactFiberEvents', () => { it('should render a simple event component with a single event target', () => { const Test = () => ( - -
Hello world
-
+
+ Hello world +
); diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js index 1de8540a6eeb5..e2e5178ba37f5 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js @@ -608,8 +608,8 @@ describe('ReactHooks', () => { 'Warning: The final argument passed to useLayoutEffect changed size ' + 'between renders. The order and size of this array must remain ' + 'constant.\n\n' + - 'Previous: [A, B]\n' + - 'Incoming: [A]\n', + 'Previous: [A]\n' + + 'Incoming: [A, B]\n', ]); }); diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js index 4d6ddee647149..6fe7fc093663c 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js @@ -15,7 +15,6 @@ let ReactFeatureFlags; let React; let ReactNoop; let Scheduler; -let enableNewScheduler; describe('ReactIncrementalErrorHandling', () => { beforeEach(() => { @@ -23,7 +22,6 @@ describe('ReactIncrementalErrorHandling', () => { ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false; - enableNewScheduler = ReactFeatureFlags.enableNewScheduler; PropTypes = require('prop-types'); React = require('react'); ReactNoop = require('react-noop-renderer'); @@ -1034,23 +1032,15 @@ describe('ReactIncrementalErrorHandling', () => { ReactNoop.renderToRootWithID(, 'e'); ReactNoop.renderToRootWithID(, 'f'); - if (enableNewScheduler) { - // The new scheduler will throw all three errors. - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toThrow('a'); - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toThrow('c'); - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toThrow('e'); - } else { - // The old scheduler only throws the first one. - expect(() => { - expect(Scheduler).toFlushWithoutYielding(); - }).toThrow('a'); - } + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toThrow('a'); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toThrow('c'); + expect(() => { + expect(Scheduler).toFlushWithoutYielding(); + }).toThrow('e'); expect(Scheduler).toFlushWithoutYielding(); expect(ReactNoop.getChildren('a')).toEqual([]); diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalPerf-test.internal.js b/packages/react-reconciler/src/__tests__/ReactIncrementalPerf-test.internal.js index 1c6bf0f6b733c..2080cbc17a23b 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalPerf-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalPerf-test.internal.js @@ -118,568 +118,554 @@ describe('ReactDebugFiberPerf', () => { return
{props.children}
; } - describe('old scheduler', () => { - runTests(false); + beforeEach(() => { + jest.resetModules(); + resetFlamechart(); + global.performance = createUserTimingPolyfill(); + + require('shared/ReactFeatureFlags').enableUserTimingAPI = true; + require('shared/ReactFeatureFlags').enableProfilerTimer = false; + require('shared/ReactFeatureFlags').replayFailedUnitOfWorkWithInvokeGuardedCallback = false; + require('shared/ReactFeatureFlags').debugRenderPhaseSideEffectsForStrictMode = false; + + // Import after the polyfill is set up: + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + PropTypes = require('prop-types'); }); - describe('new scheduler', () => { - runTests(true); + afterEach(() => { + delete global.performance; }); - function runTests(enableNewScheduler) { - beforeEach(() => { - jest.resetModules(); - resetFlamechart(); - global.performance = createUserTimingPolyfill(); - - require('shared/ReactFeatureFlags').enableNewScheduler = enableNewScheduler; - require('shared/ReactFeatureFlags').enableUserTimingAPI = true; - require('shared/ReactFeatureFlags').enableProfilerTimer = false; - require('shared/ReactFeatureFlags').replayFailedUnitOfWorkWithInvokeGuardedCallback = false; - require('shared/ReactFeatureFlags').debugRenderPhaseSideEffectsForStrictMode = false; - - // Import after the polyfill is set up: - React = require('react'); - ReactNoop = require('react-noop-renderer'); - Scheduler = require('scheduler'); - PropTypes = require('prop-types'); - }); + it('measures a simple reconciliation', () => { + ReactNoop.render( + + + , + ); + addComment('Mount'); + expect(Scheduler).toFlushWithoutYielding(); + + ReactNoop.render( + + + , + ); + addComment('Update'); + expect(Scheduler).toFlushWithoutYielding(); + + ReactNoop.render(null); + addComment('Unmount'); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getFlameChart()).toMatchSnapshot(); + }); - afterEach(() => { - delete global.performance; + it('properly displays the forwardRef component in measurements', () => { + const AnonymousForwardRef = React.forwardRef((props, ref) => ( + + )); + const NamedForwardRef = React.forwardRef(function refForwarder(props, ref) { + return ; }); + function notImportant(props, ref) { + return ; + } + notImportant.displayName = 'OverriddenName'; + const DisplayNamedForwardRef = React.forwardRef(notImportant); + + ReactNoop.render( + + + + + , + ); + addComment('Mount'); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getFlameChart()).toMatchSnapshot(); + }); - it('measures a simple reconciliation', () => { - ReactNoop.render( - - - , - ); - addComment('Mount'); - expect(Scheduler).toFlushWithoutYielding(); - - ReactNoop.render( - - - , - ); - addComment('Update'); - expect(Scheduler).toFlushWithoutYielding(); - - ReactNoop.render(null); - addComment('Unmount'); - expect(Scheduler).toFlushWithoutYielding(); + it('does not include ConcurrentMode, StrictMode, or Profiler components in measurements', () => { + ReactNoop.render( + + + + + + + + + , + ); + addComment('Mount'); + expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); - }); + expect(getFlameChart()).toMatchSnapshot(); + }); - it('properly displays the forwardRef component in measurements', () => { - const AnonymousForwardRef = React.forwardRef((props, ref) => ( - - )); - const NamedForwardRef = React.forwardRef(function refForwarder( - props, - ref, - ) { - return ; - }); - function notImportant(props, ref) { - return ; - } - notImportant.displayName = 'OverriddenName'; - const DisplayNamedForwardRef = React.forwardRef(notImportant); + it('does not include context provider or consumer in measurements', () => { + const {Consumer, Provider} = React.createContext(true); - ReactNoop.render( + ReactNoop.render( + - - - - , - ); - addComment('Mount'); - expect(Scheduler).toFlushWithoutYielding(); - - expect(getFlameChart()).toMatchSnapshot(); - }); - - it('does not include ConcurrentMode, StrictMode, or Profiler components in measurements', () => { - ReactNoop.render( - - - - - - - - - , - ); - addComment('Mount'); - expect(Scheduler).toFlushWithoutYielding(); - - expect(getFlameChart()).toMatchSnapshot(); - }); - - it('does not include context provider or consumer in measurements', () => { - const {Consumer, Provider} = React.createContext(true); - - ReactNoop.render( - - - {value => } - - , - ); - addComment('Mount'); - expect(Scheduler).toFlushWithoutYielding(); - - expect(getFlameChart()).toMatchSnapshot(); - }); + {value => } +
+ , + ); + addComment('Mount'); + expect(Scheduler).toFlushWithoutYielding(); + + expect(getFlameChart()).toMatchSnapshot(); + }); - it('skips parents during setState', () => { - class A extends React.Component { - render() { - return
{this.props.children}
; - } + it('skips parents during setState', () => { + class A extends React.Component { + render() { + return
{this.props.children}
; } + } - class B extends React.Component { - render() { - return
{this.props.children}
; - } + class B extends React.Component { + render() { + return
{this.props.children}
; } + } - let a; - let b; - ReactNoop.render( + let a; + let b; + ReactNoop.render( + - -
(a = inst)} /> - - - - (b = inst)} /> + (a = inst)} /> - , - ); - expect(Scheduler).toFlushWithoutYielding(); - resetFlamechart(); - - a.setState({}); - b.setState({}); - addComment('Should include just A and B, no Parents'); - expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); - }); - - it('warns on cascading renders from setState', () => { - class Cascading extends React.Component { - componentDidMount() { - this.setState({}); - } - render() { - return
{this.props.children}
; - } - } - - ReactNoop.render( + - - , - ); - addComment('Should print a warning'); - expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); - }); + (b = inst)} /> + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + resetFlamechart(); + + a.setState({}); + b.setState({}); + addComment('Should include just A and B, no Parents'); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + }); - it('warns on cascading renders from top-level render', () => { - class Cascading extends React.Component { - componentDidMount() { - ReactNoop.renderToRootWithID(, 'b'); - addComment('Scheduling another root from componentDidMount'); - } - render() { - return
{this.props.children}
; - } + it('warns on cascading renders from setState', () => { + class Cascading extends React.Component { + componentDidMount() { + this.setState({}); } - - ReactNoop.renderToRootWithID(, 'a'); - addComment('Rendering the first root'); - expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); - }); - - it('does not treat setState from cWM or cWRP as cascading', () => { - class NotCascading extends React.Component { - UNSAFE_componentWillMount() { - this.setState({}); - } - UNSAFE_componentWillReceiveProps() { - this.setState({}); - } - render() { - return
{this.props.children}
; - } + render() { + return
{this.props.children}
; } + } + + ReactNoop.render( + + + , + ); + addComment('Should print a warning'); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + }); - ReactNoop.render( - - - , - ); - addComment('Should not print a warning'); - expect(() => expect(Scheduler).toFlushWithoutYielding()).toWarnDev( - [ - 'componentWillMount: Please update the following components ' + - 'to use componentDidMount instead: NotCascading' + - '\n\ncomponentWillReceiveProps: Please update the following components ' + - 'to use static getDerivedStateFromProps instead: NotCascading', - ], - {withoutStack: true}, - ); - ReactNoop.render( - - - , - ); - addComment('Should not print a warning'); - expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); - }); - - it('captures all lifecycles', () => { - class AllLifecycles extends React.Component { - static childContextTypes = { - foo: PropTypes.any, - }; - shouldComponentUpdate() { - return true; - } - getChildContext() { - return {foo: 42}; - } - UNSAFE_componentWillMount() {} - componentDidMount() {} - UNSAFE_componentWillReceiveProps() {} - UNSAFE_componentWillUpdate() {} - componentDidUpdate() {} - componentWillUnmount() {} - render() { - return
; - } + it('warns on cascading renders from top-level render', () => { + class Cascading extends React.Component { + componentDidMount() { + ReactNoop.renderToRootWithID(, 'b'); + addComment('Scheduling another root from componentDidMount'); } - ReactNoop.render(); - addComment('Mount'); - expect(() => expect(Scheduler).toFlushWithoutYielding()).toWarnDev( - [ - 'componentWillMount: Please update the following components ' + - 'to use componentDidMount instead: AllLifecycles' + - '\n\ncomponentWillReceiveProps: Please update the following components ' + - 'to use static getDerivedStateFromProps instead: AllLifecycles' + - '\n\ncomponentWillUpdate: Please update the following components ' + - 'to use componentDidUpdate instead: AllLifecycles', - 'Legacy context API has been detected within a strict-mode tree: \n\n' + - 'Please update the following components: AllLifecycles', - ], - {withoutStack: true}, - ); - ReactNoop.render(); - addComment('Update'); - expect(Scheduler).toFlushWithoutYielding(); - ReactNoop.render(null); - addComment('Unmount'); - expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); - }); + render() { + return
{this.props.children}
; + } + } - it('measures deprioritized work', () => { - addComment('Flush the parent'); - ReactNoop.flushSync(() => { - ReactNoop.render( - - - , - ); - }); - addComment('Flush the child'); - expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); - }); + ReactNoop.renderToRootWithID(, 'a'); + addComment('Rendering the first root'); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + }); - it('measures deferred work in chunks', () => { - class A extends React.Component { - render() { - Scheduler.yieldValue('A'); - return
{this.props.children}
; - } + it('does not treat setState from cWM or cWRP as cascading', () => { + class NotCascading extends React.Component { + UNSAFE_componentWillMount() { + this.setState({}); } - - class B extends React.Component { - render() { - Scheduler.yieldValue('B'); - return
{this.props.children}
; - } + UNSAFE_componentWillReceiveProps() { + this.setState({}); } + render() { + return
{this.props.children}
; + } + } + + ReactNoop.render( + + + , + ); + addComment('Should not print a warning'); + expect(() => expect(Scheduler).toFlushWithoutYielding()).toWarnDev( + [ + 'componentWillMount: Please update the following components ' + + 'to use componentDidMount instead: NotCascading' + + '\n\ncomponentWillReceiveProps: Please update the following components ' + + 'to use static getDerivedStateFromProps instead: NotCascading', + ], + {withoutStack: true}, + ); + ReactNoop.render( + + + , + ); + addComment('Should not print a warning'); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + }); - class C extends React.Component { - render() { - Scheduler.yieldValue('C'); - return
{this.props.children}
; - } + it('captures all lifecycles', () => { + class AllLifecycles extends React.Component { + static childContextTypes = { + foo: PropTypes.any, + }; + shouldComponentUpdate() { + return true; } + getChildContext() { + return {foo: 42}; + } + UNSAFE_componentWillMount() {} + componentDidMount() {} + UNSAFE_componentWillReceiveProps() {} + UNSAFE_componentWillUpdate() {} + componentDidUpdate() {} + componentWillUnmount() {} + render() { + return
; + } + } + ReactNoop.render(); + addComment('Mount'); + expect(() => expect(Scheduler).toFlushWithoutYielding()).toWarnDev( + [ + 'componentWillMount: Please update the following components ' + + 'to use componentDidMount instead: AllLifecycles' + + '\n\ncomponentWillReceiveProps: Please update the following components ' + + 'to use static getDerivedStateFromProps instead: AllLifecycles' + + '\n\ncomponentWillUpdate: Please update the following components ' + + 'to use componentDidUpdate instead: AllLifecycles', + 'Legacy context API has been detected within a strict-mode tree: \n\n' + + 'Please update the following components: AllLifecycles', + ], + {withoutStack: true}, + ); + ReactNoop.render(); + addComment('Update'); + expect(Scheduler).toFlushWithoutYielding(); + ReactNoop.render(null); + addComment('Unmount'); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + }); + it('measures deprioritized work', () => { + addComment('Flush the parent'); + ReactNoop.flushSync(() => { ReactNoop.render( - + , ); - addComment('Start rendering through B'); - expect(Scheduler).toFlushAndYieldThrough(['A', 'B']); - addComment('Complete the rest'); - expect(Scheduler).toFlushAndYield(['C']); - expect(getFlameChart()).toMatchSnapshot(); }); + addComment('Flush the child'); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + }); - it('recovers from fatal errors', () => { - function Baddie() { - throw new Error('Game over'); - } - - ReactNoop.render( - - - , - ); - try { - addComment('Will fatal'); - expect(Scheduler).toFlushWithoutYielding(); - } catch (err) { - expect(err.message).toBe('Game over'); + it('measures deferred work in chunks', () => { + class A extends React.Component { + render() { + Scheduler.yieldValue('A'); + return
{this.props.children}
; } - ReactNoop.render( - - - , - ); - addComment('Will reconcile from a clean state'); - expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); - }); + } - it('recovers from caught errors', () => { - function Baddie() { - throw new Error('Game over'); + class B extends React.Component { + render() { + Scheduler.yieldValue('B'); + return
{this.props.children}
; } + } - function ErrorReport() { - return
; + class C extends React.Component { + render() { + Scheduler.yieldValue('C'); + return
{this.props.children}
; } + } - class Boundary extends React.Component { - state = {error: null}; - componentDidCatch(error) { - this.setState({error}); - } - render() { - if (this.state.error) { - return ; - } - return this.props.children; - } - } + ReactNoop.render( + + + + + + + + + + + , + ); + addComment('Start rendering through B'); + expect(Scheduler).toFlushAndYieldThrough(['A', 'B']); + addComment('Complete the rest'); + expect(Scheduler).toFlushAndYield(['C']); + expect(getFlameChart()).toMatchSnapshot(); + }); - ReactNoop.render( - - - - - - - , - ); - addComment('Stop on Baddie and restart from Boundary'); + it('recovers from fatal errors', () => { + function Baddie() { + throw new Error('Game over'); + } + + ReactNoop.render( + + + , + ); + try { + addComment('Will fatal'); expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); - }); + } catch (err) { + expect(err.message).toBe('Game over'); + } + ReactNoop.render( + + + , + ); + addComment('Will reconcile from a clean state'); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + }); - it('deduplicates lifecycle names during commit to reduce overhead', () => { - class A extends React.Component { - componentDidUpdate() {} - render() { - return
; - } - } + it('recovers from caught errors', () => { + function Baddie() { + throw new Error('Game over'); + } - class B extends React.Component { - componentDidUpdate(prevProps) { - if (this.props.cascade && !prevProps.cascade) { - this.setState({}); - } - } - render() { - return
; + function ErrorReport() { + return
; + } + + class Boundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + render() { + if (this.state.error) { + return ; } + return this.props.children; } + } - ReactNoop.render( - - - - - - , - ); - expect(Scheduler).toFlushWithoutYielding(); - resetFlamechart(); - - ReactNoop.render( - - - - - - , - ); - addComment('The commit phase should mention A and B just once'); - expect(Scheduler).toFlushWithoutYielding(); - ReactNoop.render( - - - - - - , - ); - addComment("Because of deduplication, we don't know B was cascading,"); - addComment('but we should still see the warning for the commit phase.'); - expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); - }); - - it('supports portals', () => { - const portalContainer = ReactNoop.getOrCreateRootContainer( - 'portalContainer', - ); - ReactNoop.render( - - {ReactNoop.createPortal(, portalContainer, null)} - , - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); - }); + ReactNoop.render( + + + + + + + , + ); + addComment('Stop on Baddie and restart from Boundary'); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + }); - it('supports memo', () => { - const MemoFoo = React.memo(function Foo() { + it('deduplicates lifecycle names during commit to reduce overhead', () => { + class A extends React.Component { + componentDidUpdate() {} + render() { return
; - }); - ReactNoop.render( - - - , - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); - }); - - it('supports Suspense and lazy', async () => { - function Spinner() { - return ; } + } - function fakeImport(result) { - return {default: result}; + class B extends React.Component { + componentDidUpdate(prevProps) { + if (this.props.cascade && !prevProps.cascade) { + this.setState({}); + } + } + render() { + return
; } + } + + ReactNoop.render( + + + + + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + resetFlamechart(); + + ReactNoop.render( + + + + + + , + ); + addComment('The commit phase should mention A and B just once'); + expect(Scheduler).toFlushWithoutYielding(); + ReactNoop.render( + + + + + + , + ); + addComment("Because of deduplication, we don't know B was cascading,"); + addComment('but we should still see the warning for the commit phase.'); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + }); - let resolve; - const LazyFoo = React.lazy( - () => - new Promise(r => { - resolve = r; - }), - ); + it('supports portals', () => { + const portalContainer = ReactNoop.getOrCreateRootContainer( + 'portalContainer', + ); + ReactNoop.render( + + {ReactNoop.createPortal(, portalContainer, null)} + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + }); - ReactNoop.render( - - }> - - - , - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); + it('supports memo', () => { + const MemoFoo = React.memo(function Foo() { + return
; + }); + ReactNoop.render( + + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + }); - resolve( - fakeImport(function Foo() { - return
; - }), - ); + it('supports Suspense and lazy', async () => { + function Spinner() { + return ; + } - await Promise.resolve(); + function fakeImport(result) { + return {default: result}; + } - ReactNoop.render( - - - - - , - ); - expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); - }); + let resolve; + const LazyFoo = React.lazy( + () => + new Promise(r => { + resolve = r; + }), + ); + + ReactNoop.render( + + }> + + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + + resolve( + fakeImport(function Foo() { + return
; + }), + ); + + await Promise.resolve(); + + ReactNoop.render( + + + + + , + ); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + }); - it('does not schedule an extra callback if setState is called during a synchronous commit phase', () => { - class Component extends React.Component { - state = {step: 1}; - componentDidMount() { - this.setState({step: 2}); - } - render() { - return ; - } + it('does not schedule an extra callback if setState is called during a synchronous commit phase', () => { + class Component extends React.Component { + state = {step: 1}; + componentDidMount() { + this.setState({step: 2}); + } + render() { + return ; } - ReactNoop.flushSync(() => { - ReactNoop.render(); - }); - expect(getFlameChart()).toMatchSnapshot(); + } + ReactNoop.flushSync(() => { + ReactNoop.render(); }); + expect(getFlameChart()).toMatchSnapshot(); + }); - it('warns if an in-progress update is interrupted', () => { - function Foo() { - Scheduler.yieldValue('Foo'); - return ; - } + it('warns if an in-progress update is interrupted', () => { + function Foo() { + Scheduler.yieldValue('Foo'); + return ; + } + ReactNoop.render(); + ReactNoop.flushNextYield(); + ReactNoop.flushSync(() => { ReactNoop.render(); - ReactNoop.flushNextYield(); - ReactNoop.flushSync(() => { - ReactNoop.render(); - }); - expect(Scheduler).toHaveYielded(['Foo']); - expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); }); + expect(Scheduler).toHaveYielded(['Foo']); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + }); - it('warns if async work expires (starvation)', () => { - function Foo() { - return ; - } + it('warns if async work expires (starvation)', () => { + function Foo() { + return ; + } - ReactNoop.render(); - ReactNoop.expire(6000); - expect(Scheduler).toFlushWithoutYielding(); - expect(getFlameChart()).toMatchSnapshot(); - }); - } + ReactNoop.render(); + ReactNoop.expire(6000); + expect(Scheduler).toFlushWithoutYielding(); + expect(getFlameChart()).toMatchSnapshot(); + }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js index bacabb906c581..195f98fd7a53a 100644 --- a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js @@ -5,7 +5,6 @@ let Scheduler; let ReactFeatureFlags; let Suspense; let lazy; -let enableNewScheduler; describe('ReactLazy', () => { beforeEach(() => { @@ -19,7 +18,6 @@ describe('ReactLazy', () => { lazy = React.lazy; ReactTestRenderer = require('react-test-renderer'); Scheduler = require('scheduler'); - enableNewScheduler = ReactFeatureFlags.enableNewScheduler; }); function Text(props) { @@ -487,13 +485,7 @@ describe('ReactLazy', () => { await Promise.resolve(); - if (enableNewScheduler) { - // The new scheduler pings in a separate task - expect(Scheduler).toHaveYielded([]); - } else { - // The old scheduler pings synchronously - expect(Scheduler).toHaveYielded(['UNSAFE_componentWillMount: A', 'A1']); - } + expect(Scheduler).toHaveYielded([]); root.update( }> @@ -501,19 +493,7 @@ describe('ReactLazy', () => { , ); - if (enableNewScheduler) { - // Because this ping happens in a new task, the ping and the update - // are batched together - expect(Scheduler).toHaveYielded(['UNSAFE_componentWillMount: A', 'A2']); - } else { - // The old scheduler must do two separate renders, no batching. - expect(Scheduler).toHaveYielded([ - 'UNSAFE_componentWillReceiveProps: A -> A', - 'UNSAFE_componentWillUpdate: A -> A', - 'A2', - ]); - } - + expect(Scheduler).toHaveYielded(['UNSAFE_componentWillMount: A', 'A2']); expect(root).toMatchRenderedOutput('A2'); root.update( diff --git a/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.internal.js index 153d93692a612..d558a578a20b5 100644 --- a/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSchedulerIntegration-test.internal.js @@ -26,7 +26,6 @@ describe('ReactSchedulerIntegration', () => { jest.resetModules(); ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; - ReactFeatureFlags.enableNewScheduler = true; React = require('react'); ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js index 1a23bdf09303a..18d477d839860 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js @@ -5,7 +5,6 @@ let Scheduler; let ReactCache; let Suspense; let act; -let enableNewScheduler; let TextResource; let textResourceShouldFail; @@ -23,7 +22,6 @@ describe('ReactSuspense', () => { act = ReactTestRenderer.act; Scheduler = require('scheduler'); ReactCache = require('react-cache'); - enableNewScheduler = ReactFeatureFlags.enableNewScheduler; Suspense = React.Suspense; @@ -267,11 +265,7 @@ describe('ReactSuspense', () => { await LazyClass; - if (enableNewScheduler) { - expect(Scheduler).toFlushExpired(['Hi', 'Did mount: Hi']); - } else { - expect(Scheduler).toHaveYielded(['Hi', 'Did mount: Hi']); - } + expect(Scheduler).toFlushExpired(['Hi', 'Did mount: Hi']); expect(root).toMatchRenderedOutput('Hi'); }); @@ -400,24 +394,13 @@ describe('ReactSuspense', () => { jest.advanceTimersByTime(100); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [B:1]']); - expect(Scheduler).toFlushExpired([ - 'B:1', - 'Unmount [Loading...]', - // Should be a mount, not an update - 'Mount [B:1]', - ]); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [B:1]', - 'B:1', - 'Unmount [Loading...]', - // Should be a mount, not an update - 'Mount [B:1]', - ]); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [B:1]']); + expect(Scheduler).toFlushExpired([ + 'B:1', + 'Unmount [Loading...]', + // Should be a mount, not an update + 'Mount [B:1]', + ]); expect(root).toMatchRenderedOutput('AB:1C'); instance.setState({step: 2}); @@ -430,21 +413,12 @@ describe('ReactSuspense', () => { jest.advanceTimersByTime(100); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [B:2]']); - expect(Scheduler).toFlushExpired([ - 'B:2', - 'Unmount [Loading...]', - 'Update [B:2]', - ]); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [B:2]', - 'B:2', - 'Unmount [Loading...]', - 'Update [B:2]', - ]); - } + expect(Scheduler).toHaveYielded(['Promise resolved [B:2]']); + expect(Scheduler).toFlushExpired([ + 'B:2', + 'Unmount [Loading...]', + 'Update [B:2]', + ]); expect(root).toMatchRenderedOutput('AB:2C'); }); @@ -477,13 +451,8 @@ describe('ReactSuspense', () => { jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [A]']); - expect(Scheduler).toFlushExpired(['A']); - } else { - expect(Scheduler).toHaveYielded(['Promise resolved [A]', 'A']); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushExpired(['A']); expect(root).toMatchRenderedOutput('Stateful: 1A'); root.update(); @@ -500,13 +469,8 @@ describe('ReactSuspense', () => { jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [B]']); - expect(Scheduler).toFlushExpired(['B']); - } else { - expect(Scheduler).toHaveYielded(['Promise resolved [B]', 'B']); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushExpired(['B']); expect(root).toMatchRenderedOutput('Stateful: 2B'); }); @@ -547,12 +511,8 @@ describe('ReactSuspense', () => { jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [A]']); - expect(Scheduler).toFlushExpired(['A']); - } else { - expect(Scheduler).toHaveYielded(['Promise resolved [A]', 'A']); - } + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushExpired(['A']); expect(root).toMatchRenderedOutput('Stateful: 1A'); root.update(); @@ -576,13 +536,8 @@ describe('ReactSuspense', () => { jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [B]']); - expect(Scheduler).toFlushExpired(['B']); - } else { - expect(Scheduler).toHaveYielded(['Promise resolved [B]', 'B']); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushExpired(['B']); expect(root).toMatchRenderedOutput('Stateful: 2B'); }); @@ -664,16 +619,8 @@ describe('ReactSuspense', () => { expect(Scheduler).toHaveYielded(['Suspend! [A]', 'Loading...']); jest.advanceTimersByTime(500); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [A]']); - expect(Scheduler).toFlushExpired(['A', 'Did commit: A']); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [A]', - 'A', - 'Did commit: A', - ]); - } + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushExpired(['A', 'Did commit: A']); }); it('retries when an update is scheduled on a timed out tree', () => { @@ -756,43 +703,25 @@ describe('ReactSuspense', () => { 'Loading...', ]); expect(Scheduler).toFlushAndYield([]); + jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Child 1]']); - expect(Scheduler).toFlushExpired([ - 'Child 1', - 'Suspend! [Child 2]', - 'Suspend! [Child 3]', - ]); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [Child 1]', - 'Child 1', - 'Suspend! [Child 2]', - 'Suspend! [Child 3]', - ]); - } + + expect(Scheduler).toHaveYielded(['Promise resolved [Child 1]']); + expect(Scheduler).toFlushExpired([ + 'Child 1', + 'Suspend! [Child 2]', + 'Suspend! [Child 3]', + ]); + jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Child 2]']); - expect(Scheduler).toFlushExpired(['Child 2', 'Suspend! [Child 3]']); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [Child 2]', - 'Child 2', - 'Suspend! [Child 3]', - ]); - } + + expect(Scheduler).toHaveYielded(['Promise resolved [Child 2]']); + expect(Scheduler).toFlushExpired(['Child 2', 'Suspend! [Child 3]']); + jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Child 3]']); - expect(Scheduler).toFlushExpired(['Child 3']); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [Child 3]', - 'Child 3', - ]); - } + + expect(Scheduler).toHaveYielded(['Promise resolved [Child 3]']); + expect(Scheduler).toFlushExpired(['Child 3']); expect(root).toMatchRenderedOutput( ['Child 1', 'Child 2', 'Child 3'].join(''), ); @@ -852,15 +781,8 @@ describe('ReactSuspense', () => { expect(root).toMatchRenderedOutput('Loading...'); jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Tab: 0]']); - expect(Scheduler).toFlushExpired(['Tab: 0']); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [Tab: 0]', - 'Tab: 0', - ]); - } + expect(Scheduler).toHaveYielded(['Promise resolved [Tab: 0]']); + expect(Scheduler).toFlushExpired(['Tab: 0']); expect(root).toMatchRenderedOutput('Tab: 0 + sibling'); act(() => setTab(1)); @@ -872,16 +794,8 @@ describe('ReactSuspense', () => { expect(root).toMatchRenderedOutput('Loading...'); jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Tab: 1]']); - expect(Scheduler).toFlushExpired(['Tab: 1']); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [Tab: 1]', - 'Tab: 1', - ]); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [Tab: 1]']); + expect(Scheduler).toFlushExpired(['Tab: 1']); expect(root).toMatchRenderedOutput('Tab: 1 + sibling'); act(() => setTab(2)); @@ -893,16 +807,8 @@ describe('ReactSuspense', () => { expect(root).toMatchRenderedOutput('Loading...'); jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Tab: 2]']); - expect(Scheduler).toFlushExpired(['Tab: 2']); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [Tab: 2]', - 'Tab: 2', - ]); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [Tab: 2]']); + expect(Scheduler).toFlushExpired(['Tab: 2']); expect(root).toMatchRenderedOutput('Tab: 2 + sibling'); }); @@ -939,13 +845,8 @@ describe('ReactSuspense', () => { expect(Scheduler).toHaveYielded(['Suspend! [A:0]', 'Loading...']); jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [A:0]']); - expect(Scheduler).toFlushExpired(['A:0']); - } else { - expect(Scheduler).toHaveYielded(['Promise resolved [A:0]', 'A:0']); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [A:0]']); + expect(Scheduler).toFlushExpired(['A:0']); expect(root).toMatchRenderedOutput('A:0'); act(() => setStep(1)); @@ -982,65 +883,35 @@ describe('ReactSuspense', () => { // Resolve A jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [A]']); - expect(Scheduler).toFlushExpired([ - 'A', - // The promises for B and C have now been thrown twice - 'Suspend! [B]', - 'Suspend! [C]', - ]); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [A]', - 'A', - // The promises for B and C have now been thrown twice - 'Suspend! [B]', - 'Suspend! [C]', - ]); - } + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushExpired([ + 'A', + // The promises for B and C have now been thrown twice + 'Suspend! [B]', + 'Suspend! [C]', + ]); // Resolve B jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [B]']); - expect(Scheduler).toFlushExpired([ - // Even though the promise for B was thrown twice, we should only - // re-render once. - 'B', - // The promise for C has now been thrown three times - 'Suspend! [C]', - ]); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [B]', - // Even though the promise for B was thrown twice, we should only - // re-render once. - 'B', - // The promise for C has now been thrown three times - 'Suspend! [C]', - ]); - } + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushExpired([ + // Even though the promise for B was thrown twice, we should only + // re-render once. + 'B', + // The promise for C has now been thrown three times + 'Suspend! [C]', + ]); // Resolve C jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [C]']); - expect(Scheduler).toFlushExpired([ - // Even though the promise for C was thrown three times, we should only - // re-render once. - 'C', - ]); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [C]', - // Even though the promise for C was thrown three times, we should only - // re-render once. - 'C', - ]); - } + expect(Scheduler).toHaveYielded(['Promise resolved [C]']); + expect(Scheduler).toFlushExpired([ + // Even though the promise for C was thrown three times, we should only + // re-render once. + 'C', + ]); }); it('#14162', () => { diff --git a/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js index 0711e244f2dce..7060f427303a1 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js @@ -17,7 +17,6 @@ let ReactCache; let Suspense; let TextResource; let textResourceShouldFail; -let enableNewScheduler; describe('ReactSuspensePlaceholder', () => { beforeEach(() => { @@ -31,7 +30,6 @@ describe('ReactSuspensePlaceholder', () => { ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); ReactCache = require('react-cache'); - enableNewScheduler = ReactFeatureFlags.enableNewScheduler; Profiler = React.Profiler; Suspense = React.Suspense; @@ -325,16 +323,8 @@ describe('ReactSuspensePlaceholder', () => { jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Loaded]']); - expect(Scheduler).toFlushExpired(['Loaded']); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [Loaded]', - 'Loaded', - ]); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [Loaded]']); + expect(Scheduler).toFlushExpired(['Loaded']); expect(ReactNoop).toMatchRenderedOutput('LoadedText'); expect(onRender).toHaveBeenCalledTimes(2); @@ -434,16 +424,8 @@ describe('ReactSuspensePlaceholder', () => { jest.advanceTimersByTime(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Loaded]']); - expect(Scheduler).toFlushExpired(['Loaded']); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [Loaded]', - 'Loaded', - ]); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [Loaded]']); + expect(Scheduler).toFlushExpired(['Loaded']); expect(ReactNoop).toMatchRenderedOutput('LoadedNew'); expect(onRender).toHaveBeenCalledTimes(4); @@ -490,8 +472,16 @@ describe('ReactSuspensePlaceholder', () => { expect(onRender.mock.calls[1][3]).toBe(15); // Update again while timed out. + // Since this test was originally written we added an optimization to avoid + // suspending in the case that we already timed out. To simulate the old + // behavior, we add a different suspending boundary as a sibling. ReactNoop.render( - , + + + + + + , ); expect(Scheduler).toFlushAndYield([ 'App', @@ -499,18 +489,23 @@ describe('ReactSuspensePlaceholder', () => { 'Suspend! [Loaded]', 'New', 'Fallback', + 'Suspend! [Sibling]', ]); expect(ReactNoop).toMatchRenderedOutput('Loading...'); expect(onRender).toHaveBeenCalledTimes(2); // Resolve the pending promise. jest.advanceTimersByTime(250); - expect(Scheduler).toHaveYielded(['Promise resolved [Loaded]']); + expect(Scheduler).toHaveYielded([ + 'Promise resolved [Loaded]', + 'Promise resolved [Sibling]', + ]); expect(Scheduler).toFlushAndYield([ 'App', 'Suspending', 'Loaded', 'New', + 'Sibling', ]); expect(onRender).toHaveBeenCalledTimes(3); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js index b68b239f80e68..9be24b826b9e2 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js @@ -7,7 +7,6 @@ let ReactCache; let Suspense; let StrictMode; let ConcurrentMode; -let enableNewScheduler; let TextResource; let textResourceShouldFail; @@ -29,7 +28,6 @@ describe('ReactSuspenseWithNoopRenderer', () => { Suspense = React.Suspense; StrictMode = React.StrictMode; ConcurrentMode = React.unstable_ConcurrentMode; - enableNewScheduler = ReactFeatureFlags.enableNewScheduler; TextResource = ReactCache.unstable_createResource(([text, ms = 0]) => { return new Promise((resolve, reject) => @@ -827,6 +825,50 @@ describe('ReactSuspenseWithNoopRenderer', () => { expect(ReactNoop.getChildren()).toEqual([span('goodbye')]); }); + it('a suspended update that expires', async () => { + // Regression test. This test used to fall into an infinite loop. + function ExpensiveText({text}) { + // This causes the update to expire. + Scheduler.advanceTime(10000); + // Then something suspends. + return ; + } + + function App() { + return ( + + + + + + ); + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Suspend! [A]', + 'Suspend! [B]', + 'Suspend! [C]', + ]); + expect(ReactNoop).toMatchRenderedOutput('Loading...'); + + await advanceTimers(200000); + expect(Scheduler).toHaveYielded([ + 'Promise resolved [A]', + 'Promise resolved [B]', + 'Promise resolved [C]', + ]); + + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']); + expect(ReactNoop).toMatchRenderedOutput( + + + + + , + ); + }); + describe('sync mode', () => { it('times out immediately', async () => { function App() { @@ -845,16 +887,8 @@ describe('ReactSuspenseWithNoopRenderer', () => { ReactNoop.expire(100); await advanceTimers(100); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Result]']); - expect(Scheduler).toFlushExpired(['Result']); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [Result]', - 'Result', - ]); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [Result]']); + expect(Scheduler).toFlushExpired(['Result']); expect(ReactNoop.getChildren()).toEqual([span('Result')]); }); @@ -891,27 +925,15 @@ describe('ReactSuspenseWithNoopRenderer', () => { // Initial mount. This is synchronous, because the root is sync. ReactNoop.renderLegacySyncRoot(); await advanceTimers(100); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded([ - 'Suspend! [Step: 1]', - 'Sibling', - 'Loading (1)', - 'Loading (2)', - 'Loading (3)', - 'Promise resolved [Step: 1]', - ]); - expect(Scheduler).toFlushExpired(['Step: 1']); - } else { - expect(Scheduler).toHaveYielded([ - 'Suspend! [Step: 1]', - 'Sibling', - 'Loading (1)', - 'Loading (2)', - 'Loading (3)', - 'Promise resolved [Step: 1]', - 'Step: 1', - ]); - } + expect(Scheduler).toHaveYielded([ + 'Suspend! [Step: 1]', + 'Sibling', + 'Loading (1)', + 'Loading (2)', + 'Loading (3)', + 'Promise resolved [Step: 1]', + ]); + expect(Scheduler).toFlushExpired(['Step: 1']); expect(ReactNoop).toMatchRenderedOutput( @@ -943,15 +965,8 @@ describe('ReactSuspenseWithNoopRenderer', () => { ); await advanceTimers(100); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Step: 2]']); - expect(Scheduler).toFlushExpired(['Step: 2']); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [Step: 2]', - 'Step: 2', - ]); - } + expect(Scheduler).toHaveYielded(['Promise resolved [Step: 2]']); + expect(Scheduler).toFlushExpired(['Step: 2']); expect(ReactNoop).toMatchRenderedOutput( @@ -1010,33 +1025,18 @@ describe('ReactSuspenseWithNoopRenderer', () => { ); await advanceTimers(100); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded([ - 'Before', - 'Suspend! [Async: 1]', - 'After', - 'Loading...', - 'Before', - 'Sync: 1', - 'After', - 'Did mount', - 'Promise resolved [Async: 1]', - ]); - expect(Scheduler).toFlushExpired(['Async: 1']); - } else { - expect(Scheduler).toHaveYielded([ - 'Before', - 'Suspend! [Async: 1]', - 'After', - 'Loading...', - 'Before', - 'Sync: 1', - 'After', - 'Did mount', - 'Promise resolved [Async: 1]', - 'Async: 1', - ]); - } + expect(Scheduler).toHaveYielded([ + 'Before', + 'Suspend! [Async: 1]', + 'After', + 'Loading...', + 'Before', + 'Sync: 1', + 'After', + 'Did mount', + 'Promise resolved [Async: 1]', + ]); + expect(Scheduler).toFlushExpired(['Async: 1']); expect(ReactNoop).toMatchRenderedOutput( @@ -1091,16 +1091,8 @@ describe('ReactSuspenseWithNoopRenderer', () => { // synchronously. await advanceTimers(100); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Async: 2]']); - expect(Scheduler).toFlushExpired(['Async: 2']); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [Async: 2]', - 'Async: 2', - ]); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [Async: 2]']); + expect(Scheduler).toFlushExpired(['Async: 2']); expect(ReactNoop).toMatchRenderedOutput( @@ -1164,33 +1156,18 @@ describe('ReactSuspenseWithNoopRenderer', () => { Scheduler.yieldValue('Did mount'), ); await advanceTimers(100); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded([ - 'Before', - 'Suspend! [Async: 1]', - 'After', - 'Loading...', - 'Before', - 'Sync: 1', - 'After', - 'Did mount', - 'Promise resolved [Async: 1]', - ]); - expect(Scheduler).toFlushExpired(['Async: 1']); - } else { - expect(Scheduler).toHaveYielded([ - 'Before', - 'Suspend! [Async: 1]', - 'After', - 'Loading...', - 'Before', - 'Sync: 1', - 'After', - 'Did mount', - 'Promise resolved [Async: 1]', - 'Async: 1', - ]); - } + expect(Scheduler).toHaveYielded([ + 'Before', + 'Suspend! [Async: 1]', + 'After', + 'Loading...', + 'Before', + 'Sync: 1', + 'After', + 'Did mount', + 'Promise resolved [Async: 1]', + ]); + expect(Scheduler).toFlushExpired(['Async: 1']); expect(ReactNoop).toMatchRenderedOutput( @@ -1245,16 +1222,8 @@ describe('ReactSuspenseWithNoopRenderer', () => { // synchronously. await advanceTimers(100); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Async: 2]']); - expect(Scheduler).toFlushExpired(['Async: 2']); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [Async: 2]', - 'Async: 2', - ]); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [Async: 2]']); + expect(Scheduler).toFlushExpired(['Async: 2']); expect(ReactNoop).toMatchRenderedOutput( @@ -1332,13 +1301,8 @@ describe('ReactSuspenseWithNoopRenderer', () => { ReactNoop.expire(1000); await advanceTimers(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [B]']); - expect(Scheduler).toFlushExpired(['B']); - } else { - expect(Scheduler).toHaveYielded(['Promise resolved [B]', 'B']); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushExpired(['B']); expect(ReactNoop).toMatchRenderedOutput( @@ -1390,21 +1354,12 @@ describe('ReactSuspenseWithNoopRenderer', () => { await advanceTimers(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']); - expect(Scheduler).toFlushExpired([ - 'constructor', - 'Hi', - 'componentDidMount', - ]); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [Hi]', - 'constructor', - 'Hi', - 'componentDidMount', - ]); - } + expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']); + expect(Scheduler).toFlushExpired([ + 'constructor', + 'Hi', + 'componentDidMount', + ]); expect(ReactNoop.getChildren()).toEqual([span('Hi')]); }); @@ -1443,12 +1398,8 @@ describe('ReactSuspenseWithNoopRenderer', () => { ]); expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); await advanceTimers(100); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']); - expect(Scheduler).toFlushExpired(['Hi']); - } else { - expect(Scheduler).toHaveYielded(['Promise resolved [Hi]', 'Hi']); - } + expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']); + expect(Scheduler).toFlushExpired(['Hi']); expect(ReactNoop.getChildren()).toEqual([span('Hi')]); }); @@ -1492,12 +1443,8 @@ describe('ReactSuspenseWithNoopRenderer', () => { await advanceTimers(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']); - expect(Scheduler).toFlushExpired(['Hi']); - } else { - expect(Scheduler).toHaveYielded(['Promise resolved [Hi]', 'Hi']); - } + expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']); + expect(Scheduler).toFlushExpired(['Hi']); }); } else { it('hides/unhides suspended children before layout effects fire (mutation)', async () => { @@ -1536,12 +1483,8 @@ describe('ReactSuspenseWithNoopRenderer', () => { await advanceTimers(1000); - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']); - expect(Scheduler).toFlushExpired(['Hi']); - } else { - expect(Scheduler).toHaveYielded(['Promise resolved [Hi]', 'Hi']); - } + expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']); + expect(Scheduler).toFlushExpired(['Hi']); }); } }); @@ -1624,6 +1567,162 @@ describe('ReactSuspenseWithNoopRenderer', () => { , ); }); + + it('suspends for longer if something took a long (CPU bound) time to render', async () => { + function Foo() { + Scheduler.yieldValue('Foo'); + return ( + }> + + + ); + } + + ReactNoop.render(); + Scheduler.advanceTime(100); + await advanceTimers(100); + // Start rendering + expect(Scheduler).toFlushAndYieldThrough(['Foo']); + // For some reason it took a long time to render Foo. + Scheduler.advanceTime(1250); + await advanceTimers(1250); + expect(Scheduler).toFlushAndYield([ + // A suspends + 'Suspend! [A]', + 'Loading...', + ]); + // We're now suspended and we haven't shown anything yet. + expect(ReactNoop.getChildren()).toEqual([]); + + // Flush some of the time + Scheduler.advanceTime(450); + await advanceTimers(450); + // Because we've already been waiting for so long we can + // wait a bit longer. Still nothing... + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([]); + + // Eventually we'll show the fallback. + Scheduler.advanceTime(500); + await advanceTimers(500); + // No need to rerender. + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + // Flush the promise completely + Scheduler.advanceTime(4500); + await advanceTimers(4500); + // Renders successfully + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['A']); + expect(ReactNoop.getChildren()).toEqual([span('A')]); + }); + + it('suspends for longer if a fallback has been shown for a long time', async () => { + function Foo() { + Scheduler.yieldValue('Foo'); + return ( + }> + + }> + + + + ); + } + + ReactNoop.render(); + // Start rendering + expect(Scheduler).toFlushAndYield([ + 'Foo', + // A suspends + 'Suspend! [A]', + // B suspends + 'Suspend! [B]', + 'Loading more...', + 'Loading...', + ]); + // We're now suspended and we haven't shown anything yet. + expect(ReactNoop.getChildren()).toEqual([]); + + // Show the fallback. + Scheduler.advanceTime(400); + await advanceTimers(400); + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + // Wait a long time. + Scheduler.advanceTime(5000); + await advanceTimers(5000); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + + // Retry with the new content. + expect(Scheduler).toFlushAndYield([ + 'A', + // B still suspends + 'Suspend! [B]', + 'Loading more...', + ]); + // Because we've already been waiting for so long we can + // wait a bit longer. Still nothing... + Scheduler.advanceTime(600); + await advanceTimers(600); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + // Eventually we'll show more content with inner fallback. + Scheduler.advanceTime(3000); + await advanceTimers(3000); + // No need to rerender. + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([ + span('A'), + span('Loading more...'), + ]); + + // Flush the last promise completely + Scheduler.advanceTime(4500); + await advanceTimers(4500); + // Renders successfully + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushAndYield(['B']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); + }); + + it('does not suspend for very long after a higher priority update', async () => { + function Foo() { + Scheduler.yieldValue('Foo'); + return ( + }> + + + ); + } + + ReactNoop.interactiveUpdates(() => ReactNoop.render()); + expect(Scheduler).toFlushAndYieldThrough(['Foo']); + + // Advance some time. + Scheduler.advanceTime(100); + await advanceTimers(100); + + expect(Scheduler).toFlushAndYield([ + // A suspends + 'Suspend! [A]', + 'Loading...', + ]); + // We're now suspended and we haven't shown anything yet. + expect(ReactNoop.getChildren()).toEqual([]); + + // Flush some of the time + Scheduler.advanceTime(500); + await advanceTimers(500); + // We should have already shown the fallback. + // When we wrote this test, we inferred the start time of high priority + // updates as way earlier in the past. This test ensures that we don't + // use this assumption to add a very long JND. + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + }); }); // TODO: diff --git a/packages/react-reconciler/src/__tests__/__snapshots__/ReactIncrementalPerf-test.internal.js.snap b/packages/react-reconciler/src/__tests__/__snapshots__/ReactIncrementalPerf-test.internal.js.snap index 541307deeadc4..9f39e61ea88a3 100644 --- a/packages/react-reconciler/src/__tests__/__snapshots__/ReactIncrementalPerf-test.internal.js.snap +++ b/packages/react-reconciler/src/__tests__/__snapshots__/ReactIncrementalPerf-test.internal.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ReactDebugFiberPerf new scheduler captures all lifecycles 1`] = ` +exports[`ReactDebugFiberPerf captures all lifecycles 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) // Mount @@ -44,7 +44,7 @@ exports[`ReactDebugFiberPerf new scheduler captures all lifecycles 1`] = ` " `; -exports[`ReactDebugFiberPerf new scheduler deduplicates lifecycle names during commit to reduce overhead 1`] = ` +exports[`ReactDebugFiberPerf deduplicates lifecycle names during commit to reduce overhead 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) // The commit phase should mention A and B just once @@ -91,7 +91,7 @@ exports[`ReactDebugFiberPerf new scheduler deduplicates lifecycle names during c " `; -exports[`ReactDebugFiberPerf new scheduler does not include ConcurrentMode, StrictMode, or Profiler components in measurements 1`] = ` +exports[`ReactDebugFiberPerf does not include ConcurrentMode, StrictMode, or Profiler components in measurements 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) // Mount @@ -107,7 +107,7 @@ exports[`ReactDebugFiberPerf new scheduler does not include ConcurrentMode, Stri " `; -exports[`ReactDebugFiberPerf new scheduler does not include context provider or consumer in measurements 1`] = ` +exports[`ReactDebugFiberPerf does not include context provider or consumer in measurements 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) // Mount @@ -122,7 +122,7 @@ exports[`ReactDebugFiberPerf new scheduler does not include context provider or " `; -exports[`ReactDebugFiberPerf new scheduler does not schedule an extra callback if setState is called during a synchronous commit phase 1`] = ` +exports[`ReactDebugFiberPerf does not schedule an extra callback if setState is called during a synchronous commit phase 1`] = ` "⚛ (React Tree Reconciliation: Completed Root) ⚛ Component [mount] @@ -142,7 +142,7 @@ exports[`ReactDebugFiberPerf new scheduler does not schedule an extra callback i " `; -exports[`ReactDebugFiberPerf new scheduler does not treat setState from cWM or cWRP as cascading 1`] = ` +exports[`ReactDebugFiberPerf does not treat setState from cWM or cWRP as cascading 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) // Should not print a warning @@ -171,7 +171,7 @@ exports[`ReactDebugFiberPerf new scheduler does not treat setState from cWM or c " `; -exports[`ReactDebugFiberPerf new scheduler measures a simple reconciliation 1`] = ` +exports[`ReactDebugFiberPerf measures a simple reconciliation 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) // Mount @@ -208,7 +208,7 @@ exports[`ReactDebugFiberPerf new scheduler measures a simple reconciliation 1`] " `; -exports[`ReactDebugFiberPerf new scheduler measures deferred work in chunks 1`] = ` +exports[`ReactDebugFiberPerf measures deferred work in chunks 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) // Start rendering through B @@ -235,7 +235,7 @@ exports[`ReactDebugFiberPerf new scheduler measures deferred work in chunks 1`] " `; -exports[`ReactDebugFiberPerf new scheduler measures deprioritized work 1`] = ` +exports[`ReactDebugFiberPerf measures deprioritized work 1`] = ` "// Flush the parent ⚛ (React Tree Reconciliation: Completed Root) ⚛ Parent [mount] @@ -258,7 +258,7 @@ exports[`ReactDebugFiberPerf new scheduler measures deprioritized work 1`] = ` " `; -exports[`ReactDebugFiberPerf new scheduler properly displays the forwardRef component in measurements 1`] = ` +exports[`ReactDebugFiberPerf properly displays the forwardRef component in measurements 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) // Mount @@ -278,7 +278,7 @@ exports[`ReactDebugFiberPerf new scheduler properly displays the forwardRef comp " `; -exports[`ReactDebugFiberPerf new scheduler recovers from caught errors 1`] = ` +exports[`ReactDebugFiberPerf recovers from caught errors 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) // Stop on Baddie and restart from Boundary @@ -312,7 +312,7 @@ exports[`ReactDebugFiberPerf new scheduler recovers from caught errors 1`] = ` " `; -exports[`ReactDebugFiberPerf new scheduler recovers from fatal errors 1`] = ` +exports[`ReactDebugFiberPerf recovers from fatal errors 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) // Will fatal @@ -343,7 +343,7 @@ exports[`ReactDebugFiberPerf new scheduler recovers from fatal errors 1`] = ` " `; -exports[`ReactDebugFiberPerf new scheduler skips parents during setState 1`] = ` +exports[`ReactDebugFiberPerf skips parents during setState 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) // Should include just A and B, no Parents @@ -358,7 +358,7 @@ exports[`ReactDebugFiberPerf new scheduler skips parents during setState 1`] = ` " `; -exports[`ReactDebugFiberPerf new scheduler supports Suspense and lazy 1`] = ` +exports[`ReactDebugFiberPerf supports Suspense and lazy 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) ⚛ (React Tree Reconciliation: Completed Root) @@ -369,7 +369,7 @@ exports[`ReactDebugFiberPerf new scheduler supports Suspense and lazy 1`] = ` " `; -exports[`ReactDebugFiberPerf new scheduler supports Suspense and lazy 2`] = ` +exports[`ReactDebugFiberPerf supports Suspense and lazy 2`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) ⚛ (React Tree Reconciliation: Completed Root) @@ -392,7 +392,7 @@ exports[`ReactDebugFiberPerf new scheduler supports Suspense and lazy 2`] = ` " `; -exports[`ReactDebugFiberPerf new scheduler supports memo 1`] = ` +exports[`ReactDebugFiberPerf supports memo 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) ⚛ (React Tree Reconciliation: Completed Root) @@ -406,7 +406,7 @@ exports[`ReactDebugFiberPerf new scheduler supports memo 1`] = ` " `; -exports[`ReactDebugFiberPerf new scheduler supports portals 1`] = ` +exports[`ReactDebugFiberPerf supports portals 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) ⚛ (React Tree Reconciliation: Completed Root) @@ -420,7 +420,7 @@ exports[`ReactDebugFiberPerf new scheduler supports portals 1`] = ` " `; -exports[`ReactDebugFiberPerf new scheduler warns if an in-progress update is interrupted 1`] = ` +exports[`ReactDebugFiberPerf warns if an in-progress update is interrupted 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) ⚛ (React Tree Reconciliation: Yielded) @@ -443,7 +443,7 @@ exports[`ReactDebugFiberPerf new scheduler warns if an in-progress update is int " `; -exports[`ReactDebugFiberPerf new scheduler warns if async work expires (starvation) 1`] = ` +exports[`ReactDebugFiberPerf warns if async work expires (starvation) 1`] = ` "⛔ (Waiting for async callback... will force flush in 5250 ms) Warning: React was blocked by main thread ⚛ (Committing Changes) @@ -453,7 +453,7 @@ exports[`ReactDebugFiberPerf new scheduler warns if async work expires (starvati " `; -exports[`ReactDebugFiberPerf new scheduler warns on cascading renders from setState 1`] = ` +exports[`ReactDebugFiberPerf warns on cascading renders from setState 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) // Should print a warning @@ -477,511 +477,7 @@ exports[`ReactDebugFiberPerf new scheduler warns on cascading renders from setSt " `; -exports[`ReactDebugFiberPerf new scheduler warns on cascading renders from top-level render 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Rendering the first root -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Cascading [mount] - -⛔ (Committing Changes) Warning: Lifecycle hook scheduled a cascading update - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 1 Total) - ⛔ Cascading.componentDidMount Warning: Scheduled a cascading update - -// Scheduling another root from componentDidMount -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Child [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler captures all lifecycles 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Mount -⚛ (React Tree Reconciliation: Completed Root) - ⚛ AllLifecycles [mount] - ⚛ AllLifecycles.componentWillMount - ⚛ AllLifecycles.getChildContext - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 1 Total) - ⚛ AllLifecycles.componentDidMount - -⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Update -⚛ (React Tree Reconciliation: Completed Root) - ⚛ AllLifecycles [update] - ⚛ AllLifecycles.componentWillReceiveProps - ⚛ AllLifecycles.shouldComponentUpdate - ⚛ AllLifecycles.componentWillUpdate - ⚛ AllLifecycles.getChildContext - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 2 Total) - ⚛ (Calling Lifecycle Methods: 2 Total) - ⚛ AllLifecycles.componentDidUpdate - -⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Unmount -⚛ (React Tree Reconciliation: Completed Root) - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ AllLifecycles.componentWillUnmount - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler deduplicates lifecycle names during commit to reduce overhead 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -// The commit phase should mention A and B just once -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [update] - ⚛ A [update] - ⚛ B [update] - ⚛ A [update] - ⚛ B [update] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 9 Total) - ⚛ (Calling Lifecycle Methods: 9 Total) - ⚛ A.componentDidUpdate - ⚛ B.componentDidUpdate - -⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Because of deduplication, we don't know B was cascading, -// but we should still see the warning for the commit phase. -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [update] - ⚛ A [update] - ⚛ B [update] - ⚛ A [update] - ⚛ B [update] - -⛔ (Committing Changes) Warning: Lifecycle hook scheduled a cascading update - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 9 Total) - ⚛ (Calling Lifecycle Methods: 9 Total) - ⚛ A.componentDidUpdate - ⚛ B.componentDidUpdate - -⚛ (React Tree Reconciliation: Completed Root) - ⚛ B [update] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 2 Total) - ⚛ (Calling Lifecycle Methods: 2 Total) - ⚛ B.componentDidUpdate -" -`; - -exports[`ReactDebugFiberPerf old scheduler does not include ConcurrentMode, StrictMode, or Profiler components in measurements 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Mount -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Profiler [mount] - ⚛ Parent [mount] - ⚛ Child [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler does not include context provider or consumer in measurements 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Mount -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⚛ Child [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler does not schedule an extra callback if setState is called during a synchronous commit phase 1`] = ` -"⚛ (React Tree Reconciliation: Completed Root) - ⚛ Component [mount] - -⛔ (Committing Changes) Warning: Lifecycle hook scheduled a cascading update - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 1 Total) - ⛔ Component.componentDidMount Warning: Scheduled a cascading update - -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Component [update] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 1 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler does not treat setState from cWM or cWRP as cascading 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Should not print a warning -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⚛ NotCascading [mount] - ⚛ NotCascading.componentWillMount - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) - -⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Should not print a warning -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [update] - ⚛ NotCascading [update] - ⚛ NotCascading.componentWillReceiveProps - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 2 Total) - ⚛ (Calling Lifecycle Methods: 2 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler measures a simple reconciliation 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Mount -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⚛ Child [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) - -⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Update -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [update] - ⚛ Child [update] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 2 Total) - ⚛ (Calling Lifecycle Methods: 2 Total) - -⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Unmount -⚛ (React Tree Reconciliation: Completed Root) - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler measures deferred work in chunks 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Start rendering through B -⚛ (React Tree Reconciliation: Yielded) - ⚛ Parent [mount] - ⚛ A [mount] - ⚛ Child [mount] - ⚛ B [mount] - -⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Complete the rest -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⚛ B [mount] - ⚛ Child [mount] - ⚛ C [mount] - ⚛ Child [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler measures deprioritized work 1`] = ` -"// Flush the parent -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) - -⚛ (Waiting for async callback... will force flush in 10737418210 ms) - -// Flush the child -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Child [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler properly displays the forwardRef component in measurements 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Mount -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⚛ ForwardRef [mount] - ⚛ Child [mount] - ⚛ ForwardRef(refForwarder) [mount] - ⚛ Child [mount] - ⚛ ForwardRef(OverriddenName) [mount] - ⚛ Child [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler recovers from caught errors 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Stop on Baddie and restart from Boundary -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⛔ Boundary [mount] Warning: An error was thrown inside this error boundary - ⚛ Parent [mount] - ⚛ Baddie [mount] - ⚛ Boundary [mount] - -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⛔ Boundary [mount] Warning: An error was thrown inside this error boundary - ⚛ Parent [mount] - ⚛ Baddie [mount] - ⚛ Boundary [mount] - -⛔ (Committing Changes) Warning: Lifecycle hook scheduled a cascading update - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 2 Total) - ⚛ (Calling Lifecycle Methods: 1 Total) - -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Boundary [update] - ⚛ ErrorReport [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler recovers from fatal errors 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Will fatal -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⚛ Baddie [mount] - -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⚛ Baddie [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 1 Total) - -⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Will reconcile from a clean state -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⚛ Child [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler skips parents during setState 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Should include just A and B, no Parents -⚛ (React Tree Reconciliation: Completed Root) - ⚛ A [update] - ⚛ B [update] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 2 Total) - ⚛ (Calling Lifecycle Methods: 2 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler supports Suspense and lazy 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⛔ Suspense [mount] Warning: Rendering was suspended - ⚛ Suspense [mount] - ⚛ Spinner [mount] -" -`; - -exports[`ReactDebugFiberPerf old scheduler supports Suspense and lazy 2`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⛔ Suspense [mount] Warning: Rendering was suspended - ⚛ Suspense [mount] - ⚛ Spinner [mount] - -⚛ (Waiting for async callback... will force flush in 5250 ms) - -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⚛ Suspense [mount] - ⚛ Foo [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler supports memo 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⚛ Foo [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler supports portals 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⚛ Child [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 2 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler warns if an in-progress update is interrupted 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -⚛ (React Tree Reconciliation: Yielded) - ⚛ Foo [mount] - -⚛ (Waiting for async callback... will force flush in 5250 ms) - ⛔ (React Tree Reconciliation: Completed Root) Warning: A top-level update interrupted the previous render - ⚛ Foo [mount] - ⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) - -⚛ (React Tree Reconciliation: Completed Root) - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 0 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler warns if async work expires (starvation) 1`] = ` -"⛔ (Waiting for async callback... will force flush in 5250 ms) Warning: React was blocked by main thread - -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Foo [mount] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 0 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler warns on cascading renders from setState 1`] = ` -"⚛ (Waiting for async callback... will force flush in 5250 ms) - -// Should print a warning -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Parent [mount] - ⚛ Cascading [mount] - -⛔ (Committing Changes) Warning: Lifecycle hook scheduled a cascading update - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 2 Total) - ⚛ (Calling Lifecycle Methods: 1 Total) - ⛔ Cascading.componentDidMount Warning: Scheduled a cascading update - -⚛ (React Tree Reconciliation: Completed Root) - ⚛ Cascading [update] - -⚛ (Committing Changes) - ⚛ (Committing Snapshot Effects: 0 Total) - ⚛ (Committing Host Effects: 1 Total) - ⚛ (Calling Lifecycle Methods: 1 Total) -" -`; - -exports[`ReactDebugFiberPerf old scheduler warns on cascading renders from top-level render 1`] = ` +exports[`ReactDebugFiberPerf warns on cascading renders from top-level render 1`] = ` "⚛ (Waiting for async callback... will force flush in 5250 ms) // Rendering the first root diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index d1f38c65d22a3..98698a68a8416 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -63,8 +63,11 @@ export const isPrimaryRenderer = $$$hostConfig.isPrimaryRenderer; export const supportsMutation = $$$hostConfig.supportsMutation; export const supportsPersistence = $$$hostConfig.supportsPersistence; export const supportsHydration = $$$hostConfig.supportsHydration; -export const handleEventComponent = $$$hostConfig.handleEventComponent; +export const mountEventComponent = $$$hostConfig.mountEventComponent; +export const updateEventComponent = $$$hostConfig.updateEventComponent; export const handleEventTarget = $$$hostConfig.handleEventTarget; +export const getEventTargetChildElement = + $$$hostConfig.getEventTargetChildElement; // ------------------- // Mutation @@ -84,6 +87,10 @@ export const hideInstance = $$$hostConfig.hideInstance; export const hideTextInstance = $$$hostConfig.hideTextInstance; export const unhideInstance = $$$hostConfig.unhideInstance; export const unhideTextInstance = $$$hostConfig.unhideTextInstance; +export const unmountEventComponent = $$$hostConfig.unmountEventComponent; +export const commitTouchHitTargetUpdate = + $$$hostConfig.commitTouchHitTargetUpdate; +export const commitEventTarget = $$$hostConfig.commitEventTarget; // ------------------- // Persistence diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index 8020282394c64..f765c9d6d928c 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -9,11 +9,23 @@ import warning from 'shared/warning'; -import type {ReactEventResponder} from 'shared/ReactTypes'; +import type {ReactEventComponentInstance} from 'shared/ReactTypes'; import {REACT_EVENT_TARGET_TOUCH_HIT} from 'shared/ReactSymbols'; import {enableEventAPI} from 'shared/ReactFeatureFlags'; +type EventTargetChildElement = { + type: string, + props: null | { + style?: { + position?: string, + bottom?: string, + left?: string, + right?: string, + top?: string, + }, + }, +}; export type Type = string; export type Props = Object; export type Container = {| @@ -170,12 +182,6 @@ export function createInstance( hostContext: Object, internalInstanceHandle: Object, ): Instance { - if (__DEV__ && enableEventAPI) { - warning( - hostContext !== EVENT_TOUCH_HIT_TARGET_CONTEXT, - 'validateDOMNesting: must not have any children.', - ); - } return { type, props, @@ -233,10 +239,6 @@ export function createTextInstance( internalInstanceHandle: Object, ): TextInstance { if (__DEV__ && enableEventAPI) { - warning( - hostContext !== EVENT_TOUCH_HIT_TARGET_CONTEXT, - 'validateDOMNesting: must not have any children.', - ); warning( hostContext !== EVENT_COMPONENT_CONTEXT, 'validateDOMNesting: React event components cannot have text DOM nodes as children. ' + @@ -325,21 +327,76 @@ export function unhideTextInstance( textInstance.isHidden = false; } -export function handleEventComponent( - eventResponder: ReactEventResponder, - rootContainerInstance: Container, - internalInstanceHandle: Object, -) { - // TODO: add handleEventComponent implementation +export function mountEventComponent( + eventComponentInstance: ReactEventComponentInstance, +): void { + // noop +} + +export function updateEventComponent( + eventComponentInstance: ReactEventComponentInstance, +): void { + // noop +} + +export function unmountEventComponent( + eventComponentInstance: ReactEventComponentInstance, +): void { + // noop +} + +export function getEventTargetChildElement( + type: Symbol | number, + props: Props, +): null | EventTargetChildElement { + if (enableEventAPI) { + if (type === REACT_EVENT_TARGET_TOUCH_HIT) { + const {bottom, left, right, top} = props; + + if (!bottom && !left && !right && !top) { + return null; + } + return { + type: 'div', + props: { + style: { + position: 'absolute', + zIndex: -1, + bottom: bottom ? `-${bottom}px` : '0px', + left: left ? `-${left}px` : '0px', + right: right ? `-${right}px` : '0px', + top: top ? `-${top}px` : '0px', + }, + }, + }; + } + } + return null; } export function handleEventTarget( type: Symbol | number, props: Props, - parentInstance: Container, + rootContainerInstance: Container, internalInstanceHandle: Object, -) { - if (type === REACT_EVENT_TARGET_TOUCH_HIT) { - // TODO +): boolean { + if (enableEventAPI) { + if (type === REACT_EVENT_TARGET_TOUCH_HIT) { + // In DEV we do a computed style check on the position to ensure + // the parent host component is correctly position in the document. + if (__DEV__) { + return true; + } + } } + return false; +} + +export function commitEventTarget( + type: Symbol | number, + props: Props, + instance: Instance, + parentInstance: Instance, +): void { + // noop } diff --git a/packages/react/src/React.js b/packages/react/src/React.js index ba696073f82a5..0a96310a11925 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -22,6 +22,7 @@ import { createFactory, cloneElement, isValidElement, + jsx, } from './ReactElement'; import {createContext} from './ReactContext'; import {lazy} from './ReactLazy'; @@ -43,10 +44,16 @@ import { createElementWithValidation, createFactoryWithValidation, cloneElementWithValidation, + jsxWithValidation, + jsxWithValidationStatic, + jsxWithValidationDynamic, } from './ReactElementValidator'; import ReactSharedInternals from './ReactSharedInternals'; import {error, warn} from './withComponentStack'; -import {enableStableConcurrentModeAPIs} from 'shared/ReactFeatureFlags'; +import { + enableStableConcurrentModeAPIs, + enableJSXTransformAPI, +} from 'shared/ReactFeatureFlags'; const React = { Children: { @@ -107,4 +114,17 @@ if (enableStableConcurrentModeAPIs) { React.unstable_ConcurrentMode = undefined; } +if (enableJSXTransformAPI) { + if (__DEV__) { + React.jsxDEV = jsxWithValidation; + React.jsx = jsxWithValidationDynamic; + React.jsxs = jsxWithValidationStatic; + } else { + React.jsx = jsx; + // we may want to special case jsxs internally to take advantage of static children. + // for now we can ship identical prod functions + React.jsxs = jsx; + } +} + export default React; diff --git a/packages/react/src/ReactElement.js b/packages/react/src/ReactElement.js index 50561efe3aace..9416a313b9648 100644 --- a/packages/react/src/ReactElement.js +++ b/packages/react/src/ReactElement.js @@ -95,8 +95,10 @@ function defineRefPropWarningGetter(props, displayName) { * if something is a React Element. * * @param {*} type + * @param {*} props * @param {*} key * @param {string|object} ref + * @param {*} owner * @param {*} self A *temporary* helper to detect places where `this` is * different from the `owner` when React.createElement is called, so that we * can warn. We want to get rid of owner and replace string `ref`s with arrow @@ -104,8 +106,6 @@ function defineRefPropWarningGetter(props, displayName) { * change in behavior. * @param {*} source An annotation object (added by a transpiler or otherwise) * indicating filename, line number, and/or other information. - * @param {*} owner - * @param {*} props * @internal */ const ReactElement = function(type, key, ref, self, source, owner, props) { @@ -164,6 +164,139 @@ const ReactElement = function(type, key, ref, self, source, owner, props) { return element; }; +/** + * https://github.com/reactjs/rfcs/pull/107 + * @param {*} type + * @param {object} props + * @param {string} key + */ +export function jsx(type, config, maybeKey) { + let propName; + + // Reserved names are extracted + const props = {}; + + let key = null; + let ref = null; + + if (hasValidRef(config)) { + ref = config.ref; + } + + if (hasValidKey(config)) { + key = '' + config.key; + } + + // Remaining properties are added to a new props object + for (propName in config) { + if ( + hasOwnProperty.call(config, propName) && + !RESERVED_PROPS.hasOwnProperty(propName) + ) { + props[propName] = config[propName]; + } + } + + // intentionally not checking if key was set above + // this key is higher priority as it's static + if (maybeKey !== undefined) { + key = '' + maybeKey; + } + + // Resolve default props + if (type && type.defaultProps) { + const defaultProps = type.defaultProps; + for (propName in defaultProps) { + if (props[propName] === undefined) { + props[propName] = defaultProps[propName]; + } + } + } + + return ReactElement( + type, + key, + ref, + undefined, + undefined, + ReactCurrentOwner.current, + props, + ); +} + +/** + * https://github.com/reactjs/rfcs/pull/107 + * @param {*} type + * @param {object} props + * @param {string} key + */ +export function jsxDEV(type, config, maybeKey, source, self) { + let propName; + + // Reserved names are extracted + const props = {}; + + let key = null; + let ref = null; + + if (hasValidRef(config)) { + ref = config.ref; + } + + if (hasValidKey(config)) { + key = '' + config.key; + } + + // Remaining properties are added to a new props object + for (propName in config) { + if ( + hasOwnProperty.call(config, propName) && + !RESERVED_PROPS.hasOwnProperty(propName) + ) { + props[propName] = config[propName]; + } + } + + // intentionally not checking if key was set above + // this key is higher priority as it's static + if (maybeKey !== undefined) { + key = '' + maybeKey; + } + + // Resolve default props + if (type && type.defaultProps) { + const defaultProps = type.defaultProps; + for (propName in defaultProps) { + if (props[propName] === undefined) { + props[propName] = defaultProps[propName]; + } + } + } + + if (key || ref) { + const displayName = + typeof type === 'function' + ? type.displayName || type.name || 'Unknown' + : type; + if (key) { + defineKeyPropWarningGetter(props, displayName); + } + if (ref) { + defineRefPropWarningGetter(props, displayName); + } + } + + return ReactElement( + type, + key, + ref, + self, + source, + ReactCurrentOwner.current, + props, + ); +} + /** * Create and return a new ReactElement of the given type. * See https://reactjs.org/docs/react-api.html#createelement diff --git a/packages/react/src/ReactElementValidator.js b/packages/react/src/ReactElementValidator.js index fa83aea4ef28e..6a1626a2e27e8 100644 --- a/packages/react/src/ReactElementValidator.js +++ b/packages/react/src/ReactElementValidator.js @@ -27,7 +27,12 @@ import warning from 'shared/warning'; import warningWithoutStack from 'shared/warningWithoutStack'; import ReactCurrentOwner from './ReactCurrentOwner'; -import {isValidElement, createElement, cloneElement} from './ReactElement'; +import { + isValidElement, + createElement, + cloneElement, + jsxDEV, +} from './ReactElement'; import ReactDebugCurrentFrame, { setCurrentlyValidatingElement, } from './ReactDebugCurrentFrame'; @@ -48,13 +53,8 @@ function getDeclarationErrorAddendum() { return ''; } -function getSourceInfoErrorAddendum(elementProps) { - if ( - elementProps !== null && - elementProps !== undefined && - elementProps.__source !== undefined - ) { - const source = elementProps.__source; +function getSourceInfoErrorAddendum(source) { + if (source !== undefined) { const fileName = source.fileName.replace(/^.*[\\\/]/, ''); const lineNumber = source.lineNumber; return '\n\nCheck your code at ' + fileName + ':' + lineNumber + '.'; @@ -62,6 +62,13 @@ function getSourceInfoErrorAddendum(elementProps) { return ''; } +function getSourceInfoErrorAddendumForProps(elementProps) { + if (elementProps !== null && elementProps !== undefined) { + return getSourceInfoErrorAddendum(elementProps.__source); + } + return ''; +} + /** * Warn if there's no key explicitly set on dynamic arrays of children or * object keys are not valid. This allows us to keep track of children between @@ -259,6 +266,117 @@ function validateFragmentProps(fragment) { setCurrentlyValidatingElement(null); } +export function jsxWithValidation( + type, + props, + key, + isStaticChildren, + source, + self, +) { + const validType = isValidElementType(type); + + // We warn in this case but don't throw. We expect the element creation to + // succeed and there will likely be errors in render. + if (!validType) { + let info = ''; + if ( + type === undefined || + (typeof type === 'object' && + type !== null && + Object.keys(type).length === 0) + ) { + info += + ' You likely forgot to export your component from the file ' + + "it's defined in, or you might have mixed up default and named imports."; + } + + const sourceInfo = getSourceInfoErrorAddendum(source); + if (sourceInfo) { + info += sourceInfo; + } else { + info += getDeclarationErrorAddendum(); + } + + let typeString; + if (type === null) { + typeString = 'null'; + } else if (Array.isArray(type)) { + typeString = 'array'; + } else if (type !== undefined && type.$$typeof === REACT_ELEMENT_TYPE) { + typeString = `<${getComponentName(type.type) || 'Unknown'} />`; + info = + ' Did you accidentally export a JSX literal instead of a component?'; + } else { + typeString = typeof type; + } + + warning( + false, + 'React.jsx: type is invalid -- expected a string (for ' + + 'built-in components) or a class/function (for composite ' + + 'components) but got: %s.%s', + typeString, + info, + ); + } + + const element = jsxDEV(type, props, key, source, self); + + // The result can be nullish if a mock or a custom function is used. + // TODO: Drop this when these are no longer allowed as the type argument. + if (element == null) { + return element; + } + + // Skip key warning if the type isn't valid since our key validation logic + // doesn't expect a non-string/function type and can throw confusing errors. + // We don't want exception behavior to differ between dev and prod. + // (Rendering will throw with a helpful message and as soon as the type is + // fixed, the key warnings will appear.) + if (validType) { + const children = props.children; + if (children !== undefined) { + if (isStaticChildren) { + for (let i = 0; i < children.length; i++) { + validateChildKeys(children[i], type); + } + } else { + validateChildKeys(children, type); + } + } + } + + if (props.key !== undefined) { + warning( + false, + 'React.jsx: Spreading a key to JSX is a deprecated pattern. ' + + 'Explicitly pass a key after spreading props in your JSX call. ' + + 'E.g. ', + ); + } + + if (type === REACT_FRAGMENT_TYPE) { + validateFragmentProps(element); + } else { + validatePropTypes(element); + } + + return element; +} + +// These two functions exist to still get child warnings in dev +// even with the prod transform. This means that jsxDEV is purely +// opt-in behavior for better messages but that we won't stop +// giving you warnings if you use production apis. +export function jsxWithValidationStatic(type, props, key) { + return jsxWithValidation(type, props, key, true); +} + +export function jsxWithValidationDynamic(type, props, key) { + return jsxWithValidation(type, props, key, false); +} + export function createElementWithValidation(type, props, children) { const validType = isValidElementType(type); @@ -277,7 +395,7 @@ export function createElementWithValidation(type, props, children) { "it's defined in, or you might have mixed up default and named imports."; } - const sourceInfo = getSourceInfoErrorAddendum(props); + const sourceInfo = getSourceInfoErrorAddendumForProps(props); if (sourceInfo) { info += sourceInfo; } else { diff --git a/packages/react/src/__tests__/ReactElementJSX-test.internal.js b/packages/react/src/__tests__/ReactElementJSX-test.internal.js new file mode 100644 index 0000000000000..db62a2c51ec15 --- /dev/null +++ b/packages/react/src/__tests__/ReactElementJSX-test.internal.js @@ -0,0 +1,364 @@ +/** + * 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. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactDOM; +let ReactFeatureFlags; +let ReactTestUtils; + +// NOTE: We're explicitly not using JSX here. This is intended to test +// a new React.jsx api which does not have a JSX transformer yet. +// A lot of these tests are pulled from ReactElement-test because +// this api is meant to be backwards compatible. +describe('ReactElement.jsx', () => { + let originalSymbol; + + beforeEach(() => { + jest.resetModules(); + + // Delete the native Symbol if we have one to ensure we test the + // unpolyfilled environment. + originalSymbol = global.Symbol; + global.Symbol = undefined; + + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableJSXTransformAPI = true; + + React = require('react'); + ReactDOM = require('react-dom'); + ReactTestUtils = require('react-dom/test-utils'); + }); + + afterEach(() => { + global.Symbol = originalSymbol; + }); + + it('allows static methods to be called using the type property', () => { + class StaticMethodComponentClass extends React.Component { + render() { + return React.jsx('div', {}); + } + } + StaticMethodComponentClass.someStaticMethod = () => 'someReturnValue'; + + const element = React.jsx(StaticMethodComponentClass, {}); + expect(element.type.someStaticMethod()).toBe('someReturnValue'); + }); + + it('identifies valid elements', () => { + class Component extends React.Component { + render() { + return React.jsx('div', {}); + } + } + + expect(React.isValidElement(React.jsx('div', {}))).toEqual(true); + expect(React.isValidElement(React.jsx(Component, {}))).toEqual(true); + + expect(React.isValidElement(null)).toEqual(false); + expect(React.isValidElement(true)).toEqual(false); + expect(React.isValidElement({})).toEqual(false); + expect(React.isValidElement('string')).toEqual(false); + expect(React.isValidElement(React.createFactory('div'))).toEqual(false); + expect(React.isValidElement(Component)).toEqual(false); + expect(React.isValidElement({type: 'div', props: {}})).toEqual(false); + + const jsonElement = JSON.stringify(React.jsx('div', {})); + expect(React.isValidElement(JSON.parse(jsonElement))).toBe(true); + }); + + it('is indistinguishable from a plain object', () => { + const element = React.jsx('div', {className: 'foo'}); + const object = {}; + expect(element.constructor).toBe(object.constructor); + }); + + it('should use default prop value when removing a prop', () => { + class Component extends React.Component { + render() { + return React.jsx('span', {}); + } + } + Component.defaultProps = {fruit: 'persimmon'}; + + const container = document.createElement('div'); + const instance = ReactDOM.render( + React.jsx(Component, {fruit: 'mango'}), + container, + ); + expect(instance.props.fruit).toBe('mango'); + + ReactDOM.render(React.jsx(Component, {}), container); + expect(instance.props.fruit).toBe('persimmon'); + }); + + it('should normalize props with default values', () => { + class Component extends React.Component { + render() { + return React.jsx('span', {children: this.props.prop}); + } + } + Component.defaultProps = {prop: 'testKey'}; + + const instance = ReactTestUtils.renderIntoDocument( + React.jsx(Component, {}), + ); + expect(instance.props.prop).toBe('testKey'); + + const inst2 = ReactTestUtils.renderIntoDocument( + React.jsx(Component, {prop: null}), + ); + expect(inst2.props.prop).toBe(null); + }); + + it('throws when changing a prop (in dev) after element creation', () => { + class Outer extends React.Component { + render() { + const el = React.jsx('div', {className: 'moo'}); + + if (__DEV__) { + expect(function() { + el.props.className = 'quack'; + }).toThrow(); + expect(el.props.className).toBe('moo'); + } else { + el.props.className = 'quack'; + expect(el.props.className).toBe('quack'); + } + + return el; + } + } + const outer = ReactTestUtils.renderIntoDocument( + React.jsx(Outer, {color: 'orange'}), + ); + if (__DEV__) { + expect(ReactDOM.findDOMNode(outer).className).toBe('moo'); + } else { + expect(ReactDOM.findDOMNode(outer).className).toBe('quack'); + } + }); + + it('throws when adding a prop (in dev) after element creation', () => { + const container = document.createElement('div'); + class Outer extends React.Component { + render() { + const el = React.jsx('div', {children: this.props.sound}); + + if (__DEV__) { + expect(function() { + el.props.className = 'quack'; + }).toThrow(); + expect(el.props.className).toBe(undefined); + } else { + el.props.className = 'quack'; + expect(el.props.className).toBe('quack'); + } + + return el; + } + } + Outer.defaultProps = {sound: 'meow'}; + const outer = ReactDOM.render(React.jsx(Outer, {}), container); + expect(ReactDOM.findDOMNode(outer).textContent).toBe('meow'); + if (__DEV__) { + expect(ReactDOM.findDOMNode(outer).className).toBe(''); + } else { + expect(ReactDOM.findDOMNode(outer).className).toBe('quack'); + } + }); + + it('does not warn for NaN props', () => { + class Test extends React.Component { + render() { + return React.jsx('div', {}); + } + } + const test = ReactTestUtils.renderIntoDocument( + React.jsx(Test, {value: +undefined}), + ); + expect(test.props.value).toBeNaN(); + }); + + it('should warn when `key` is being accessed on composite element', () => { + const container = document.createElement('div'); + class Child extends React.Component { + render() { + return React.jsx('div', {children: this.props.key}); + } + } + class Parent extends React.Component { + render() { + return React.jsxs('div', { + children: [ + React.jsx(Child, {}, '0'), + React.jsx(Child, {}, '1'), + React.jsx(Child, {}, '2'), + ], + }); + } + } + expect(() => ReactDOM.render(React.jsx(Parent, {}), container)).toWarnDev( + 'Child: `key` is not a prop. Trying to access it will result ' + + 'in `undefined` being returned. If you need to access the same ' + + 'value within the child component, you should pass it as a different ' + + 'prop. (https://fb.me/react-special-props)', + {withoutStack: true}, + ); + }); + + it('should warn when `key` is being accessed on a host element', () => { + const element = React.jsxs('div', {}, '3'); + expect(() => void element.props.key).toWarnDev( + 'div: `key` is not a prop. Trying to access it will result ' + + 'in `undefined` being returned. If you need to access the same ' + + 'value within the child component, you should pass it as a different ' + + 'prop. (https://fb.me/react-special-props)', + {withoutStack: true}, + ); + }); + + it('should warn when `ref` is being accessed', () => { + const container = document.createElement('div'); + class Child extends React.Component { + render() { + return React.jsx('div', {children: this.props.ref}); + } + } + class Parent extends React.Component { + render() { + return React.jsx('div', { + children: React.jsx(Child, {ref: 'childElement'}), + }); + } + } + expect(() => ReactDOM.render(React.jsx(Parent, {}), container)).toWarnDev( + 'Child: `ref` is not a prop. Trying to access it will result ' + + 'in `undefined` being returned. If you need to access the same ' + + 'value within the child component, you should pass it as a different ' + + 'prop. (https://fb.me/react-special-props)', + {withoutStack: true}, + ); + }); + + it('identifies elements, but not JSON, if Symbols are supported', () => { + // Rudimentary polyfill + // Once all jest engines support Symbols natively we can swap this to test + // WITH native Symbols by default. + const REACT_ELEMENT_TYPE = function() {}; // fake Symbol + const OTHER_SYMBOL = function() {}; // another fake Symbol + global.Symbol = function(name) { + return OTHER_SYMBOL; + }; + global.Symbol.for = function(key) { + if (key === 'react.element') { + return REACT_ELEMENT_TYPE; + } + return OTHER_SYMBOL; + }; + + jest.resetModules(); + + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableJSXTransformAPI = true; + + React = require('react'); + + class Component extends React.Component { + render() { + return React.jsx('div'); + } + } + + expect(React.isValidElement(React.jsx('div', {}))).toEqual(true); + expect(React.isValidElement(React.jsx(Component, {}))).toEqual(true); + + expect(React.isValidElement(null)).toEqual(false); + expect(React.isValidElement(true)).toEqual(false); + expect(React.isValidElement({})).toEqual(false); + expect(React.isValidElement('string')).toEqual(false); + expect(React.isValidElement(React.createFactory('div'))).toEqual(false); + expect(React.isValidElement(Component)).toEqual(false); + expect(React.isValidElement({type: 'div', props: {}})).toEqual(false); + + const jsonElement = JSON.stringify(React.jsx('div', {})); + expect(React.isValidElement(JSON.parse(jsonElement))).toBe(false); + }); + + it('should warn when unkeyed children are passed to jsx', () => { + const container = document.createElement('div'); + class Child extends React.Component { + render() { + return React.jsx('div', {}); + } + } + class Parent extends React.Component { + render() { + return React.jsx('div', { + children: [ + React.jsx(Child, {}), + React.jsx(Child, {}), + React.jsx(Child, {}), + ], + }); + } + } + expect(() => ReactDOM.render(React.jsx(Parent, {}), container)).toWarnDev( + 'Warning: Each child in a list should have a unique "key" prop.\n\n' + + 'Check the render method of `Parent`. See https://fb.me/react-warning-keys for more information.\n' + + ' in Child (created by Parent)\n' + + ' in Parent', + ); + }); + + it('should warn when keys are passed as part of props', () => { + const container = document.createElement('div'); + class Child extends React.Component { + render() { + return React.jsx('div', {}); + } + } + class Parent extends React.Component { + render() { + return React.jsx('div', { + children: [React.jsx(Child, {key: '0'})], + }); + } + } + expect(() => ReactDOM.render(React.jsx(Parent, {}), container)).toWarnDev( + 'Warning: React.jsx: Spreading a key to JSX is a deprecated pattern. ' + + 'Explicitly pass a key after spreading props in your JSX call. ' + + 'E.g. ', + ); + }); + + it('should not warn when unkeyed children are passed to jsxs', () => { + const container = document.createElement('div'); + class Child extends React.Component { + render() { + return React.jsx('div', {}); + } + } + class Parent extends React.Component { + render() { + return React.jsxs('div', { + children: [ + React.jsx(Child, {}), + React.jsx(Child, {}), + React.jsx(Child, {}), + ], + }); + } + } + // TODO: an explicit expect for no warning? + ReactDOM.render(React.jsx(Parent, {}), container); + }); +}); diff --git a/packages/react/src/__tests__/ReactProfiler-test.internal.js b/packages/react/src/__tests__/ReactProfiler-test.internal.js index 95c3b54770db4..e894f2c3173e4 100644 --- a/packages/react/src/__tests__/ReactProfiler-test.internal.js +++ b/packages/react/src/__tests__/ReactProfiler-test.internal.js @@ -12,7 +12,6 @@ let React; let ReactFeatureFlags; -let enableNewScheduler; let ReactNoop; let Scheduler; let ReactCache; @@ -36,7 +35,6 @@ function loadModules({ ReactFeatureFlags.enableProfilerTimer = enableProfilerTimer; ReactFeatureFlags.enableSchedulerTracing = enableSchedulerTracing; ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = replayFailedUnitOfWorkWithInvokeGuardedCallback; - enableNewScheduler = ReactFeatureFlags.enableNewScheduler; React = require('react'); Scheduler = require('scheduler'); @@ -1354,9 +1352,7 @@ describe('Profiler', () => { }, ); }).toThrow('Expected error onWorkScheduled'); - if (enableNewScheduler) { - expect(Scheduler).toFlushAndYield(['Component:fail']); - } + expect(Scheduler).toFlushAndYield(['Component:fail']); throwInOnWorkScheduled = false; expect(onWorkScheduled).toHaveBeenCalled(); @@ -1391,14 +1387,10 @@ describe('Profiler', () => { // Errors that happen inside of a subscriber should throw, throwInOnWorkStarted = true; expect(Scheduler).toFlushAndThrow('Expected error onWorkStarted'); - if (enableNewScheduler) { - // Rendering was interrupted by the error that was thrown - expect(Scheduler).toHaveYielded([]); - // Rendering continues in the next task - expect(Scheduler).toFlushAndYield(['Component:text']); - } else { - expect(Scheduler).toHaveYielded(['Component:text']); - } + // Rendering was interrupted by the error that was thrown + expect(Scheduler).toHaveYielded([]); + // Rendering continues in the next task + expect(Scheduler).toFlushAndYield(['Component:text']); throwInOnWorkStarted = false; expect(onWorkStarted).toHaveBeenCalled(); @@ -2389,16 +2381,8 @@ describe('Profiler', () => { jest.runAllTimers(); await resourcePromise; - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [loaded]']); - expect(Scheduler).toFlushExpired(['AsyncText [loaded]']); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [loaded]', - 'AsyncText [loaded]', - ]); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [loaded]']); + expect(Scheduler).toFlushExpired(['AsyncText [loaded]']); expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); expect( onInteractionScheduledWorkCompleted, @@ -2454,9 +2438,7 @@ describe('Profiler', () => { await resourcePromise; expect(Scheduler).toHaveYielded(['Promise resolved [loaded]']); - if (enableNewScheduler) { - expect(Scheduler).toFlushExpired([]); - } + expect(Scheduler).toFlushExpired([]); expect(onInteractionScheduledWorkCompleted).not.toHaveBeenCalled(); @@ -2631,16 +2613,8 @@ describe('Profiler', () => { jest.advanceTimersByTime(100); await originalPromise; - if (enableNewScheduler) { - expect(Scheduler).toHaveYielded(['Promise resolved [loaded]']); - expect(Scheduler).toFlushExpired(['AsyncText [loaded]']); - } else { - expect(Scheduler).toHaveYielded([ - 'Promise resolved [loaded]', - 'AsyncText [loaded]', - ]); - } - + expect(Scheduler).toHaveYielded(['Promise resolved [loaded]']); + expect(Scheduler).toFlushExpired(['AsyncText [loaded]']); expect(renderer.toJSON()).toEqual(['loaded', 'updated']); expect(onRender).toHaveBeenCalledTimes(1); diff --git a/packages/shared/HostConfigWithNoHydration.js b/packages/shared/HostConfigWithNoHydration.js index 1be5f0b8a987d..adc976a849bac 100644 --- a/packages/shared/HostConfigWithNoHydration.js +++ b/packages/shared/HostConfigWithNoHydration.js @@ -47,3 +47,5 @@ export const didNotFindHydratableContainerSuspenseInstance = shim; export const didNotFindHydratableInstance = shim; export const didNotFindHydratableTextInstance = shim; export const didNotFindHydratableSuspenseInstance = shim; +export const canHydrateTouchHitTargetInstance = shim; +export const hydrateTouchHitTargetInstance = shim; diff --git a/packages/shared/HostConfigWithNoPersistence.js b/packages/shared/HostConfigWithNoPersistence.js index d5f84cf43fd6d..9646c6a11f48b 100644 --- a/packages/shared/HostConfigWithNoPersistence.js +++ b/packages/shared/HostConfigWithNoPersistence.js @@ -30,3 +30,4 @@ export const finalizeContainerChildren = shim; export const replaceContainerChildren = shim; export const cloneHiddenInstance = shim; export const cloneHiddenTextInstance = shim; +export const cloneHiddenTouchHitTargetInstance = shim; diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 990dedf247649..398aa209707d4 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -65,6 +65,5 @@ export const warnAboutDeprecatedSetNativeProps = false; // Experimental React Events support. Only used in www builds for now. export const enableEventAPI = false; -// Enables rewritten version of ReactFiberScheduler. Added in case we need to -// quickly revert it. -export const enableNewScheduler = false; +// New API for JSX transforms to target - https://github.com/reactjs/rfcs/pull/107 +export const enableJSXTransformAPI = false; diff --git a/packages/shared/ReactSymbols.js b/packages/shared/ReactSymbols.js index cde9f89c5b463..152e360773da0 100644 --- a/packages/shared/ReactSymbols.js +++ b/packages/shared/ReactSymbols.js @@ -57,6 +57,12 @@ export const REACT_EVENT_TARGET_TYPE = hasSymbol export const REACT_EVENT_TARGET_TOUCH_HIT = hasSymbol ? Symbol.for('react.event_target.touch_hit') : 0xead7; +export const REACT_EVENT_FOCUS_TARGET = hasSymbol + ? Symbol.for('react.event_target.focus') + : 0xead8; +export const REACT_EVENT_PRESS_TARGET = hasSymbol + ? Symbol.for('react.event_target.press') + : 0xead9; const MAYBE_ITERATOR_SYMBOL = typeof Symbol === 'function' && Symbol.iterator; const FAUX_ITERATOR_SYMBOL = '@@iterator'; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 52ce27f9dfb10..cc6a9c3473781 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -87,10 +87,33 @@ export type ReactEventResponderEventType = export type ReactEventResponder = { targetEventTypes: Array, - createInitialState?: (props: Object) => Object, - handleEvent: (context: Object, props: Object, state: Object) => void, + createInitialState?: (props: null | Object) => Object, + onEvent: ( + event: ReactResponderEvent, + context: ReactResponderContext, + props: null | Object, + state: null | Object, + ) => void, + onUnmount: ( + context: ReactResponderContext, + props: null | Object, + state: null | Object, + ) => void, + onOwnershipChange: ( + context: ReactResponderContext, + props: null | Object, + state: null | Object, + ) => void, }; +export type ReactEventComponentInstance = {| + context: null | Object, + props: null | Object, + responder: ReactEventResponder, + rootInstance: mixed, + state: null | Object, +|}; + export type ReactEventComponent = {| $$typeof: Symbol | number, displayName?: string, @@ -103,3 +126,57 @@ export type ReactEventTarget = {| displayName?: string, type: Symbol | number, |}; + +type AnyNativeEvent = Event | KeyboardEvent | MouseEvent | Touch; + +export type ReactResponderEvent = { + nativeEvent: AnyNativeEvent, + target: Element | Document, + type: string, + passive: boolean, + passiveSupported: boolean, +}; + +export type ReactResponderDispatchEventOptions = { + capture?: boolean, + discrete?: boolean, +}; + +export type ReactResponderContext = { + dispatchEvent: ( + eventObject: Object, + listener: (Object) => void, + otpions: ReactResponderDispatchEventOptions, + ) => void, + dispatchStopPropagation: (passive?: boolean) => void, + isTargetWithinElement: ( + childTarget: Element | Document, + parentTarget: Element | Document, + ) => boolean, + isTargetWithinEventComponent: (Element | Document) => boolean, + isPositionWithinTouchHitTarget: ( + doc: Document, + x: number, + y: number, + ) => boolean, + addRootEventTypes: ( + document: Document, + rootEventTypes: Array, + ) => void, + removeRootEventTypes: ( + rootEventTypes: Array, + ) => void, + hasOwnership: () => boolean, + requestOwnership: () => boolean, + releaseOwnership: () => boolean, + setTimeout: (func: () => void, timeout: number) => Symbol, + clearTimeout: (timerId: Symbol) => void, + getEventTargetsFromTarget: ( + target: Element | Document, + queryType?: Symbol | number, + queryKey?: string, + ) => Array<{ + node: Element, + props: null | Object, + }>, +}; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 658f7781ff313..285ea06bbc64f 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -31,7 +31,7 @@ export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; export const warnAboutDeprecatedLifecycles = true; export const warnAboutDeprecatedSetNativeProps = true; export const enableEventAPI = false; -export const enableNewScheduler = false; +export const enableJSXTransformAPI = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 38e036682caac..60f29acdc7797 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -28,7 +28,7 @@ export const warnAboutShorthandPropertyCollision = false; export const enableSchedulerDebugging = false; export const warnAboutDeprecatedSetNativeProps = false; export const enableEventAPI = false; -export const enableNewScheduler = false; +export const enableJSXTransformAPI = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.new-scheduler.js b/packages/shared/forks/ReactFeatureFlags.new-scheduler.js deleted file mode 100644 index a2a3bfdfe786d..0000000000000 --- a/packages/shared/forks/ReactFeatureFlags.new-scheduler.js +++ /dev/null @@ -1,30 +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. - * - * @flow strict - */ - -export const enableUserTimingAPI = __DEV__; -export const debugRenderPhaseSideEffects = false; -export const debugRenderPhaseSideEffectsForStrictMode = __DEV__; -export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; -export const warnAboutDeprecatedLifecycles = true; -export const enableProfilerTimer = __PROFILE__; -export const enableSchedulerTracing = __PROFILE__; -export const enableSuspenseServerRenderer = false; // TODO: __DEV__? Here it might just be false. -export const enableSchedulerDebugging = false; -export function addUserTimingListener() { - throw new Error('Not implemented.'); -} -export const disableJavaScriptURLs = false; -export const disableYielding = false; -export const disableInputAttributeSyncing = false; -export const enableStableConcurrentModeAPIs = false; -export const warnAboutShorthandPropertyCollision = false; -export const warnAboutDeprecatedSetNativeProps = false; -export const enableEventAPI = false; - -export const enableNewScheduler = true; diff --git a/packages/shared/forks/ReactFeatureFlags.persistent.js b/packages/shared/forks/ReactFeatureFlags.persistent.js index e5dc81c58b32d..14b8716b96342 100644 --- a/packages/shared/forks/ReactFeatureFlags.persistent.js +++ b/packages/shared/forks/ReactFeatureFlags.persistent.js @@ -28,7 +28,7 @@ export const warnAboutShorthandPropertyCollision = false; export const enableSchedulerDebugging = false; export const warnAboutDeprecatedSetNativeProps = false; export const enableEventAPI = false; -export const enableNewScheduler = false; +export const enableJSXTransformAPI = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 08fe309d20b79..40c982f3e7cc3 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -28,7 +28,7 @@ export const warnAboutShorthandPropertyCollision = false; export const enableSchedulerDebugging = false; export const warnAboutDeprecatedSetNativeProps = false; export const enableEventAPI = false; -export const enableNewScheduler = false; +export const enableJSXTransformAPI = false; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 97fc3164eb68c..f6f80c8985350 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -26,7 +26,7 @@ export const warnAboutDeprecatedSetNativeProps = false; export const disableJavaScriptURLs = false; export const disableYielding = false; export const enableEventAPI = true; -export const enableNewScheduler = false; +export const enableJSXTransformAPI = true; // Only used in www builds. export function addUserTimingListener() { diff --git a/packages/shared/forks/ReactFeatureFlags.www-new-scheduler.js b/packages/shared/forks/ReactFeatureFlags.www-new-scheduler.js deleted file mode 100644 index 0d87b53fe3bd9..0000000000000 --- a/packages/shared/forks/ReactFeatureFlags.www-new-scheduler.js +++ /dev/null @@ -1,39 +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. - * - * @flow - */ - -import typeof * as FeatureFlagsType from 'shared/ReactFeatureFlags'; -import typeof * as FeatureFlagsShimType from './ReactFeatureFlags.www-new-scheduler'; - -export { - enableUserTimingAPI, - debugRenderPhaseSideEffects, - debugRenderPhaseSideEffectsForStrictMode, - replayFailedUnitOfWorkWithInvokeGuardedCallback, - warnAboutDeprecatedLifecycles, - enableProfilerTimer, - enableSchedulerTracing, - enableSuspenseServerRenderer, - enableSchedulerDebugging, - addUserTimingListener, - disableJavaScriptURLs, - disableYielding, - disableInputAttributeSyncing, - enableStableConcurrentModeAPIs, - warnAboutShorthandPropertyCollision, - warnAboutDeprecatedSetNativeProps, - enableEventAPI, -} from './ReactFeatureFlags.www'; - -export const enableNewScheduler = true; - -// Flow magic to verify the exports of this file match the original version. -// eslint-disable-next-line no-unused-vars -type Check<_X, Y: _X, X: Y = _X> = null; -// eslint-disable-next-line no-unused-expressions -(null: Check); diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index a5049339166f3..0be35ad2d9f4a 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -40,11 +40,6 @@ export const enableSuspenseServerRenderer = true; export const disableJavaScriptURLs = true; -// I've chosen to make this a static flag instead of a dynamic flag controlled -// by a GK so that it doesn't increase bundle size. It should still be easy -// to rollback by reverting the commit that turns this on. -export const enableNewScheduler = false; - let refCount = 0; export function addUserTimingListener() { if (__DEV__) { @@ -74,6 +69,8 @@ function updateFlagOutsideOfReactCallStack() { export const enableEventAPI = true; +export const enableJSXTransformAPI = true; + // Flow magic to verify the exports of this file match the original version. // eslint-disable-next-line no-unused-vars type Check<_X, Y: _X, X: Y = _X> = null; diff --git a/packages/shared/getComponentName.js b/packages/shared/getComponentName.js index cfa07d91e1d13..5b09f0c69bcb1 100644 --- a/packages/shared/getComponentName.js +++ b/packages/shared/getComponentName.js @@ -25,10 +25,14 @@ import { REACT_EVENT_COMPONENT_TYPE, REACT_EVENT_TARGET_TYPE, REACT_EVENT_TARGET_TOUCH_HIT, + REACT_EVENT_FOCUS_TARGET, + REACT_EVENT_PRESS_TARGET, } from 'shared/ReactSymbols'; import {refineResolvedLazyComponent} from 'shared/ReactLazyComponent'; import type {ReactEventComponent, ReactEventTarget} from 'shared/ReactTypes'; +import {enableEventAPI} from './ReactFeatureFlags'; + function getWrappedName( outerType: mixed, innerType: any, @@ -94,21 +98,29 @@ function getComponentName(type: mixed): string | null { break; } case REACT_EVENT_COMPONENT_TYPE: { - const eventComponent = ((type: any): ReactEventComponent); - const displayName = eventComponent.displayName; - if (displayName !== undefined) { - return displayName; + if (enableEventAPI) { + const eventComponent = ((type: any): ReactEventComponent); + const displayName = eventComponent.displayName; + if (displayName !== undefined) { + return displayName; + } } break; } case REACT_EVENT_TARGET_TYPE: { - const eventTarget = ((type: any): ReactEventTarget); - if (eventTarget.type === REACT_EVENT_TARGET_TOUCH_HIT) { - return 'TouchHitTarget'; - } - const displayName = eventTarget.displayName; - if (displayName !== undefined) { - return displayName; + if (enableEventAPI) { + const eventTarget = ((type: any): ReactEventTarget); + if (eventTarget.type === REACT_EVENT_TARGET_TOUCH_HIT) { + return 'TouchHitTarget'; + } else if (eventTarget.type === REACT_EVENT_FOCUS_TARGET) { + return 'FocusTarget'; + } else if (eventTarget.type === REACT_EVENT_PRESS_TARGET) { + return 'PressTarget'; + } + const displayName = eventTarget.displayName; + if (displayName !== undefined) { + return displayName; + } } } } diff --git a/scripts/circleci/build.sh b/scripts/circleci/build.sh index 70cb78f240320..f6fae55e870f8 100755 --- a/scripts/circleci/build.sh +++ b/scripts/circleci/build.sh @@ -1,17 +1,6 @@ -#!/bin/bash - -set -e +#!/bin/bash -# On master, download the bundle sizes from last master build so that -# the size printed in the CI logs for master commits is accurate. -# We don't do it for pull requests because those are compared against -# the merge base by Dangerfile instead. See https://github.com/facebook/react/pull/12606. -if [ -z "$CI_PULL_REQUEST" ]; then - curl -o scripts/rollup/results.json http://react.zpao.com/builds/master/latest/results.json -else - # If build fails, cause danger to fail/abort too - rm scripts/rollup/results.json -fi +set -e yarn build --extract-errors # Note: since we run the full build including extracting error codes, diff --git a/scripts/circleci/pack_and_store_artifact.sh b/scripts/circleci/pack_and_store_artifact.sh index 0475feeba229f..061101b6fdec2 100755 --- a/scripts/circleci/pack_and_store_artifact.sh +++ b/scripts/circleci/pack_and_store_artifact.sh @@ -2,10 +2,14 @@ set -e +# Compress build directory into a single tarball for easy download +tar -zcvf ./build.tgz ./build + # NPM pack all modules to ensure we archive the correct set of files -for dir in ./build/node_modules/* ; do +cd ./build/node_modules +for dir in ./* ; do npm pack "$dir" done -# Wrap everything in a single zip file for easy download by the publish script -tar -zcvf ./node_modules.tgz ./*.tgz \ No newline at end of file +# Compress packed modules into a single tarball for easy download by the publish script +tar -zcvf ../../node_modules.tgz ./*.tgz diff --git a/scripts/circleci/test_entry_point.sh b/scripts/circleci/test_entry_point.sh index 6227b7c5dcbd0..87bbad4aba9e8 100755 --- a/scripts/circleci/test_entry_point.sh +++ b/scripts/circleci/test_entry_point.sh @@ -11,7 +11,6 @@ if [ $((0 % CIRCLE_NODE_TOTAL)) -eq "$CIRCLE_NODE_INDEX" ]; then COMMANDS_TO_RUN+=('node ./scripts/tasks/flow-ci') COMMANDS_TO_RUN+=('node ./scripts/tasks/eslint') COMMANDS_TO_RUN+=('yarn test --maxWorkers=2') - COMMANDS_TO_RUN+=('yarn test-new-scheduler --maxWorkers=2') COMMANDS_TO_RUN+=('yarn test-persistent --maxWorkers=2') COMMANDS_TO_RUN+=('./scripts/circleci/check_license.sh') COMMANDS_TO_RUN+=('./scripts/circleci/check_modules.sh') diff --git a/scripts/error-codes/README.md b/scripts/error-codes/README.md index 2f39c9097ee1e..27d9067cd299a 100644 --- a/scripts/error-codes/README.md +++ b/scripts/error-codes/README.md @@ -12,6 +12,6 @@ provide a better debugging support in production. Check out the blog post can test it by running `yarn build -- --extract-errors`, but you should only commit changes to this file when running a release. (The release tool will perform this step automatically.) -- [`minify-error-codes`](https://github.com/facebook/react/blob/master/scripts/error-codes/minify-error-codes) +- [`transform-error-messages`](https://github.com/facebook/react/blob/master/scripts/error-codes/transform-error-messages) is a Babel pass that rewrites error messages to IDs for a production (minified) build. diff --git a/scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap b/scripts/error-codes/__tests__/__snapshots__/transform-error-messages.js.snap similarity index 90% rename from scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap rename to scripts/error-codes/__tests__/__snapshots__/transform-error-messages.js.snap index 5227bf089c455..54eb6ec99dbca 100644 --- a/scripts/error-codes/__tests__/__snapshots__/minify-error-messages.js.snap +++ b/scripts/error-codes/__tests__/__snapshots__/transform-error-messages.js.snap @@ -94,3 +94,14 @@ import invariant from 'shared/invariant'; } })();" `; + +exports[`error transform should support noMinify option 1`] = ` +"import _ReactError from 'shared/ReactError'; + +import invariant from 'shared/invariant'; +(function () { + if (!condition) { + throw _ReactError(\`Do not override existing functions.\`); + } +})();" +`; diff --git a/scripts/error-codes/__tests__/minify-error-messages.js b/scripts/error-codes/__tests__/transform-error-messages.js similarity index 82% rename from scripts/error-codes/__tests__/minify-error-messages.js rename to scripts/error-codes/__tests__/transform-error-messages.js index f9a799af4528d..f7caa79c30689 100644 --- a/scripts/error-codes/__tests__/minify-error-messages.js +++ b/scripts/error-codes/__tests__/transform-error-messages.js @@ -8,11 +8,11 @@ 'use strict'; let babel = require('babel-core'); -let devExpressionWithCodes = require('../minify-error-messages'); +let devExpressionWithCodes = require('../transform-error-messages'); -function transform(input) { +function transform(input, options = {}) { return babel.transform(input, { - plugins: [devExpressionWithCodes], + plugins: [[devExpressionWithCodes, options]], }).code; } @@ -82,4 +82,16 @@ invariant(condition, 'What\\'s up?'); `) ).toMatchSnapshot(); }); + + it('should support noMinify option', () => { + expect( + transform( + ` +import invariant from 'shared/invariant'; +invariant(condition, 'Do not override existing functions.'); +`, + {noMinify: true} + ) + ).toMatchSnapshot(); + }); }); diff --git a/scripts/error-codes/minify-error-messages.js b/scripts/error-codes/transform-error-messages.js similarity index 97% rename from scripts/error-codes/minify-error-messages.js rename to scripts/error-codes/transform-error-messages.js index 8a55ce4d4bbc5..d83fdc4a59848 100644 --- a/scripts/error-codes/minify-error-messages.js +++ b/scripts/error-codes/transform-error-messages.js @@ -19,6 +19,7 @@ module.exports = function(babel) { visitor: { CallExpression(path, file) { const node = path.node; + const noMinify = file.opts.noMinify; if (path.get('callee').isIdentifier({name: 'invariant'})) { // Turns this code: // @@ -66,7 +67,7 @@ module.exports = function(babel) { const errorMap = invertObject(existingErrorMap); let prodErrorId = errorMap[errorMsgLiteral]; - if (prodErrorId === undefined) { + if (prodErrorId === undefined || noMinify) { // There is no error code for this message. We use a lint rule to // enforce that messages can be minified, so assume this is // intentional and exit gracefully. diff --git a/scripts/flow/react-native-host-hooks.js b/scripts/flow/react-native-host-hooks.js index 91a17955104ea..414775f48f4c5 100644 --- a/scripts/flow/react-native-host-hooks.js +++ b/scripts/flow/react-native-host-hooks.js @@ -10,6 +10,9 @@ /* eslint-disable */ import type { + MeasureOnSuccessCallback, + MeasureInWindowOnSuccessCallback, + MeasureLayoutOnSuccessCallback, ReactNativeBaseComponentViewConfig, ViewConfigGetter, } from 'react-native-renderer/src/ReactNativeTypes'; @@ -124,6 +127,21 @@ declare module 'FabricUIManager' { payload: Object, ) => void, ): void; + + declare function measure( + node: Node, + callback: MeasureOnSuccessCallback, + ): void; + declare function measureInWindow( + node: Node, + callback: MeasureInWindowOnSuccessCallback, + ): void; + declare function measureLayout( + node: Node, + relativeNode: Node, + onFail: () => void, + onSuccess: MeasureLayoutOnSuccessCallback, + ): void; } declare module 'View' { diff --git a/scripts/jest/config.source-new-scheduler.js b/scripts/jest/config.source-new-scheduler.js deleted file mode 100644 index 6d74d5bb1b0fa..0000000000000 --- a/scripts/jest/config.source-new-scheduler.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; - -const baseConfig = require('./config.base'); - -module.exports = Object.assign({}, baseConfig, { - setupFiles: [ - ...baseConfig.setupFiles, - require.resolve('./setupNewScheduler.js'), - require.resolve('./setupHostConfigs.js'), - ], -}); diff --git a/scripts/jest/preprocessor.js b/scripts/jest/preprocessor.js index 3a7b1448e505b..d35f965499672 100644 --- a/scripts/jest/preprocessor.js +++ b/scripts/jest/preprocessor.js @@ -15,7 +15,7 @@ const pathToBabel = path.join( 'package.json' ); const pathToBabelPluginDevWithCode = require.resolve( - '../error-codes/minify-error-messages' + '../error-codes/transform-error-messages' ); const pathToBabelPluginWrapWarning = require.resolve( '../babel/wrap-warning-with-env-check' diff --git a/scripts/jest/setupNewScheduler.js b/scripts/jest/setupNewScheduler.js deleted file mode 100644 index d3d58bd5653db..0000000000000 --- a/scripts/jest/setupNewScheduler.js +++ /dev/null @@ -1,7 +0,0 @@ -'use strict'; - -jest.mock('shared/ReactFeatureFlags', () => { - const ReactFeatureFlags = require.requireActual('shared/ReactFeatureFlags'); - ReactFeatureFlags.enableNewScheduler = true; - return ReactFeatureFlags; -}); diff --git a/scripts/rollup/build.js b/scripts/rollup/build.js index d21e58599ece4..486531ec99c42 100644 --- a/scripts/rollup/build.js +++ b/scripts/rollup/build.js @@ -113,7 +113,7 @@ function getBabelConfig(updateBabelOptions, bundleType, filename) { return Object.assign({}, options, { plugins: options.plugins.concat([ // Minify invariant messages - require('../error-codes/minify-error-messages'), + require('../error-codes/transform-error-messages'), // Wrap warning() calls in a __DEV__ check so they are stripped from production. require('../babel/wrap-warning-with-env-check'), ]), @@ -126,6 +126,11 @@ function getBabelConfig(updateBabelOptions, bundleType, filename) { case RN_FB_PROFILING: return Object.assign({}, options, { plugins: options.plugins.concat([ + [ + require('../error-codes/transform-error-messages'), + // Preserve full error messages in React Native build + {noMinify: true}, + ], // Wrap warning() calls in a __DEV__ check so they are stripped from production. require('../babel/wrap-warning-with-env-check'), ]), @@ -141,7 +146,7 @@ function getBabelConfig(updateBabelOptions, bundleType, filename) { // Use object-assign polyfill in open source path.resolve('./scripts/babel/transform-object-assign-require'), // Minify invariant messages - require('../error-codes/minify-error-messages'), + require('../error-codes/transform-error-messages'), // Wrap warning() calls in a __DEV__ check so they are stripped from production. require('../babel/wrap-warning-with-env-check'), ]), diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index e2e59d2c4092e..47005d3c81338 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -110,22 +110,6 @@ const bundles = [ externals: ['react'], }, - /******* React DOM (new scheduler) *******/ - { - bundleTypes: [ - FB_WWW_DEV, - FB_WWW_PROD, - FB_WWW_PROFILING, - NODE_DEV, - NODE_PROD, - NODE_PROFILING, - ], - moduleType: RENDERER, - entry: 'react-dom/unstable-new-scheduler', - global: 'ReactDOMNewScheduler', - externals: ['react'], - }, - /******* Test Utils *******/ { moduleType: RENDERER_UTILS, @@ -224,6 +208,7 @@ const bundles = [ 'RCTEventEmitter', 'TextInputState', 'UIManager', + 'FabricUIManager', 'deepDiffer', 'deepFreezeAndThrowOnMutationInDev', 'flattenStyle', @@ -243,6 +228,7 @@ const bundles = [ 'RCTEventEmitter', 'TextInputState', 'UIManager', + 'FabricUIManager', 'deepDiffer', 'deepFreezeAndThrowOnMutationInDev', 'flattenStyle', diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index 45c1fd411f178..c93ac87559aaa 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -7,9 +7,6 @@ const inlinedHostConfigs = require('../shared/inlinedHostConfigs'); const UMD_DEV = bundleTypes.UMD_DEV; const UMD_PROD = bundleTypes.UMD_PROD; const UMD_PROFILING = bundleTypes.UMD_PROFILING; -const NODE_DEV = bundleTypes.NODE_DEV; -const NODE_PROD = bundleTypes.NODE_PROD; -const NODE_PROFILING = bundleTypes.NODE_PROFILING; const FB_WWW_DEV = bundleTypes.FB_WWW_DEV; const FB_WWW_PROD = bundleTypes.FB_WWW_PROD; const FB_WWW_PROFILING = bundleTypes.FB_WWW_PROFILING; @@ -71,22 +68,6 @@ const forks = Object.freeze({ // We have a few forks for different environments. 'shared/ReactFeatureFlags': (bundleType, entry) => { switch (entry) { - case 'react-dom/unstable-new-scheduler': { - switch (bundleType) { - case FB_WWW_DEV: - case FB_WWW_PROD: - case FB_WWW_PROFILING: - return 'shared/forks/ReactFeatureFlags.www-new-scheduler.js'; - case NODE_DEV: - case NODE_PROD: - case NODE_PROFILING: - return 'shared/forks/ReactFeatureFlags.new-scheduler.js'; - default: - throw Error( - `Unexpected entry (${entry}) and bundleType (${bundleType})` - ); - } - } case 'react-native-renderer': switch (bundleType) { case RN_FB_DEV: diff --git a/scripts/rollup/results.json b/scripts/rollup/results.json index c858da46fff87..6849341c1c03c 100644 --- a/scripts/rollup/results.json +++ b/scripts/rollup/results.json @@ -578,57 +578,57 @@ "filename": "ReactNativeRenderer-dev.js", "bundleType": "RN_FB_DEV", "packageName": "react-native-renderer", - "size": 645983, - "gzip": 137694 + "size": 720540, + "gzip": 154199 }, { "filename": "ReactNativeRenderer-prod.js", "bundleType": "RN_FB_PROD", "packageName": "react-native-renderer", - "size": 252030, - "gzip": 44064 + "size": 252865, + "gzip": 44240 }, { "filename": "ReactNativeRenderer-dev.js", "bundleType": "RN_OSS_DEV", "packageName": "react-native-renderer", - "size": 645895, - "gzip": 137660 + "size": 720452, + "gzip": 154169 }, { "filename": "ReactNativeRenderer-prod.js", "bundleType": "RN_OSS_PROD", "packageName": "react-native-renderer", - "size": 252044, - "gzip": 44061 + "size": 252879, + "gzip": 44238 }, { "filename": "ReactFabric-dev.js", "bundleType": "RN_FB_DEV", "packageName": "react-native-renderer", - "size": 634566, - "gzip": 134983 + "size": 709123, + "gzip": 151511 }, { "filename": "ReactFabric-prod.js", "bundleType": "RN_FB_PROD", "packageName": "react-native-renderer", - "size": 245276, - "gzip": 42773 + "size": 246002, + "gzip": 42956 }, { "filename": "ReactFabric-dev.js", "bundleType": "RN_OSS_DEV", "packageName": "react-native-renderer", - "size": 634470, - "gzip": 134930 + "size": 709027, + "gzip": 151463 }, { "filename": "ReactFabric-prod.js", "bundleType": "RN_OSS_PROD", "packageName": "react-native-renderer", - "size": 245282, - "gzip": 42767 + "size": 246008, + "gzip": 42950 }, { "filename": "ReactTestRenderer-dev.js", @@ -725,15 +725,15 @@ "filename": "ReactNativeRenderer-profiling.js", "bundleType": "RN_OSS_PROFILING", "packageName": "react-native-renderer", - "size": 258447, - "gzip": 45443 + "size": 259040, + "gzip": 45588 }, { "filename": "ReactFabric-profiling.js", "bundleType": "RN_OSS_PROFILING", "packageName": "react-native-renderer", - "size": 250755, - "gzip": 44122 + "size": 251432, + "gzip": 44320 }, { "filename": "Scheduler-dev.js", @@ -774,15 +774,15 @@ "filename": "ReactNativeRenderer-profiling.js", "bundleType": "RN_FB_PROFILING", "packageName": "react-native-renderer", - "size": 258428, - "gzip": 45445 + "size": 259021, + "gzip": 45590 }, { "filename": "ReactFabric-profiling.js", "bundleType": "RN_FB_PROFILING", "packageName": "react-native-renderer", - "size": 250744, - "gzip": 44126 + "size": 251421, + "gzip": 44324 }, { "filename": "react.profiling.min.js", diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index 02c131a847a61..66c54963c7f28 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -9,11 +9,7 @@ module.exports = [ { shortName: 'dom', - entryPoints: [ - 'react-dom', - 'react-dom/unstable-fizz.node', - 'react-dom/unstable-new-scheduler', - ], + entryPoints: ['react-dom', 'react-dom/unstable-fizz.node'], isFlowTyped: true, isFizzSupported: true, }, diff --git a/yarn.lock b/yarn.lock index 63f2929f3668a..d1417acf58c86 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2258,40 +2258,40 @@ globby@^5.0.0: pify "^2.0.0" pinkie-promise "^2.0.0" -google-closure-compiler-java@^20190106.0.0: - version "20190106.0.0" - resolved "https://registry.yarnpkg.com/google-closure-compiler-java/-/google-closure-compiler-java-20190106.0.0.tgz#10b89c17901bece749bc6f7f5ea5cfdedb0374ff" - integrity sha512-M/mrssfSTY7CQLzW9Zc1voGHvPCxMG2MK7Y1USY9/oBHBVzYRxDac3k0icjpglPu9/uIDw4BwpKTrGYfvv3O1Q== - -google-closure-compiler-js@^20190106.0.0: - version "20190106.0.0" - resolved "https://registry.yarnpkg.com/google-closure-compiler-js/-/google-closure-compiler-js-20190106.0.0.tgz#cf630a1d290bf7dd545d614754e844d08663fc5a" - integrity sha512-9gbXqArlCvwp3FZOQO8dyyt6BZChliLuU95aseoTS/aapCfkxclBT4R6ar9hrEvu/fA4Zgpz+KPQyeOeJkUauQ== - -google-closure-compiler-linux@^20190106.0.0: - version "20190106.0.0" - resolved "https://registry.yarnpkg.com/google-closure-compiler-linux/-/google-closure-compiler-linux-20190106.0.0.tgz#512cc89768c302b7f3ebe36a45bc0f41698cabe1" - integrity sha512-rShT8RSaGbbnNAFhPL1t2BP6Mq9ayBwWPpCPgH9bLtGSH4qrmmx+V5RMaZ4gOaOlhyB/UpwB6E7E4TEG5RbJyg== - -google-closure-compiler-osx@^20190106.0.0: - version "20190106.0.0" - resolved "https://registry.yarnpkg.com/google-closure-compiler-osx/-/google-closure-compiler-osx-20190106.0.0.tgz#ee013acedf97b9135b305bb206fc0a115c088aab" - integrity sha512-yLmJfb6MnqriG7daWCGQVz4YEtHDxjKmAbEkSXMy2YkWFACgRTF0b9u3BPIP8/pX/5XmKCKWWE1d66OMIlRaqQ== - -google-closure-compiler@20190106.0.0: - version "20190106.0.0" - resolved "https://registry.yarnpkg.com/google-closure-compiler/-/google-closure-compiler-20190106.0.0.tgz#dc06d30c5ef380cde7f54b6741e58e7378186d1a" - integrity sha512-6bXgR9T9kBgs9iZAtqmLe8tmk8uF6IjqDK8sal7PQ2rDju0hRbkJPgDHvlmGlCuB1wsJNanIXHYtqHUCrcvpcw== +google-closure-compiler-java@^20190301.0.0: + version "20190301.0.0" + resolved "https://registry.yarnpkg.com/google-closure-compiler-java/-/google-closure-compiler-java-20190301.0.0.tgz#89d1d6ab04b7625daf38d63b28b557f92103e3e1" + integrity sha512-IMv77Mu1chPjSaJC1PWyKSNIvm19nSjx4oXvf67ZBLRkuPKHb3S1ECD3l71pfxNZ2+2tAXnxkEcWcREJ8ph4Tg== + +google-closure-compiler-js@^20190301.0.0: + version "20190301.0.0" + resolved "https://registry.yarnpkg.com/google-closure-compiler-js/-/google-closure-compiler-js-20190301.0.0.tgz#2b1035a13e42118386dbdf264195976d42240870" + integrity sha512-J0HVHwpGf3o5MwyifrYhfhNpD7Zznn+fktcKKmwhguKqaNbgCr1AfnaGEarej3Lx1W9CouJEm5OTRTZRJgvRHQ== + +google-closure-compiler-linux@^20190301.0.0: + version "20190301.0.0" + resolved "https://registry.yarnpkg.com/google-closure-compiler-linux/-/google-closure-compiler-linux-20190301.0.0.tgz#dfc0f564642fdfad19ba59e1ced7957fcf3ecbc4" + integrity sha512-r+47izRha1ZOHP8E5wq7YsjatzJVD0yn/7dnZA/jSJmTxoFDfEaV78PYGAgCpL8kslHHApPDFEn9Ozx2eSH2gg== + +google-closure-compiler-osx@^20190301.0.0: + version "20190301.0.0" + resolved "https://registry.yarnpkg.com/google-closure-compiler-osx/-/google-closure-compiler-osx-20190301.0.0.tgz#006c4c4eb8f5a7078b208a107ec5f204f151ead1" + integrity sha512-W/Mub4k7oKcd1XYIae0NrJysNvpiAjXhq0DCoTJaTZzkc8dGVqcvrQ/YqYNwLkUULqL1dsrYyt3jv1X6l9OqZw== + +google-closure-compiler@20190301.0.0: + version "20190301.0.0" + resolved "https://registry.yarnpkg.com/google-closure-compiler/-/google-closure-compiler-20190301.0.0.tgz#332e5b940601047a580bcf182e782f089b2c7cf2" + integrity sha512-FCtg6VsC9BhvbDLh+idMP4F3gka60KLEW0Oqw7M/vhBZnP2/aB4zzxuUDo5LOxuR+RyVqB4VyGOFnM9Z/14iVw== dependencies: chalk "^1.0.0" - google-closure-compiler-java "^20190106.0.0" - google-closure-compiler-js "^20190106.0.0" + google-closure-compiler-java "^20190301.0.0" + google-closure-compiler-js "^20190301.0.0" minimist "^1.2.0" vinyl "^2.0.1" vinyl-sourcemaps-apply "^0.2.0" optionalDependencies: - google-closure-compiler-linux "^20190106.0.0" - google-closure-compiler-osx "^20190106.0.0" + google-closure-compiler-linux "^20190301.0.0" + google-closure-compiler-osx "^20190301.0.0" graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.4: version "4.1.11" From 5761e3b5e1fe5358bc28918e3bd9170373b336ba Mon Sep 17 00:00:00 2001 From: Sunil Pai Date: Fri, 12 Apr 2019 15:31:37 +0100 Subject: [PATCH 4/5] DEV check for createContainer warnIfNotScopedWithMatchingAct check --- .../react-reconciler/src/ReactFiberReconciler.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 051b3238d1dca..5b830af78c623 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -278,13 +278,15 @@ export function createContainer( isConcurrent: boolean, hydrate: boolean, ): OpaqueRoot { - const fiberRoot = createFiberRoot(containerInfo, isConcurrent, hydrate); - // jest isn't a 'global', it's just exposed to tests via a wrapped function - // further, this isn't a test file, so flow doesn't recognize the symbol. So... - // $FlowExpectedError - because requirements don't give a damn about your type sigs. - if ('undefined' !== typeof jest) { - warnIfNotScopedWithMatchingAct(fiberRoot.current); - } + const fiberRoot = createFiberRoot(containerInfo, isConcurrent, hydrate); + if(__DEV__){ + // jest isn't a 'global', it's just exposed to tests via a wrapped function + // further, this isn't a test file, so flow doesn't recognize the symbol. So... + // $FlowExpectedError - because requirements don't give a damn about your type sigs. + if ('undefined' !== typeof jest) { + warnIfNotScopedWithMatchingAct(fiberRoot.current); + } + } return fiberRoot; } From 82282784c2c9f37a9fec2415c24a7f93ce1c95cc Mon Sep 17 00:00:00 2001 From: Sunil Pai Date: Fri, 12 Apr 2019 15:32:38 +0100 Subject: [PATCH 5/5] ugh prettier --- packages/react-reconciler/src/ReactFiberReconciler.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 5b830af78c623..49465a21748f7 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -278,15 +278,15 @@ export function createContainer( isConcurrent: boolean, hydrate: boolean, ): OpaqueRoot { - const fiberRoot = createFiberRoot(containerInfo, isConcurrent, hydrate); - if(__DEV__){ + const fiberRoot = createFiberRoot(containerInfo, isConcurrent, hydrate); + if (__DEV__) { // jest isn't a 'global', it's just exposed to tests via a wrapped function // further, this isn't a test file, so flow doesn't recognize the symbol. So... // $FlowExpectedError - because requirements don't give a damn about your type sigs. if ('undefined' !== typeof jest) { warnIfNotScopedWithMatchingAct(fiberRoot.current); - } - } + } + } return fiberRoot; }