From 6d0effad76f236a248955aefbfdc2187043fcb5e Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 3 Apr 2019 12:20:42 -0700 Subject: [PATCH 01/41] Expose extra internals in FB build of react-dom/unstable-new-scheduler (#15311) The Facebook build of React DOM uses a forked entry point that exposes additional secret internals. I didn't account for this when I added the react-dom/unstable-new-scheduler build, so the extra internals are currently missing. This commit adds them. --- packages/react-dom/unstable-new-scheduler.fb.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 packages/react-dom/unstable-new-scheduler.fb.js diff --git a/packages/react-dom/unstable-new-scheduler.fb.js b/packages/react-dom/unstable-new-scheduler.fb.js new file mode 100644 index 0000000000000..ea901748d6949 --- /dev/null +++ b/packages/react-dom/unstable-new-scheduler.fb.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const ReactDOMFB = require('./src/client/ReactDOMFB'); + +// TODO: decide on the top-level export form. +// This is hacky but makes it work with both Rollup and Jest. +module.exports = ReactDOMFB.default || ReactDOMFB; From 41aa345d2bde261c2fb4a4ef89e379640c88be67 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Wed, 3 Apr 2019 13:21:27 -0700 Subject: [PATCH 02/41] Fix a crash in Suspense with findDOMNode --- .../ReactDOMSuspensePlaceholder-test.js | 44 +++++++++++++++++++ .../src/ReactFiberTreeReflection.js | 25 ++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) 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-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js index f2c3e37b699ec..150e8200aed40 100644 --- a/packages/react-reconciler/src/ReactFiberTreeReflection.js +++ b/packages/react-reconciler/src/ReactFiberTreeReflection.js @@ -21,6 +21,8 @@ import { HostRoot, HostPortal, HostText, + Fragment, + SuspenseComponent, } from 'shared/ReactWorkTags'; import {NoEffect, Placement} from 'shared/ReactSideEffectTags'; @@ -119,8 +121,27 @@ export function findCurrentFiberUsingSlowPath(fiber: Fiber): Fiber | null { let parentA = a.return; let parentB = parentA ? parentA.alternate : null; if (!parentA || !parentB) { - // We're at the root. - break; + // We're either at the root, or we're in a special Fragment + // with no alternate, which is how Suspense (un)hiding works. + let maybeSuspenseFragment = parentA || parentB; + if (maybeSuspenseFragment && maybeSuspenseFragment.tag === Fragment) { + const maybeSuspense = maybeSuspenseFragment.return; + if ( + maybeSuspense && + maybeSuspense.tag === SuspenseComponent && + // If state isn't null, it timed out and we have two Fragment children. + maybeSuspense.memoizedState !== null + ) { + parentA = maybeSuspense; + parentB = maybeSuspense; + a = maybeSuspenseFragment; + b = maybeSuspenseFragment; + } else { + break; + } + } else { + break; + } } // If both copies of the parent fiber point to the same child, we can From 43b1f74c88d986c88623412be7b1d65a6e271779 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 3 Apr 2019 15:07:09 -0700 Subject: [PATCH 03/41] Alternate fix for #14198 This doesn't rely on checking the tag. When the alternate of a parent is missing, it assumes it's a fragment indirection and moves onto the next parent fiber. --- .../src/ReactFiberTreeReflection.js | 44 ++++++++----------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js index 150e8200aed40..734dead68cf6f 100644 --- a/packages/react-reconciler/src/ReactFiberTreeReflection.js +++ b/packages/react-reconciler/src/ReactFiberTreeReflection.js @@ -21,8 +21,6 @@ import { HostRoot, HostPortal, HostText, - Fragment, - SuspenseComponent, } from 'shared/ReactWorkTags'; import {NoEffect, Placement} from 'shared/ReactSideEffectTags'; @@ -115,33 +113,27 @@ 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) { - // We're either at the root, or we're in a special Fragment - // with no alternate, which is how Suspense (un)hiding works. - let maybeSuspenseFragment = parentA || parentB; - if (maybeSuspenseFragment && maybeSuspenseFragment.tag === Fragment) { - const maybeSuspense = maybeSuspenseFragment.return; - if ( - maybeSuspense && - maybeSuspense.tag === SuspenseComponent && - // If state isn't null, it timed out and we have two Fragment children. - maybeSuspense.memoizedState !== null - ) { - parentA = maybeSuspense; - parentB = maybeSuspense; - a = maybeSuspenseFragment; - b = maybeSuspenseFragment; - } else { - break; - } - } else { - break; + 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 From 1dcab970fa9979c89edafd48447b146830f44bfa Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 3 Apr 2019 18:15:33 -0700 Subject: [PATCH 04/41] Store entire build directory as CI artifacts (#15310) * Store FB bundles as CI artifacts Updates the Circle CI config to store Facebook bundles as build artifacts. We already do this for our npm packages. * Might as well store everything in build/ * Store build directory as a tarball So it's easy to download --- .circleci/config.yml | 5 ++++- scripts/circleci/pack_and_store_artifact.sh | 10 +++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 7bebd1f37c533..a913ed226873e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -44,4 +44,7 @@ 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/error-codes/codes.json 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 From e221972818e887f2ced524a48918735dccf70c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kunuk=20Nykj=C3=A6r?= Date: Thu, 4 Apr 2019 09:56:09 +0200 Subject: [PATCH 05/41] update gcc version (#15034) --- package.json | 2 +- yarn.lock | 56 ++++++++++++++++++++++++++-------------------------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index 1699d6847ef41..92beff532f100 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", 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 7a2dc4853948a70a599124b7f47513df793d472a Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Thu, 4 Apr 2019 15:32:32 +0100 Subject: [PATCH 06/41] Allow DevTools to toggle Suspense fallbacks (#15232) * Allow DevTools to toggle Suspense state * Change API to overrideSuspense This lets detect support for overriding Suspense from DevTools. * Add ConcurrentMode test * Newlines * Remove unnecessary change * Naming changes --- .../ReactDevToolsHooksIntegration-test.js | 114 ++++++++++++++++++ .../src/ReactFiberBeginWork.js | 7 ++ .../src/ReactFiberReconciler.js | 19 +++ 3 files changed, 140 insertions(+) diff --git a/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactDevToolsHooksIntegration-test.js index 113524f832af5..f7ea7ffebc031 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,112 @@ 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([]); + 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-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index e6b04911a7025..2832021950f69 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -96,6 +96,7 @@ import { registerSuspenseInstanceRetry, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; +import {shouldSuspend} from './ReactFiberReconciler'; import { pushHostContext, pushHostContainer, @@ -1392,6 +1393,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; diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index b5e1182376523..77fadd31e9b2e 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -340,8 +340,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 = ( @@ -409,6 +417,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 { @@ -419,6 +436,8 @@ export function injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean { ...devToolsConfig, overrideHookState, overrideProps, + setSuspenseHandler, + scheduleUpdate, currentDispatcherRef: ReactCurrentDispatcher, findHostInstanceByFiber(fiber: Fiber): Instance | TextInstance | null { const hostFiber = findCurrentHostFiber(fiber); From 937d262f557fd3332576be0da60adf5f94c545d9 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Thu, 4 Apr 2019 08:55:35 -0700 Subject: [PATCH 07/41] React events: keyboard press, types, tests (#15314) * Add HoverProps type * Add more Hover event module tests * Add more Press event module tests * Change default longPress delay from 1000 to 500 * Rename dispatchPressEvent -> dispatchEvent * Consolidate state updates in Press event module * Add keyboard support for Press events * Add FocusProps type and unit tests --- packages/react-events/src/Focus.js | 33 +- packages/react-events/src/Hover.js | 65 ++-- packages/react-events/src/Press.js | 303 ++++++++++-------- .../src/__tests__/Focus-test.internal.js | 111 +++++++ .../src/__tests__/Hover-test.internal.js | 199 ++++++++---- .../src/__tests__/Press-test.internal.js | 114 ++++++- 6 files changed, 584 insertions(+), 241 deletions(-) create mode 100644 packages/react-events/src/__tests__/Focus-test.internal.js diff --git a/packages/react-events/src/Focus.js b/packages/react-events/src/Focus.js index e56379f8871dd..0b26bde9757bf 100644 --- a/packages/react-events/src/Focus.js +++ b/packages/react-events/src/Focus.js @@ -10,10 +10,12 @@ import type {EventResponderContext} from 'events/EventTypes'; 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, @@ -27,6 +29,11 @@ type FocusEvent = {| type: FocusEventType, |}; +const targetEventTypes = [ + {name: 'focus', passive: true, capture: true}, + {name: 'blur', passive: true, capture: true}, +]; + function createFocusEvent( type: FocusEventType, target: Element | Document, @@ -39,7 +46,10 @@ function createFocusEvent( }; } -function dispatchFocusInEvents(context: EventResponderContext, props: Object) { +function dispatchFocusInEvents( + context: EventResponderContext, + props: FocusProps, +) { const {event, eventTarget} = context; if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { return; @@ -53,19 +63,22 @@ function dispatchFocusInEvents(context: EventResponderContext, props: Object) { context.dispatchEvent(syntheticEvent, {discrete: true}); } if (props.onFocusChange) { - const focusChangeEventListener = () => { + const listener = () => { props.onFocusChange(true); }; const syntheticEvent = createFocusEvent( 'focuschange', eventTarget, - focusChangeEventListener, + listener, ); context.dispatchEvent(syntheticEvent, {discrete: true}); } } -function dispatchFocusOutEvents(context: EventResponderContext, props: Object) { +function dispatchFocusOutEvents( + context: EventResponderContext, + props: FocusProps, +) { const {event, eventTarget} = context; if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { return; @@ -75,13 +88,13 @@ function dispatchFocusOutEvents(context: EventResponderContext, props: Object) { context.dispatchEvent(syntheticEvent, {discrete: true}); } if (props.onFocusChange) { - const focusChangeEventListener = () => { + const listener = () => { props.onFocusChange(false); }; const syntheticEvent = createFocusEvent( 'focuschange', eventTarget, - focusChangeEventListener, + listener, ); context.dispatchEvent(syntheticEvent, {discrete: true}); } diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index 226430b838d09..ff42aac232e79 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -10,12 +10,14 @@ import type {EventResponderContext} from 'events/EventTypes'; 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, + onHoverStart: (e: HoverEvent) => void, +}; type HoverState = { isHovered: boolean, @@ -31,6 +33,21 @@ type HoverEvent = {| 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', 'mouseout'); +} + function createHoverEvent( type: HoverEventType, target: Element | Document, @@ -43,16 +60,9 @@ function createHoverEvent( }; } -// 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 dispatchHoverStartEvents( context: EventResponderContext, - props: Object, - state: HoverState, + props: HoverProps, ): void { const {event, eventTarget} = context; if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { @@ -67,19 +77,22 @@ function dispatchHoverStartEvents( context.dispatchEvent(syntheticEvent, {discrete: true}); } if (props.onHoverChange) { - const hoverChangeEventListener = () => { + const listener = () => { props.onHoverChange(true); }; const syntheticEvent = createHoverEvent( 'hoverchange', eventTarget, - hoverChangeEventListener, + listener, ); context.dispatchEvent(syntheticEvent, {discrete: true}); } } -function dispatchHoverEndEvents(context: EventResponderContext, props: Object) { +function dispatchHoverEndEvents( + context: EventResponderContext, + props: HoverProps, +) { const {event, eventTarget} = context; if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { return; @@ -93,13 +106,13 @@ function dispatchHoverEndEvents(context: EventResponderContext, props: Object) { context.dispatchEvent(syntheticEvent, {discrete: true}); } if (props.onHoverChange) { - const hoverChangeEventListener = () => { + const listener = () => { props.onHoverChange(false); }; const syntheticEvent = createHoverEvent( 'hoverchange', eventTarget, - hoverChangeEventListener, + listener, ); context.dispatchEvent(syntheticEvent, {discrete: true}); } @@ -116,18 +129,22 @@ const HoverResponder = { }, handleEvent( context: EventResponderContext, - props: Object, + props: HoverProps, state: HoverState, ): void { const {eventType, eventTarget, event} = context; switch (eventType) { - case 'touchstart': - // Touch devices don't have hover support + /** + * Prevent hover events when touch is being used. + */ + case 'touchstart': { if (!state.isTouched) { state.isTouched = true; } break; + } + case 'pointerover': case 'mouseover': { if ( @@ -148,7 +165,7 @@ const HoverResponder = { state.isInHitSlop = true; return; } - dispatchHoverStartEvents(context, props, state); + dispatchHoverStartEvents(context, props); state.isHovered = true; } break; @@ -172,7 +189,7 @@ const HoverResponder = { (event: any).y, ) ) { - dispatchHoverStartEvents(context, props, state); + dispatchHoverStartEvents(context, props); state.isHovered = true; state.isInHitSlop = false; } diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index b79c38758b9a8..69251a1fa3ef5 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -10,27 +10,6 @@ import type {EventResponderContext} from 'events/EventTypes'; 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, @@ -70,6 +49,30 @@ type PressEvent = {| type: PressEventType, |}; +// 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 = 500; + +const targetEventTypes = [ + {name: 'click', passive: false}, + {name: 'keydown', passive: false}, + 'pointerdown', + 'pointercancel', + 'contextmenu', +]; +const rootEventTypes = [ + {name: 'keyup', passive: false}, + {name: 'pointerup', passive: false}, + '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', 'mousedown', 'touchcancel'); + rootEventTypes.push({name: 'mouseup', passive: false}); +} + function createPressEvent( type: PressEventType, target: Element | Document, @@ -82,7 +85,7 @@ function createPressEvent( }; } -function dispatchPressEvent( +function dispatchEvent( context: EventResponderContext, state: PressState, name: PressEventType, @@ -93,23 +96,40 @@ function dispatchPressEvent( context.dispatchEvent(syntheticEvent, {discrete: true}); } +function dispatchPressChangeEvent( + context: EventResponderContext, + props: PressProps, + state: PressState, +): void { + const listener = () => { + props.onPressChange(state.isPressed); + }; + dispatchEvent(context, state, 'presschange', listener); +} + +function dispatchLongPressChangeEvent( + context: EventResponderContext, + props: PressProps, + state: PressState, +): void { + const listener = () => { + props.onLongPressChange(state.isLongPressed); + }; + dispatchEvent(context, state, 'longpresschange', listener); +} + function dispatchPressStartEvents( context: EventResponderContext, props: PressProps, state: PressState, ): void { - function dispatchPressChangeEvent(bool) { - const pressChangeEventListener = () => { - props.onPressChange(bool); - }; - dispatchPressEvent(context, state, 'presschange', pressChangeEventListener); - } + state.isPressed = true; if (props.onPressStart) { - dispatchPressEvent(context, state, 'pressstart', props.onPressStart); + dispatchEvent(context, state, 'pressstart', props.onPressStart); } if (props.onPressChange) { - dispatchPressChangeEvent(true); + dispatchPressChangeEvent(context, props, state); } if ((props.onLongPress || props.onLongPressChange) && !state.isLongPressed) { const delayLongPress = calculateDelayMS( @@ -125,31 +145,18 @@ function dispatchPressStartEvents( state.longPressTimeout = null; if (props.onLongPress) { - const longPressEventListener = e => { + const listener = e => { props.onLongPress(e); // TODO address this again at some point // if (e.nativeEvent.defaultPrevented) { // state.defaultPrevented = true; // } }; - dispatchPressEvent( - context, - state, - 'longpress', - longPressEventListener, - ); + dispatchEvent(context, state, 'longpress', listener); } if (props.onLongPressChange) { - const longPressChangeEventListener = () => { - props.onLongPressChange(true); - }; - dispatchPressEvent( - context, - state, - 'longpresschange', - longPressChangeEventListener, - ); + dispatchLongPressChangeEvent(context, props, state); } }), delayLongPress, @@ -167,24 +174,21 @@ function dispatchPressEndEvents( state.longPressTimeout = null; } if (props.onPressEnd) { - dispatchPressEvent(context, state, 'pressend', props.onPressEnd); + dispatchEvent(context, state, 'pressend', props.onPressEnd); } - if (props.onPressChange) { - const pressChangeEventListener = () => { - props.onPressChange(false); - }; - dispatchPressEvent(context, state, 'presschange', pressChangeEventListener); + + if (state.isPressed) { + state.isPressed = false; + if (props.onPressChange) { + dispatchPressChangeEvent(context, props, state); + } } - if (props.onLongPressChange && state.isLongPressed) { - const longPressChangeEventListener = () => { - props.onLongPressChange(false); - }; - dispatchPressEvent( - context, - state, - 'longpresschange', - longPressChangeEventListener, - ); + + if (state.isLongPressed) { + state.isLongPressed = false; + if (props.onLongPressChange) { + dispatchLongPressChangeEvent(context, props, state); + } } } @@ -223,15 +227,74 @@ const PressResponder = { const {eventTarget, eventType, event} = context; switch (eventType) { - case 'keydown': { + /** + * 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.isTargetOwned(eventTarget) && + !state.shouldSkipMouseAfterTouch ) { - return; + if ( + (event: any).pointerType === 'mouse' || + eventType === 'mousedown' + ) { + if ( + // Ignore right- and middle-clicks + event.button === 1 || + event.button === 2 || + // Ignore pressing on hit slop area with mouse + context.isPositionWithinTouchHitTarget( + (event: any).x, + (event: any).y, + ) + ) { + return; + } + } + state.pressTarget = eventTarget; + dispatchPressStartEvents(context, props, state); + context.addRootEventTypes(rootEventTypes); } - dispatchPressEvent(context, state, 'press', props.onPress); + break; + } + case 'pointerup': + case 'mouseup': { + if (state.isPressed) { + if (state.shouldSkipMouseAfterTouch) { + state.shouldSkipMouseAfterTouch = false; + return; + } + + const wasLongPressed = state.isLongPressed; + + dispatchPressEndEvents(context, props, state); + + if (state.pressTarget !== null && props.onPress) { + if (context.isTargetWithinElement(eventTarget, state.pressTarget)) { + if ( + !( + wasLongPressed && + props.onLongPressShouldCancelPress && + props.onLongPressShouldCancelPress() + ) + ) { + const listener = e => { + props.onPress(e); + // TODO address this again at some point + // if (e.nativeEvent.defaultPrevented) { + // state.defaultPrevented = true; + // } + }; + dispatchEvent(context, state, 'press', listener); + } + } + } + context.removeRootEventTypes(rootEventTypes); + } + state.isAnchorTouched = false; break; } @@ -239,7 +302,7 @@ const PressResponder = { * Touch event implementations are only needed for Safari, which lacks * support for pointer events. */ - case 'touchstart': + case 'touchstart': { if (!state.isPressed && !context.isTargetOwned(eventTarget)) { // We bail out of polyfilling anchor tags, given the same heuristics // explained above in regards to needing to use click events. @@ -249,21 +312,21 @@ const PressResponder = { } state.pressTarget = eventTarget; dispatchPressStartEvents(context, props, state); - state.isPressed = true; context.addRootEventTypes(rootEventTypes); } - break; + } case 'touchend': { if (state.isAnchorTouched) { + state.isAnchorTouched = false; return; } if (state.isPressed) { + const wasLongPressed = state.isLongPressed; + dispatchPressEndEvents(context, props, state); - if ( - eventType !== 'touchcancel' && - (props.onPress || props.onLongPress) - ) { + + if (eventType !== '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; @@ -276,19 +339,16 @@ const PressResponder = { context.isTargetWithinEventComponent(target) ) { 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,93 +356,58 @@ 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': { if ( !state.isPressed && + !state.isLongPressed && !context.isTargetOwned(eventTarget) && - !state.shouldSkipMouseAfterTouch + isValidKeyPress((event: 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; - } + // Prevent spacebar press from scrolling the window + if ((event: any).key === ' ') { + (event: any).preventDefault(); } 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((event: 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 'scroll': - case 'touchcancel': case 'contextmenu': - case 'pointercancel': { + case 'pointercancel': + case 'scroll': + 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(); 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..bd449d8f2348b 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,154 @@ 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(, container); - - const mouseOverEvent = document.createEvent('Event'); - mouseOverEvent.initEvent('mouseover', true, true); - divRef.current.dispatchEvent(mouseOverEvent); - - const mouseOutEvent = document.createEvent('Event'); - mouseOutEvent.initEvent('mouseout', true, true); - divRef.current.dispatchEvent(mouseOutEvent); - - expect(events).toEqual(['hover in', 'hover out']); + 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(); + }); + + // TODO: complete delayHoverStart tests + // describe('delayHoverStart', () => {}); }); - it('should support onHoverStart and onHoverEnd', () => { - let divRef = React.createRef(); - let events = []; - - function handleOnHoverStart() { - events.push('onHoverStart'); - } + describe('onHoverChange', () => { + let onHoverChange, ref; - function handleOnHoverEnd() { - events.push('onHoverEnd'); - } - - function Component() { - return ( - -
Hover me!
+ beforeEach(() => { + onHoverChange = jest.fn(); + ref = React.createRef(); + const element = ( + +
); - } - - ReactDOM.render(, container); + ReactDOM.render(element, container); + }); + + 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); + }); + }); - const mouseOverEvent = document.createEvent('Event'); - mouseOverEvent.initEvent('mouseover', true, true); - divRef.current.dispatchEvent(mouseOverEvent); + describe('onHoverEnd', () => { + let onHoverEnd, ref; - const mouseOutEvent = document.createEvent('Event'); - mouseOutEvent.initEvent('mouseout', true, true); - divRef.current.dispatchEvent(mouseOutEvent); + 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); + }); + + it('is called after "pointercancel" event', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointercancel')); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + + 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); + }); + + // 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(); + }); + + // TODO: complete delayHoverStart tests + // describe('delayHoverEnd', () => {}); + }); - 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..504f7e49d8d85 100644 --- a/packages/react-events/src/__tests__/Press-test.internal.js +++ b/packages/react-events/src/__tests__/Press-test.internal.js @@ -14,7 +14,7 @@ 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'); @@ -69,12 +69,31 @@ describe('Event responder: Press', () => { expect(onPressStart).toHaveBeenCalledTimes(1); }); - 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); + }); + + it('is called once after "keydown" events for Spacebar', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '})); + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '})); + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '})); + expect(onPressStart).toHaveBeenCalledTimes(1); + }); + + 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')); @@ -109,20 +128,37 @@ describe('Event responder: Press', () => { expect(onPressEnd).toHaveBeenCalledTimes(1); }); - 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); }); + 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); + }); + + it('is called after "keyup" event for Spacebar', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '})); + ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: ' '})); + expect(onPressEnd).toHaveBeenCalledTimes(1); + }); + + 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 it('is called after "mouseup" event', () => { ref.current.dispatchEvent(createPointerEvent('mousedown')); ref.current.dispatchEvent(createPointerEvent('mouseup')); expect(onPressEnd).toHaveBeenCalledTimes(1); }); - it('is called after "touchend" event', () => { ref.current.dispatchEvent(createPointerEvent('touchstart')); ref.current.dispatchEvent(createPointerEvent('touchend')); @@ -155,6 +191,33 @@ 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); + }); + + // 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); + }); }); describe('onPress', () => { @@ -176,6 +239,20 @@ describe('Event responder: Press', () => { ref.current.dispatchEvent(createPointerEvent('pointerup')); expect(onPress).toHaveBeenCalledTimes(1); }); + + 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); + }); + + // 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); + //}); }); describe('onLongPress', () => { @@ -192,7 +269,7 @@ describe('Event responder: Press', () => { ReactDOM.render(element, container); }); - it('is called if press lasts default delay', () => { + it('is called if "pointerdown" lasts default delay', () => { ref.current.dispatchEvent(createPointerEvent('pointerdown')); jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY - 1); expect(onLongPress).not.toBeCalled(); @@ -200,7 +277,7 @@ describe('Event responder: Press', () => { expect(onLongPress).toHaveBeenCalledTimes(1); }); - 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 +285,22 @@ 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); + }); + + 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(); + }); + describe('delayLongPress', () => { it('can be configured', () => { const element = ( @@ -339,7 +432,7 @@ describe('Event responder: Press', () => { describe('nested responders', () => { it('dispatch events in the correct order', () => { - let events = []; + const events = []; const ref = React.createRef(); const createEventHandler = msg => () => { events.push(msg); @@ -385,13 +478,6 @@ describe('Event responder: Press', () => { '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']); }); }); From b93a8a9bb8460a3d582072d3b252ecc15c6ea0f5 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Thu, 4 Apr 2019 23:28:23 +0100 Subject: [PATCH 08/41] Experimental event API: refactor responder modules for lifecycle inclusion (#15322) --- packages/events/EventTypes.js | 40 +- .../src/events/DOMEventResponderSystem.js | 465 +++++++++--------- .../DOMEventResponderSystem-test.internal.js | 102 ++-- packages/react-events/src/Drag.js | 35 +- packages/react-events/src/Focus.js | 51 +- packages/react-events/src/Hover.js | 71 ++- packages/react-events/src/Press.js | 144 +++--- packages/react-events/src/Swipe.js | 37 +- .../__tests__/TouchHitTarget-test.internal.js | 2 +- packages/react-reconciler/src/ReactFiber.js | 1 + packages/shared/ReactTypes.js | 15 +- 11 files changed, 485 insertions(+), 478 deletions(-) diff --git a/packages/events/EventTypes.js b/packages/events/EventTypes.js index 169b34aa07713..ab5c51db640a0 100644 --- a/packages/events/EventTypes.js +++ b/packages/events/EventTypes.js @@ -10,34 +10,40 @@ 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, - }, +export type ResponderEvent = { + nativeEvent: AnyNativeEvent, + target: Element | Document, + type: string, + passive: boolean, + passiveSupported: boolean, +}; + +export type ResponderDispatchEventOptions = { + capture?: boolean, + discrete?: boolean, + stopPropagation?: boolean, +}; + +export type ResponderContext = { + dispatchEvent: ( + eventObject: Object, + otpions: ResponderDispatchEventOptions, ) => void, isTargetWithinElement: ( childTarget: Element | Document, parentTarget: Element | Document, ) => boolean, - isTargetOwned: (Element | Document) => boolean, isTargetWithinEventComponent: (Element | Document) => boolean, isPositionWithinTouchHitTarget: (x: number, y: number) => boolean, addRootEventTypes: ( + document: Document, rootEventTypes: Array, ) => void, removeRootEventTypes: ( rootEventTypes: Array, ) => void, - requestOwnership: (target: Element | Document | null) => boolean, - releaseOwnership: (target: Element | Document | null) => boolean, - withAsyncDispatching: (func: () => void) => void, + hasOwnership: () => boolean, + requestOwnership: () => boolean, + releaseOwnership: () => boolean, + setTimeout: (func: () => void, timeout: number) => TimeoutID, }; diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 45e6c95e463b6..7630d8d7b427f 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -6,6 +6,11 @@ * @flow */ +import type { + ResponderContext, + ResponderEvent, + ResponderDispatchEventOptions, +} from 'events/EventTypes'; import { type EventSystemFlags, IS_PASSIVE, @@ -20,13 +25,13 @@ import type { 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'; -let listenToResponderEventTypesImpl; +import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; + +export let listenToResponderEventTypesImpl; export function setListenToResponderEventTypes( _listenToResponderEventTypesImpl: Function, @@ -34,31 +39,215 @@ export function setListenToResponderEventTypes( listenToResponderEventTypesImpl = _listenToResponderEventTypesImpl; } -const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set; +type EventQueue = { + bubble: null | Array<$Shape>, + capture: null | Array<$Shape>, + discrete: boolean, +}; + +type PartialEventObject = { + listener: ($Shape) => void, + target: Element | Document, + type: string, +}; + +let currentOwner = null; +let currentFiber: Fiber; +let currentResponder: ReactEventResponder; +let currentEventQueue: EventQueue; + +const eventResponderContext: ResponderContext = { + dispatchEvent( + possibleEventObject: Object, + {capture, discrete, stopPropagation}: ResponderDispatchEventOptions, + ): void { + const eventQueue = currentEventQueue; + 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< + PartialEventObject, + >); + 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); + } + }, + isPositionWithinTouchHitTarget(x: number, y: number): boolean { + return false; + }, + isTargetWithinEventComponent(target: Element | Document): boolean { + const eventFiber = currentFiber; + + if (target != null) { + let fiber = getClosestInstanceFromNode(target); + while (fiber !== null) { + if (fiber === eventFiber || fiber === eventFiber.alternate) { + 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 { + listenToResponderEventTypesImpl(rootEventTypes, doc); + const eventComponent = currentFiber; + 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); + } + }, + removeRootEventTypes( + rootEventTypes: Array, + ): void { + const eventComponent = currentFiber; + 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); + } + } + }, + hasOwnership(): boolean { + return currentOwner === currentFiber; + }, + requestOwnership(): boolean { + if (currentOwner !== null) { + return false; + } + currentOwner = currentFiber; + return true; + }, + releaseOwnership(): boolean { + if (currentOwner !== currentFiber) { + return false; + } + currentOwner = null; + return false; + }, + setTimeout(func: () => void, delay): TimeoutID { + const contextResponder = currentResponder; + const contextFiber = currentFiber; + return setTimeout(() => { + const previousEventQueue = currentEventQueue; + const previousFiber = currentFiber; + const previousResponder = currentResponder; + currentEventQueue = createEventQueue(); + currentResponder = contextResponder; + currentFiber = contextFiber; + try { + func(); + batchedUpdates(processEventQueue, currentEventQueue); + } finally { + currentFiber = previousFiber; + currentEventQueue = previousEventQueue; + currentResponder = previousResponder; + } + }, delay); + }, +}; const rootEventTypesToEventComponents: Map< DOMTopLevelEventType | string, Set, > = new Map(); +const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set; +const eventsWithStopPropagation: + | WeakSet + | Set<$Shape> = new PossiblyWeakSet(); 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, -}; +function createResponderEvent( + topLevelType: string, + nativeEvent: AnyNativeEvent, + nativeEventTarget: Element | Document, + eventSystemFlags: EventSystemFlags, +): ResponderEvent { + return { + nativeEvent: nativeEvent, + target: nativeEventTarget, + type: topLevelType, + passive: (eventSystemFlags & IS_PASSIVE) !== 0, + passiveSupported: (eventSystemFlags & PASSIVE_NOT_SUPPORTED) === 0, + }; +} function createEventQueue(): EventQueue { return { @@ -100,8 +289,8 @@ function processEvents( } } -function processEventQueue(eventQueue: EventQueue): void { - const {bubble, capture, discrete} = eventQueue; +export function processEventQueue(): void { + const {bubble, capture, discrete} = currentEventQueue; if (discrete) { interactiveUpdates(() => { @@ -112,218 +301,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 { @@ -345,7 +322,7 @@ function getTargetEventTypes( function handleTopLevelType( topLevelType: DOMTopLevelEventType, fiber: Fiber, - context: Object, + responderEvent: ResponderEvent, isRootLevelEvent: boolean, ): void { const responder: ReactEventResponder = fiber.type.responder; @@ -360,9 +337,10 @@ function handleTopLevelType( if (state === null && responder.createInitialState !== undefined) { state = fiber.stateNode.state = responder.createInitialState(props); } - context._fiber = fiber; - context._responder = responder; - responder.handleEvent(context, props, state); + currentFiber = fiber; + currentResponder = responder; + + responder.onEvent(responderEvent, eventResponderContext, props, state); } export function runResponderEventsInBatch( @@ -373,17 +351,18 @@ 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); + handleTopLevelType(topLevelType, node, responderEvent, false); } node = node.return; } @@ -399,11 +378,11 @@ export function runResponderEventsInBatch( handleTopLevelType( topLevelType, rootEventComponentFiber, - context, + responderEvent, true, ); } } - processEventQueue(context._eventQueue); + processEventQueue(); } } 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..c304781341d66 100644 --- a/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js @@ -13,10 +13,10 @@ let React; let ReactFeatureFlags; let ReactDOM; -function createReactEventComponent(targetEventTypes, handleEvent) { +function createReactEventComponent(targetEventTypes, onEvent) { const testEventResponder = { targetEventTypes, - handleEvent, + onEvent, }; return { @@ -53,19 +53,19 @@ 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) => { + (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 +79,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 +103,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 +113,11 @@ describe('DOMEventResponderSystem', () => { const ClickEventComponent = createReactEventComponent( ['click'], - (context, props) => { + (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 +130,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 +141,19 @@ 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) => { + (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 +168,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,7 +186,7 @@ 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(); @@ -214,7 +214,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 +227,14 @@ describe('DOMEventResponderSystem', () => { const ClickEventComponent = createReactEventComponent( ['click'], - (context, props) => { + (event, context, props) => { if (props.onMagicClick) { - const event = { + const syntheticEvent = { listener: props.onMagicClick, - target: context.eventTarget, + target: event.target, type: 'magicclick', }; - context.dispatchEvent(event, {discrete: true}); + context.dispatchEvent(syntheticEvent, {discrete: true}); } }, ); @@ -251,7 +251,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 +264,33 @@ describe('DOMEventResponderSystem', () => { const LongPressEventComponent = createReactEventComponent( ['click'], - (context, props) => { + (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.setTimeout(() => { + if (props.onLongPress) { + const longPressEvent = { + listener: props.onLongPress, + target: event.target, + type: 'longpress', + }; + context.dispatchEvent(longPressEvent, {discrete: true}); + } + + if (props.onLongPressChange) { + const longPressChangeEvent = { + listener: props.onLongPressChange, + target: event.target, + type: 'longpresschange', + }; + context.dispatchEvent(longPressChangeEvent, {discrete: true}); + } + }, 500); }, ); @@ -313,7 +309,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); jest.runAllTimers(); diff --git a/packages/react-events/src/Drag.js b/packages/react-events/src/Drag.js index ed11c4260c270..86cb120af2667 100644 --- a/packages/react-events/src/Drag.js +++ b/packages/react-events/src/Drag.js @@ -7,7 +7,7 @@ * @flow */ -import type {EventResponderContext} from 'events/EventTypes'; +import type {ResponderEvent, ResponderContext} from 'events/EventTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; const targetEventTypes = ['pointerdown', 'pointercancel']; @@ -62,7 +62,7 @@ function createDragEvent( } function dispatchDragEvent( - context: EventResponderContext, + context: ResponderContext, name: DragEventType, listener: DragEvent => void, state: DragState, @@ -87,28 +87,31 @@ const DragResponder = { y: 0, }; }, - handleEvent( - context: EventResponderContext, + onEvent( + event: ResponderEvent, + context: ResponderContext, 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 0b26bde9757bf..30ff7102ba4cc 100644 --- a/packages/react-events/src/Focus.js +++ b/packages/react-events/src/Focus.js @@ -7,7 +7,7 @@ * @flow */ -import type {EventResponderContext} from 'events/EventTypes'; +import type {ResponderEvent, ResponderContext} from 'events/EventTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; type FocusProps = { @@ -47,55 +47,45 @@ function createFocusEvent( } function dispatchFocusInEvents( - context: EventResponderContext, + event: ResponderEvent, + context: ResponderContext, props: FocusProps, ) { - const {event, eventTarget} = context; - if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { + const {nativeEvent, target} = event; + if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) { return; } if (props.onFocus) { - const syntheticEvent = createFocusEvent( - 'focus', - eventTarget, - props.onFocus, - ); + const syntheticEvent = createFocusEvent('focus', target, props.onFocus); context.dispatchEvent(syntheticEvent, {discrete: true}); } if (props.onFocusChange) { const listener = () => { props.onFocusChange(true); }; - const syntheticEvent = createFocusEvent( - 'focuschange', - eventTarget, - listener, - ); + const syntheticEvent = createFocusEvent('focuschange', target, listener); context.dispatchEvent(syntheticEvent, {discrete: true}); } } function dispatchFocusOutEvents( - context: EventResponderContext, + event: ResponderEvent, + context: ResponderContext, props: FocusProps, ) { - const {event, eventTarget} = context; - if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { + const {nativeEvent, target} = event; + if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) { return; } if (props.onBlur) { - const syntheticEvent = createFocusEvent('blur', eventTarget, props.onBlur); + const syntheticEvent = createFocusEvent('blur', target, props.onBlur); context.dispatchEvent(syntheticEvent, {discrete: true}); } if (props.onFocusChange) { const listener = () => { props.onFocusChange(false); }; - const syntheticEvent = createFocusEvent( - 'focuschange', - eventTarget, - listener, - ); + const syntheticEvent = createFocusEvent('focuschange', target, listener); context.dispatchEvent(syntheticEvent, {discrete: true}); } } @@ -107,24 +97,25 @@ const FocusResponder = { isFocused: false, }; }, - handleEvent( - context: EventResponderContext, + onEvent( + event: ResponderEvent, + context: ResponderContext, props: Object, state: FocusState, ): void { - const {eventTarget, eventType} = context; + const {type} = event; - switch (eventType) { + switch (type) { case 'focus': { - if (!state.isFocused && !context.isTargetOwned(eventTarget)) { - dispatchFocusInEvents(context, props); + if (!state.isFocused && !context.hasOwnership()) { + dispatchFocusInEvents(event, context, props); state.isFocused = true; } break; } case 'blur': { if (state.isFocused) { - dispatchFocusOutEvents(context, props); + dispatchFocusOutEvents(event, context, props); state.isFocused = false; } break; diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index ff42aac232e79..050a50bda0c31 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -7,7 +7,7 @@ * @flow */ -import type {EventResponderContext} from 'events/EventTypes'; +import type {ResponderEvent, ResponderContext} from 'events/EventTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; type HoverProps = { @@ -61,17 +61,18 @@ function createHoverEvent( } function dispatchHoverStartEvents( - context: EventResponderContext, + event: ResponderEvent, + context: ResponderContext, props: HoverProps, ): void { - const {event, eventTarget} = context; - if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { + const {nativeEvent, target} = event; + if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) { return; } if (props.onHoverStart) { const syntheticEvent = createHoverEvent( 'hoverstart', - eventTarget, + target, props.onHoverStart, ); context.dispatchEvent(syntheticEvent, {discrete: true}); @@ -80,27 +81,24 @@ function dispatchHoverStartEvents( const listener = () => { props.onHoverChange(true); }; - const syntheticEvent = createHoverEvent( - 'hoverchange', - eventTarget, - listener, - ); + const syntheticEvent = createHoverEvent('hoverchange', target, listener); context.dispatchEvent(syntheticEvent, {discrete: true}); } } function dispatchHoverEndEvents( - context: EventResponderContext, + event: ResponderEvent, + context: ResponderContext, props: HoverProps, ) { - const {event, eventTarget} = context; - if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { + const {nativeEvent, target} = event; + if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) { return; } if (props.onHoverEnd) { const syntheticEvent = createHoverEvent( 'hoverend', - eventTarget, + target, props.onHoverEnd, ); context.dispatchEvent(syntheticEvent, {discrete: true}); @@ -109,11 +107,7 @@ function dispatchHoverEndEvents( const listener = () => { props.onHoverChange(false); }; - const syntheticEvent = createHoverEvent( - 'hoverchange', - eventTarget, - listener, - ); + const syntheticEvent = createHoverEvent('hoverchange', target, listener); context.dispatchEvent(syntheticEvent, {discrete: true}); } } @@ -127,14 +121,15 @@ const HoverResponder = { isTouched: false, }; }, - handleEvent( - context: EventResponderContext, + onEvent( + event: ResponderEvent, + context: ResponderContext, props: HoverProps, state: HoverState, ): void { - const {eventType, eventTarget, event} = context; + const {type, nativeEvent} = event; - switch (eventType) { + switch (type) { /** * Prevent hover events when touch is being used. */ @@ -147,25 +142,21 @@ const HoverResponder = { 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 ( context.isPositionWithinTouchHitTarget( - (event: any).x, - (event: any).y, + (nativeEvent: any).x, + (nativeEvent: any).y, ) ) { state.isInHitSlop = true; return; } - dispatchHoverStartEvents(context, props); + dispatchHoverStartEvents(event, context, props); state.isHovered = true; } break; @@ -173,7 +164,7 @@ const HoverResponder = { case 'pointerout': case 'mouseout': { if (state.isHovered && !state.isTouched) { - dispatchHoverEndEvents(context, props); + dispatchHoverEndEvents(event, context, props); state.isHovered = false; } state.isInHitSlop = false; @@ -185,22 +176,22 @@ const HoverResponder = { if (state.isInHitSlop) { if ( !context.isPositionWithinTouchHitTarget( - (event: any).x, - (event: any).y, + (nativeEvent: any).x, + (nativeEvent: any).y, ) ) { - dispatchHoverStartEvents(context, props); + dispatchHoverStartEvents(event, context, props); state.isHovered = true; state.isInHitSlop = false; } } else if ( state.isHovered && context.isPositionWithinTouchHitTarget( - (event: any).x, - (event: any).y, + (nativeEvent: any).x, + (nativeEvent: any).y, ) ) { - dispatchHoverEndEvents(context, props); + dispatchHoverEndEvents(event, context, props); state.isHovered = false; state.isInHitSlop = true; } @@ -209,7 +200,7 @@ const HoverResponder = { } case 'pointercancel': { if (state.isHovered && !state.isTouched) { - dispatchHoverEndEvents(context, props); + dispatchHoverEndEvents(event, context, props); state.isHovered = false; state.isTouched = false; } diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index 69251a1fa3ef5..ab2750d6271b8 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -7,7 +7,7 @@ * @flow */ -import type {EventResponderContext} from 'events/EventTypes'; +import type {ResponderEvent, ResponderContext} from 'events/EventTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; type PressProps = { @@ -86,7 +86,7 @@ function createPressEvent( } function dispatchEvent( - context: EventResponderContext, + context: ResponderContext, state: PressState, name: PressEventType, listener: (e: Object) => void, @@ -97,7 +97,7 @@ function dispatchEvent( } function dispatchPressChangeEvent( - context: EventResponderContext, + context: ResponderContext, props: PressProps, state: PressState, ): void { @@ -108,7 +108,7 @@ function dispatchPressChangeEvent( } function dispatchLongPressChangeEvent( - context: EventResponderContext, + context: ResponderContext, props: PressProps, state: PressState, ): void { @@ -119,7 +119,7 @@ function dispatchLongPressChangeEvent( } function dispatchPressStartEvents( - context: EventResponderContext, + context: ResponderContext, props: PressProps, state: PressState, ): void { @@ -138,34 +138,30 @@ function dispatchPressStartEvents( DEFAULT_LONG_PRESS_DELAY_MS, ); - state.longPressTimeout = setTimeout( - () => - context.withAsyncDispatching(() => { - state.isLongPressed = true; - state.longPressTimeout = null; - - if (props.onLongPress) { - const listener = e => { - props.onLongPress(e); - // TODO address this again at some point - // if (e.nativeEvent.defaultPrevented) { - // state.defaultPrevented = true; - // } - }; - dispatchEvent(context, state, 'longpress', listener); - } + state.longPressTimeout = context.setTimeout(() => { + state.isLongPressed = true; + state.longPressTimeout = null; + + if (props.onLongPress) { + const listener = e => { + props.onLongPress(e); + // TODO address this again at some point + // if (e.nativeEvent.defaultPrevented) { + // state.defaultPrevented = true; + // } + }; + dispatchEvent(context, state, 'longpress', listener); + } - if (props.onLongPressChange) { - dispatchLongPressChangeEvent(context, props, state); - } - }), - delayLongPress, - ); + if (props.onLongPressChange) { + dispatchLongPressChangeEvent(context, props, state); + } + }, delayLongPress); } } function dispatchPressEndEvents( - context: EventResponderContext, + context: ResponderContext, props: PressProps, state: PressState, ): void { @@ -206,6 +202,21 @@ function calculateDelayMS(delay: ?number, min = 0, fallback = 0) { return Math.max(min, maybeNumber != null ? maybeNumber : fallback); } +function unmountResponder( + context: ResponderContext, + props: PressProps, + state: PressState, +): void { + if (state.isPressed) { + state.isPressed = false; + dispatchPressEndEvents(context, props, state); + if (state.longPressTimeout !== null) { + clearTimeout(state.longPressTimeout); + state.longPressTimeout = null; + } + } +} + const PressResponder = { targetEventTypes, createInitialState(): PressState { @@ -219,14 +230,15 @@ const PressResponder = { shouldSkipMouseAfterTouch: false, }; }, - handleEvent( - context: EventResponderContext, + onEvent( + event: ResponderEvent, + context: ResponderContext, props: PressProps, state: PressState, ): void { - const {eventTarget, eventType, event} = context; + const {target, type, nativeEvent} = event; - switch (eventType) { + switch (type) { /** * Respond to pointer events and fall back to mouse. */ @@ -234,29 +246,29 @@ const PressResponder = { case 'mousedown': { if ( !state.isPressed && - !context.isTargetOwned(eventTarget) && + !context.hasOwnership() && !state.shouldSkipMouseAfterTouch ) { if ( - (event: any).pointerType === 'mouse' || - eventType === 'mousedown' + (nativeEvent: any).pointerType === 'mouse' || + type === 'mousedown' ) { if ( // Ignore right- and middle-clicks - event.button === 1 || - event.button === 2 || + nativeEvent.button === 1 || + nativeEvent.button === 2 || // Ignore pressing on hit slop area with mouse context.isPositionWithinTouchHitTarget( - (event: any).x, - (event: any).y, + (nativeEvent: any).x, + (nativeEvent: any).y, ) ) { return; } } - state.pressTarget = eventTarget; + state.pressTarget = target; dispatchPressStartEvents(context, props, state); - context.addRootEventTypes(rootEventTypes); + context.addRootEventTypes(target.ownerDocument, rootEventTypes); } break; } @@ -273,7 +285,7 @@ const PressResponder = { dispatchPressEndEvents(context, props, state); if (state.pressTarget !== null && props.onPress) { - if (context.isTargetWithinElement(eventTarget, state.pressTarget)) { + if (context.isTargetWithinElement(target, state.pressTarget)) { if ( !( wasLongPressed && @@ -303,16 +315,16 @@ const PressResponder = { * support for pointer events. */ case 'touchstart': { - if (!state.isPressed && !context.isTargetOwned(eventTarget)) { + 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; + state.pressTarget = target; dispatchPressStartEvents(context, props, state); - context.addRootEventTypes(rootEventTypes); + context.addRootEventTypes(target.ownerDocument, rootEventTypes); } break; } @@ -326,17 +338,17 @@ const PressResponder = { dispatchPressEndEvents(context, props, state); - if (eventType !== 'touchcancel' && props.onPress) { + 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 ( !( @@ -363,21 +375,21 @@ const PressResponder = { if ( !state.isPressed && !state.isLongPressed && - !context.isTargetOwned(eventTarget) && - isValidKeyPress((event: any).key) + !context.hasOwnership() && + isValidKeyPress((nativeEvent: any).key) ) { // Prevent spacebar press from scrolling the window - if ((event: any).key === ' ') { - (event: any).preventDefault(); + if ((nativeEvent: any).key === ' ') { + (nativeEvent: any).preventDefault(); } - state.pressTarget = eventTarget; + state.pressTarget = target; dispatchPressStartEvents(context, props, state); - context.addRootEventTypes(rootEventTypes); + context.addRootEventTypes(target.ownerDocument, rootEventTypes); } break; } case 'keyup': { - if (state.isPressed && isValidKeyPress((event: any).key)) { + if (state.isPressed && isValidKeyPress((nativeEvent: any).key)) { const wasLongPressed = state.isLongPressed; dispatchPressEndEvents(context, props, state); if (state.pressTarget !== null && props.onPress) { @@ -410,12 +422,24 @@ const PressResponder = { case 'click': { if (state.defaultPrevented) { - (event: any).preventDefault(); + (nativeEvent: any).preventDefault(); state.defaultPrevented = false; } } } }, + // TODO This method doesn't work as of yet + onUnmount(context: ResponderContext, props: PressProps, state: PressState) { + unmountResponder(context, props, state); + }, + // TODO This method doesn't work as of yet + onOwnershipChange( + context: ResponderContext, + props: PressProps, + state: PressState, + ) { + unmountResponder(context, props, state); + }, }; export default { diff --git a/packages/react-events/src/Swipe.js b/packages/react-events/src/Swipe.js index 85df2cca3f10e..ed211c939ef48 100644 --- a/packages/react-events/src/Swipe.js +++ b/packages/react-events/src/Swipe.js @@ -7,7 +7,7 @@ * @flow */ -import type {EventResponderContext} from 'events/EventTypes'; +import type {ResponderEvent, ResponderContext} from 'events/EventTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; const targetEventTypes = ['pointerdown', 'pointercancel']; @@ -52,7 +52,7 @@ function createSwipeEvent( } function dispatchSwipeEvent( - context: EventResponderContext, + context: ResponderContext, name: SwipeEventType, listener: SwipeEvent => void, state: SwipeState, @@ -91,21 +91,22 @@ const SwipeResponder = { y: 0, }; }, - handleEvent( - context: EventResponderContext, + onEvent( + event: ResponderEvent, + context: ResponderContext, 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)) { + if (!state.isSwiping && !context.hasOwnership()) { let obj = event; - if (eventType === 'touchstart') { - obj = (event: any).targetTouches[0]; + 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__/TouchHitTarget-test.internal.js b/packages/react-events/src/__tests__/TouchHitTarget-test.internal.js index e47f7f3cb4714..d30a6c92c2735 100644 --- a/packages/react-events/src/__tests__/TouchHitTarget-test.internal.js +++ b/packages/react-events/src/__tests__/TouchHitTarget-test.internal.js @@ -22,7 +22,7 @@ let TouchHitTarget; const noOpResponder = { targetEventTypes: [], - handleEvent() {}, + onEvent() {}, }; function createReactEventComponent() { diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index 35b62021d574f..4448f129fbff1 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -624,6 +624,7 @@ export function createFiberFromEventComponent( fiber.elementType = eventComponent; fiber.type = eventComponent; fiber.stateNode = { + context: null, props: pendingProps, state: null, }; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 52ce27f9dfb10..0988ea8d1efbc 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -7,6 +7,8 @@ * @flow */ +import type {ResponderEvent, ResponderContext} from 'events/EventTypes'; + export type ReactNode = | React$Element | ReactPortal @@ -88,7 +90,18 @@ export type ReactEventResponderEventType = export type ReactEventResponder = { targetEventTypes: Array, createInitialState?: (props: Object) => Object, - handleEvent: (context: Object, props: Object, state: Object) => void, + onEvent: ( + event: ResponderEvent, + context: ResponderContext, + props: Object, + state: Object, + ) => void, + onUnmount: (context: ResponderContext, props: Object, state: Object) => void, + onOwnershipChange: ( + context: ResponderContext, + props: Object, + state: Object, + ) => void, }; export type ReactEventComponent = {| From 49595e921dcb748c0422bc7aaadf2d3288ff1c46 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 4 Apr 2019 16:31:22 -0700 Subject: [PATCH 09/41] [New Scheduler] Fix: Suspending an expired update (#15326) When an async update expires, React renders at the expiration time that corresponds to the current time, not at the original update's expiration time. That way, all the expired work in the tree is flushed in a single batch. This is implemented inside `renderRoot` by comparing the next render expiration time to the current time. If the current time is later, `renderRoot` will restart at the later time. Because of poor factoring, the check is currently performed right before entering the work loop. But the work loop is usually entered multiple times in a single callback: each time a component throws or suspends. This led to an infinite loop where React would detect that an update expired, restart at the current time, make a bit of progress, suspend, check for expired work again, and start the loop again. I fixed this by moving the expired work check to the beginning of `renderRoot`, so that it is not performed every time something suspends. This isn't ideal, because you could technically still fall into a loop if more than 10ms lapse in between exiting `renderRoot` and entering it again. The proper fix is to lift the check outside of `renderRoot` entirely so that the function can restart without checking for expired work again. Since this is exceedingly unlikely (and this whole thing is still behind a flag), I'll do the better fix in an already-planned follow up to fork `renderRoot` into separate functions for sync and async work. --- .../src/ReactFiberScheduler.new.js | 42 +++++++++++------- ...tSuspenseWithNoopRenderer-test.internal.js | 44 +++++++++++++++++++ 2 files changed, 71 insertions(+), 15 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberScheduler.new.js b/packages/react-reconciler/src/ReactFiberScheduler.new.js index e2b4517de23fe..c86bd58ce6a57 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.new.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.new.js @@ -742,26 +742,38 @@ function renderRoot( } 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) { - 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; diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js index b68b239f80e68..89f3f2a698c62 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js @@ -827,6 +827,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() { From c3cc936dae776e04d56e40b9062c4c25a666c07d Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Fri, 5 Apr 2019 03:58:04 -0700 Subject: [PATCH 10/41] Add Hover,Focus,Press docs to REAMDE (#15328) --- packages/react-events/README.md | 142 +++++++++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) diff --git a/packages/react-events/README.md b/packages/react-events/README.md index 14d0eeafa7886..b90e166156412 100644 --- a/packages/react-events/README.md +++ b/packages/react-events/README.md @@ -1,3 +1,143 @@ # `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.* + + +## 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. + +``` +type FocusEvent = {} +``` + +### 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. + +``` +type HoverEvent = {} +``` + +### disabled: boolean + +Disables all `Hover` events. + +### 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. + +### 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. + +### onHoverChange: boolean => void + +Called when the element changes hover state (i.e., after `onHoverStart` and +`onHoverEnd`). + +### delayHoverStart: number + +The duration of the delay between when hover starts and when `onHoverStart` is +called. + +### delayHoverEnd: number + +The duration of the delay between when hover ends and when `onHoverEnd` is +called. + + +## 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. + +``` +type PressEvent = {} +``` + +### disabled: boolean + +Disables all `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. + +### onPressEnd: (e: PressEvent) => void + +Called once the element is no longer pressed. It will be cancelled if the press +starts again before the `delayPressEnd` threshold is exceeded. + +### onPressChange: boolean => void + +Called when the element changes press state (i.e., after `onPressStart` and +`onPressEnd`). + +### onLongPress: (e: PressEvent) => void + +Called once the element has been pressed for the length of `delayLongPress`. + +### 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 after `onPressEnd` only if `onLongPressShouldCancelPress` returns +`false`. + +### delayPressStart: number + +The duration of a delay between when the press starts and when `onPressStart` is +called. This delay is cut short if the press ends released before the threshold +is exceeded. + +### delayPressEnd: number + +The duration of the delay between when the press ends and when `onPressEnd` is +called. + +### delayLongPress: number = 500ms + +The duration of a press before `onLongPress` and `onLongPressChange` are called. + +### pressRententionOffset: { top: number, right: number, bottom: number, right: number } + +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. From 958b6173fdcba503a4333d3a2e42c10781201f17 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Fri, 5 Apr 2019 04:23:51 -0700 Subject: [PATCH 11/41] Add delay props to Hover event module (#15325) --- packages/react-events/src/Hover.js | 140 +++++++++++----- packages/react-events/src/Swipe.js | 2 +- .../src/__tests__/Hover-test.internal.js | 149 +++++++++++++++++- 3 files changed, 249 insertions(+), 42 deletions(-) diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index 050a50bda0c31..2addb3185e519 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -20,9 +20,12 @@ type HoverProps = { }; type HoverState = { + isActiveHovered: boolean, isHovered: boolean, isInHitSlop: boolean, isTouched: boolean, + hoverStartTimeout: null | TimeoutID, + hoverEndTimeout: null | TimeoutID, }; type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange'; @@ -60,29 +63,67 @@ function createHoverEvent( }; } +function dispatchHoverChangeEvent( + event: ResponderEvent, + context: ResponderContext, + props: HoverProps, + state: HoverState, +): void { + const listener = () => { + props.onHoverChange(state.isActiveHovered); + }; + const syntheticEvent = createHoverEvent( + 'hoverchange', + event.target, + listener, + ); + context.dispatchEvent(syntheticEvent, {discrete: true}); +} + function dispatchHoverStartEvents( event: ResponderEvent, context: ResponderContext, props: HoverProps, + state: HoverState, ): void { const {nativeEvent, target} = event; if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) { return; } - if (props.onHoverStart) { - const syntheticEvent = createHoverEvent( - 'hoverstart', - target, - props.onHoverStart, - ); - context.dispatchEvent(syntheticEvent, {discrete: true}); + + state.isHovered = true; + + if (state.hoverEndTimeout !== null) { + clearTimeout(state.hoverEndTimeout); + state.hoverEndTimeout = null; } - if (props.onHoverChange) { - const listener = () => { - props.onHoverChange(true); - }; - const syntheticEvent = createHoverEvent('hoverchange', target, listener); - context.dispatchEvent(syntheticEvent, {discrete: true}); + + const dispatch = () => { + state.isActiveHovered = true; + + if (props.onHoverStart) { + const syntheticEvent = createHoverEvent( + 'hoverstart', + target, + props.onHoverStart, + ); + context.dispatchEvent(syntheticEvent, {discrete: true}); + } + if (props.onHoverChange) { + dispatchHoverChangeEvent(event, context, props, state); + } + }; + + if (!state.isActiveHovered) { + const delay = calculateDelayMS(props.delayHoverStart, 0, 0); + if (delay > 0) { + state.hoverStartTimeout = context.setTimeout(() => { + state.hoverStartTimeout = null; + dispatch(); + }, delay); + } else { + dispatch(); + } } } @@ -90,35 +131,63 @@ function dispatchHoverEndEvents( event: ResponderEvent, context: ResponderContext, props: HoverProps, + state: HoverState, ) { const {nativeEvent, target} = event; if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) { return; } - if (props.onHoverEnd) { - const syntheticEvent = createHoverEvent( - 'hoverend', - target, - props.onHoverEnd, - ); - context.dispatchEvent(syntheticEvent, {discrete: true}); + + state.isHovered = false; + + if (state.hoverStartTimeout !== null) { + clearTimeout(state.hoverStartTimeout); + state.hoverStartTimeout = null; } - if (props.onHoverChange) { - const listener = () => { - props.onHoverChange(false); - }; - const syntheticEvent = createHoverEvent('hoverchange', target, listener); - context.dispatchEvent(syntheticEvent, {discrete: true}); + + const dispatch = () => { + state.isActiveHovered = false; + + if (props.onHoverEnd) { + const syntheticEvent = createHoverEvent( + 'hoverend', + target, + props.onHoverEnd, + ); + context.dispatchEvent(syntheticEvent, {discrete: true}); + } + if (props.onHoverChange) { + dispatchHoverChangeEvent(event, context, props, state); + } + }; + + if (state.isActiveHovered) { + const delay = calculateDelayMS(props.delayHoverEnd, 0, 0); + if (delay > 0) { + state.hoverEndTimeout = context.setTimeout(() => { + dispatch(); + }, delay); + } else { + dispatch(); + } } } +function calculateDelayMS(delay: ?number, min = 0, fallback = 0) { + const maybeNumber = delay == null ? null : delay; + return Math.max(min, maybeNumber != null ? maybeNumber : fallback); +} + const HoverResponder = { targetEventTypes, createInitialState() { return { + isActiveHovered: false, isHovered: false, isInHitSlop: false, isTouched: false, + hoverStartTimeout: null, + hoverEndTimeout: null, }; }, onEvent( @@ -156,23 +225,22 @@ const HoverResponder = { state.isInHitSlop = true; return; } - dispatchHoverStartEvents(event, context, props); - state.isHovered = true; + dispatchHoverStartEvents(event, context, props, state); } break; } case 'pointerout': case 'mouseout': { if (state.isHovered && !state.isTouched) { - dispatchHoverEndEvents(event, context, props); - state.isHovered = false; + dispatchHoverEndEvents(event, context, props, state); } state.isInHitSlop = false; state.isTouched = false; break; } + case 'pointermove': { - if (!state.isTouched) { + if (state.isHovered && !state.isTouched) { if (state.isInHitSlop) { if ( !context.isPositionWithinTouchHitTarget( @@ -180,8 +248,7 @@ const HoverResponder = { (nativeEvent: any).y, ) ) { - dispatchHoverStartEvents(event, context, props); - state.isHovered = true; + dispatchHoverStartEvents(event, context, props, state); state.isInHitSlop = false; } } else if ( @@ -191,17 +258,16 @@ const HoverResponder = { (nativeEvent: any).y, ) ) { - dispatchHoverEndEvents(event, context, props); - state.isHovered = false; + dispatchHoverEndEvents(event, context, props, state); state.isInHitSlop = true; } } break; } + case 'pointercancel': { if (state.isHovered && !state.isTouched) { - dispatchHoverEndEvents(event, context, props); - state.isHovered = false; + dispatchHoverEndEvents(event, context, props, state); state.isTouched = false; } break; diff --git a/packages/react-events/src/Swipe.js b/packages/react-events/src/Swipe.js index ed211c939ef48..9d199d9fce69e 100644 --- a/packages/react-events/src/Swipe.js +++ b/packages/react-events/src/Swipe.js @@ -104,7 +104,7 @@ const SwipeResponder = { case 'mousedown': case 'pointerdown': { if (!state.isSwiping && !context.hasOwnership()) { - let obj = event; + let obj = nativeEvent; if (type === 'touchstart') { obj = (nativeEvent: any).targetTouches[0]; state.touchId = obj.identifier; diff --git a/packages/react-events/src/__tests__/Hover-test.internal.js b/packages/react-events/src/__tests__/Hover-test.internal.js index bd449d8f2348b..c7cb373a1626b 100644 --- a/packages/react-events/src/__tests__/Hover-test.internal.js +++ b/packages/react-events/src/__tests__/Hover-test.internal.js @@ -84,8 +84,70 @@ describe('Hover event responder', () => { expect(onHoverStart).not.toBeCalled(); }); - // TODO: complete delayHoverStart tests - // describe('delayHoverStart', () => {}); + 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('onHoverStart is called synchronously if delay is 0ms', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); + + 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('onHoverStart is not called if "pointerout" is dispatched during a delay', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + jest.advanceTimersByTime(499); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.advanceTimersByTime(1); + expect(onHoverStart).not.toBeCalled(); + }); + }); }); describe('onHoverChange', () => { @@ -183,8 +245,87 @@ describe('Hover event responder', () => { expect(onHoverEnd).not.toBeCalled(); }); - // TODO: complete delayHoverStart tests - // describe('delayHoverEnd', () => {}); + 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(); + }); + }); }); it('expect displayName to show up for event component', () => { From 4fbbae8afa62b61c84cf1ca0cd7a1f5a413f59df Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Sat, 6 Apr 2019 07:51:21 +0100 Subject: [PATCH 12/41] Add full TouchHitTarget hit slop (experimental event API) to ReactDOM (#15308) --- packages/events/EventTypes.js | 6 +- packages/react-art/src/ReactARTHostConfig.js | 26 +- .../src/client/ReactDOMHostConfig.js | 134 +++++-- .../src/events/DOMEventResponderSystem.js | 31 +- .../src/server/ReactPartialRenderer.js | 24 ++ packages/react-events/src/Hover.js | 5 +- packages/react-events/src/Press.js | 1 + .../__tests__/TouchHitTarget-test.internal.js | 344 +++++++++++++++++- .../src/ReactFabricHostConfig.js | 26 +- .../src/ReactNativeHostConfig.js | 24 +- .../src/createReactNoop.js | 84 +++-- .../src/ReactFiberBeginWork.js | 33 +- .../src/ReactFiberCommitWork.js | 45 ++- .../src/ReactFiberCompleteWork.js | 21 +- .../ReactFiberEvents-test-internal.js | 252 +++++-------- .../src/forks/ReactFiberHostConfig.custom.js | 5 + .../src/ReactTestHostConfig.js | 79 +++- packages/shared/HostConfigWithNoHydration.js | 2 + .../shared/HostConfigWithNoPersistence.js | 1 + 19 files changed, 848 insertions(+), 295 deletions(-) diff --git a/packages/events/EventTypes.js b/packages/events/EventTypes.js index ab5c51db640a0..6f1f5e3b4a631 100644 --- a/packages/events/EventTypes.js +++ b/packages/events/EventTypes.js @@ -34,7 +34,11 @@ export type ResponderContext = { parentTarget: Element | Document, ) => boolean, isTargetWithinEventComponent: (Element | Document) => boolean, - isPositionWithinTouchHitTarget: (x: number, y: number) => boolean, + isPositionWithinTouchHitTarget: ( + doc: Document, + x: number, + y: number, + ) => boolean, addRootEventTypes: ( document: Document, rootEventTypes: Array, diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index 347693416591f..ef5b5914bfd34 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -443,15 +443,31 @@ export function handleEventComponent( eventResponder: ReactEventResponder, rootContainerInstance: Container, internalInstanceHandle: Object, -) { - // TODO: add handleEventComponent implementation +): 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-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index ef96ee8f944bc..27b00f7da913b 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, @@ -46,6 +46,7 @@ import dangerousStyleValue from '../shared/dangerousStyleValue'; import type {DOMContainer} from './ReactDOM'; import type {ReactEventResponder} from 'shared/ReactTypes'; import {REACT_EVENT_TARGET_TOUCH_HIT} from 'shared/ReactSymbols'; +import {canUseDOM} from 'shared/ExecutionEnvironment'; export type Type = string; export type Props = { @@ -57,6 +58,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 +88,6 @@ type HostContextDev = { eventData: null | {| isEventComponent?: boolean, isEventTarget?: boolean, - eventTargetType?: null | Symbol | number, |}, }; type HostContextProd = string; @@ -86,6 +103,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 +209,6 @@ export function getChildHostContextForEventComponent( const eventData = { isEventComponent: true, isEventTarget: false, - eventTargetType: null, }; return {namespace, ancestorInfo, eventData}; } @@ -204,17 +222,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 +274,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 +380,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, - ); } } } @@ -899,16 +901,74 @@ export function handleEventComponent( } } +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 7630d8d7b427f..5f12b84a1c5dc 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -17,7 +17,10 @@ import { PASSIVE_NOT_SUPPORTED, } from 'events/EventSystemFlags'; import type {AnyNativeEvent} from 'events/PluginModuleType'; -import {EventComponent} from 'shared/ReactWorkTags'; +import { + EventComponent, + EventTarget as EventTargetWorkTag, +} from 'shared/ReactWorkTags'; import type { ReactEventResponder, ReactEventResponderEventType, @@ -110,7 +113,31 @@ const eventResponderContext: ResponderContext = { eventsWithStopPropagation.add(eventObject); } }, - isPositionWithinTouchHitTarget(x: number, y: number): boolean { + isPositionWithinTouchHitTarget(doc: Document, x: number, y: number): boolean { + // 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 { diff --git a/packages/react-dom/src/server/ReactPartialRenderer.js b/packages/react-dom/src/server/ReactPartialRenderer.js index 7eecd8dd41b03..c493c0398ce5a 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,29 @@ 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 + ) { + const props = nextElement.props; + const bottom = props.bottom || 0; + const left = props.left || 0; + const right = props.right || 0; + const top = props.top || 0; + + if (bottom === 0 && left === 0 && right === 0 && top === 0) { + return ''; + } + let topString = top ? `-${top}px` : '0px'; + let leftString = left ? `-${left}px` : '0px'; + let rightString = right ? `-${right}px` : '0x'; + let bottomString = bottom ? `-${bottom}px` : '0px'; + + return ( + `
` + ); + } const nextChildren = toArray( ((nextChild: any): ReactElement).props.children, ); diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index 2addb3185e519..0668880268476 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -196,7 +196,7 @@ const HoverResponder = { props: HoverProps, state: HoverState, ): void { - const {type, nativeEvent} = event; + const {type, target, nativeEvent} = event; switch (type) { /** @@ -218,6 +218,7 @@ const HoverResponder = { } if ( context.isPositionWithinTouchHitTarget( + target.ownerDocument, (nativeEvent: any).x, (nativeEvent: any).y, ) @@ -244,6 +245,7 @@ const HoverResponder = { if (state.isInHitSlop) { if ( !context.isPositionWithinTouchHitTarget( + target.ownerDocument, (nativeEvent: any).x, (nativeEvent: any).y, ) @@ -254,6 +256,7 @@ const HoverResponder = { } else if ( state.isHovered && context.isPositionWithinTouchHitTarget( + target.ownerDocument, (nativeEvent: any).x, (nativeEvent: any).y, ) diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index ab2750d6271b8..2615826b16c45 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -259,6 +259,7 @@ const PressResponder = { nativeEvent.button === 2 || // Ignore pressing on hit slop area with mouse context.isPositionWithinTouchHitTarget( + target.ownerDocument, (nativeEvent: any).x, (nativeEvent: any).y, ) diff --git a/packages/react-events/src/__tests__/TouchHitTarget-test.internal.js b/packages/react-events/src/__tests__/TouchHitTarget-test.internal.js index d30a6c92c2735..e0bb517518be3 100644 --- a/packages/react-events/src/__tests__/TouchHitTarget-test.internal.js +++ b/packages/react-events/src/__tests__/TouchHitTarget-test.internal.js @@ -16,6 +16,7 @@ let ReactFeatureFlags; let EventComponent; let ReactTestRenderer; let ReactDOM; +let ReactDOMServer; let ReactSymbols; let ReactEvents; let TouchHitTarget; @@ -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,319 @@ 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', () => { + const Test = () => ( + +
+ +
+
+ ); + + const container = document.createElement('div'); + container.innerHTML = '
'; + ReactDOM.hydrate(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe('
'); + + const Test2 = () => ( + +
+ +
+
+ ); + + const container2 = document.createElement('div'); + container2.innerHTML = + '
'; + ReactDOM.hydrate(, container2); + expect(Scheduler).toFlushWithoutYielding(); + expect(container2.innerHTML).toBe( + '
', + ); + }); + + 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 with 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/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index c2ccde544cb98..5f55444bff482 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -438,15 +438,31 @@ export function handleEventComponent( eventResponder: ReactEventResponder, rootContainerInstance: Container, internalInstanceHandle: Object, -) { - // TODO: add handleEventComponent implementation +): 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/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index f4b24a1c39f4b..55fe1ba868b0e 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -498,14 +498,30 @@ export function handleEventComponent( rootContainerInstance: Container, internalInstanceHandle: Object, ) { - // TODO: add handleEventComponent implementation + 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-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 55198b6ba43ad..de42992a6bb2d 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(); @@ -431,15 +435,51 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { // 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/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 2832021950f69..3c66c38e229d8 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -96,6 +96,7 @@ import { registerSuspenseInstanceRetry, } from './ReactFiberHostConfig'; import type {SuspenseInstance} from './ReactFiberHostConfig'; +import {getEventTargetChildElement} from './ReactFiberHostConfig'; import {shouldSuspend} from './ReactFiberReconciler'; import { pushHostContext, @@ -1988,15 +1989,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..dfba03702199d 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,7 @@ import { IncompleteClassComponent, MemoComponent, SimpleMemoComponent, + EventTarget, } from 'shared/ReactWorkTags'; import { invokeGuardedCallback, @@ -90,6 +92,7 @@ import { hideTextInstance, unhideInstance, unhideTextInstance, + commitEventTarget, } from './ReactFiberHostConfig'; import { captureCommitPhaseError, @@ -299,6 +302,7 @@ function commitBeforeMutationLifeCycles( case HostText: case HostPortal: case IncompleteClassComponent: + case EventTarget: // Nothing to do for these component types return; default: { @@ -585,6 +589,7 @@ function commitLifeCycles( } case SuspenseComponent: case IncompleteClassComponent: + case EventTarget: break; default: { invariant( @@ -817,7 +822,8 @@ function commitContainer(finishedWork: Fiber) { switch (finishedWork.tag) { case ClassComponent: case HostComponent: - case HostText: { + case HostText: + case EventTarget: { return; } case HostRoot: @@ -955,17 +961,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 +1202,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; } diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index abd125d3acb78..457a6b7289a30 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -784,18 +784,15 @@ 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, + ); + if (shouldUpdate) { + markUpdate(workInProgress); } } break; 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/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index d1f38c65d22a3..9a571b168f4bc 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -65,6 +65,8 @@ export const supportsPersistence = $$$hostConfig.supportsPersistence; export const supportsHydration = $$$hostConfig.supportsHydration; export const handleEventComponent = $$$hostConfig.handleEventComponent; export const handleEventTarget = $$$hostConfig.handleEventTarget; +export const getEventTargetChildElement = + $$$hostConfig.getEventTargetChildElement; // ------------------- // Mutation @@ -84,6 +86,9 @@ export const hideInstance = $$$hostConfig.hideInstance; export const hideTextInstance = $$$hostConfig.hideTextInstance; export const unhideInstance = $$$hostConfig.unhideInstance; export const unhideTextInstance = $$$hostConfig.unhideTextInstance; +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..cb9dceec361b8 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -14,6 +14,18 @@ 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. ' + @@ -329,17 +331,62 @@ export function handleEventComponent( eventResponder: ReactEventResponder, rootContainerInstance: Container, internalInstanceHandle: Object, -) { - // TODO: add handleEventComponent implementation +): 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/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; From 4064ea9fa6387c92a985b52bfc66746f81ccd4fd Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Sat, 6 Apr 2019 08:16:57 +0100 Subject: [PATCH 13/41] Experimental event API: Support EventComponent onUnmount responder callback (#15335) --- packages/react-art/src/ReactARTHostConfig.js | 7 ++++ .../src/client/ReactDOMHostConfig.js | 13 ++++++- .../src/events/DOMEventResponderSystem.js | 36 ++++++++++++++++++- .../DOMEventResponderSystem-test.internal.js | 25 ++++++++++++- packages/react-events/src/Press.js | 2 +- .../src/ReactFabricHostConfig.js | 7 ++++ .../src/ReactNativeHostConfig.js | 9 ++++- .../src/createReactNoop.js | 6 +++- packages/react-reconciler/src/ReactFiber.js | 1 + .../src/ReactFiberCommitWork.js | 10 ++++++ .../src/ReactFiberCompleteWork.js | 4 ++- .../src/forks/ReactFiberHostConfig.custom.js | 1 + .../src/ReactTestHostConfig.js | 9 ++++- 13 files changed, 122 insertions(+), 8 deletions(-) diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index ef5b5914bfd34..0ab7413763b71 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -442,6 +442,13 @@ export function unhideTextInstance(textInstance, text): void { export function handleEventComponent( eventResponder: ReactEventResponder, rootContainerInstance: Container, +) { + throw new Error('Not yet implemented.'); +} + +export function unmountEventComponent( + eventResponder: ReactEventResponder, + rootContainerInstance: Container, internalInstanceHandle: Object, ): void { throw new Error('Not yet implemented.'); diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 27b00f7da913b..4d95d9c24cd9d 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -45,6 +45,7 @@ import dangerousStyleValue from '../shared/dangerousStyleValue'; import type {DOMContainer} from './ReactDOM'; import type {ReactEventResponder} from 'shared/ReactTypes'; +import {unmountEventResponder} from '../events/DOMEventResponderSystem'; import {REACT_EVENT_TARGET_TOUCH_HIT} from 'shared/ReactSymbols'; import {canUseDOM} from 'shared/ExecutionEnvironment'; @@ -890,7 +891,6 @@ export function didNotFindHydratableSuspenseInstance( export function handleEventComponent( eventResponder: ReactEventResponder, rootContainerInstance: Container, - internalInstanceHandle: Object, ): void { if (enableEventAPI) { const rootElement = rootContainerInstance.ownerDocument; @@ -901,6 +901,17 @@ export function handleEventComponent( } } +export function unmountEventComponent( + eventResponder: ReactEventResponder, + rootContainerInstance: Container, + internalInstanceHandle: Object, +): void { + if (enableEventAPI) { + // TODO stop listening to targetEventTypes + unmountEventResponder(eventResponder, internalInstanceHandle); + } +} + export function getEventTargetChildElement( type: Symbol | number, props: Props, diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 5f12b84a1c5dc..8be163f1048cc 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -364,10 +364,17 @@ function handleTopLevelType( if (state === null && responder.createInitialState !== undefined) { state = fiber.stateNode.state = responder.createInitialState(props); } + const previousFiber = currentFiber; + const previousResponder = currentResponder; currentFiber = fiber; currentResponder = responder; - responder.onEvent(responderEvent, eventResponderContext, props, state); + try { + responder.onEvent(responderEvent, eventResponderContext, props, state); + } finally { + currentFiber = previousFiber; + currentResponder = previousResponder; + } } export function runResponderEventsInBatch( @@ -413,3 +420,30 @@ export function runResponderEventsInBatch( processEventQueue(); } } + +export function unmountEventResponder( + responder: ReactEventResponder, + fiber: Fiber, +): void { + const onUnmount = responder.onUnmount; + if (onUnmount !== undefined) { + let {props, state} = fiber.stateNode; + const previousEventQueue = currentEventQueue; + const previousFiber = currentFiber; + const previousResponder = currentResponder; + currentEventQueue = createEventQueue(); + currentFiber = fiber; + currentResponder = responder; + try { + onUnmount(eventResponderContext, props, state); + } finally { + currentEventQueue = previousEventQueue; + currentFiber = previousFiber; + currentResponder = previousResponder; + } + } + if (currentOwner === fiber) { + // TODO fire owner changed callback + currentOwner = null; + } +} 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 c304781341d66..2692ff994b250 100644 --- a/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js @@ -13,10 +13,11 @@ let React; let ReactFeatureFlags; let ReactDOM; -function createReactEventComponent(targetEventTypes, onEvent) { +function createReactEventComponent(targetEventTypes, onEvent, onUnmount) { const testEventResponder = { targetEventTypes, onEvent, + onUnmount, }; return { @@ -316,4 +317,26 @@ describe('DOMEventResponderSystem', () => { expect(eventLog).toEqual(['press', 'longpress', 'longpresschange']); }); + + it('the event responder onUnmount() function should fire', () => { + let onUnmountFired = 0; + + const EventComponent = createReactEventComponent( + [], + (event, context, props) => {}, + () => { + onUnmountFired++; + }, + ); + + const Test = () => ( + +
, {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 diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 3c66c38e229d8..8659e010a9847 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -1413,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; diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 1c0ed5b82cebd..3bf04ea9c11a7 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -63,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'; @@ -1272,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 62915dd7a6a18..41b4ba71c4dfc 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -18,6 +18,7 @@ import type { ChildSet, } from './ReactFiberHostConfig'; import type {ReactEventComponentInstance} from 'shared/ReactTypes'; +import type {SuspenseState} from './ReactFiberSuspenseComponent'; import { IndeterminateComponent, @@ -42,6 +43,7 @@ import { EventComponent, EventTarget, } from 'shared/ReactWorkTags'; +import {ConcurrentMode, NoContext} from './ReactTypeOfMode'; import { Placement, Ref, @@ -92,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 @@ -665,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; @@ -674,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 fallbackExpirationTimeExpTime: ExpirationTime = + prevState.fallbackExpirationTime; + markRenderEventTime(fallbackExpirationTimeExpTime); + + // 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 @@ -710,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 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 975d4ada3f7df..b5b7f9dd79f7a 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -34,6 +34,7 @@ import { flushPassiveEffects, requestCurrentTime, warnIfNotCurrentlyActingUpdatesInDev, + markRenderEventTime, } from './ReactFiberScheduler'; import invariant from 'shared/invariant'; @@ -718,6 +719,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 index 3e3a038ef58ef..43e241d3a2683 100644 --- a/packages/react-reconciler/src/ReactFiberPendingPriority.js +++ b/packages/react-reconciler/src/ReactFiberPendingPriority.js @@ -219,23 +219,6 @@ function clearPing(root, completedTime) { } } -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, diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index fb94b012ef904..f58aaa7913c9a 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -14,6 +14,7 @@ import { computeExpirationForFiber as computeExpirationForFiber_old, captureCommitPhaseError as captureCommitPhaseError_old, onUncaughtError as onUncaughtError_old, + markRenderEventTime as markRenderEventTime_old, renderDidSuspend as renderDidSuspend_old, renderDidError as renderDidError_old, pingSuspendedRoot as pingSuspendedRoot_old, @@ -34,7 +35,6 @@ import { computeUniqueAsyncExpiration as computeUniqueAsyncExpiration_old, flushPassiveEffects as flushPassiveEffects_old, warnIfNotCurrentlyActingUpdatesInDev as warnIfNotCurrentlyActingUpdatesInDev_old, - inferStartTimeFromExpirationTime as inferStartTimeFromExpirationTime_old, } from './ReactFiberScheduler.old'; import { @@ -42,6 +42,7 @@ import { computeExpirationForFiber as computeExpirationForFiber_new, captureCommitPhaseError as captureCommitPhaseError_new, onUncaughtError as onUncaughtError_new, + markRenderEventTime as markRenderEventTime_new, renderDidSuspend as renderDidSuspend_new, renderDidError as renderDidError_new, pingSuspendedRoot as pingSuspendedRoot_new, @@ -62,7 +63,6 @@ import { computeUniqueAsyncExpiration as computeUniqueAsyncExpiration_new, flushPassiveEffects as flushPassiveEffects_new, warnIfNotCurrentlyActingUpdatesInDev as warnIfNotCurrentlyActingUpdatesInDev_new, - inferStartTimeFromExpirationTime as inferStartTimeFromExpirationTime_new, } from './ReactFiberScheduler.new'; export const requestCurrentTime = enableNewScheduler @@ -77,6 +77,9 @@ export const captureCommitPhaseError = enableNewScheduler export const onUncaughtError = enableNewScheduler ? onUncaughtError_new : onUncaughtError_old; +export const markRenderEventTime = enableNewScheduler + ? markRenderEventTime_new + : markRenderEventTime_old; export const renderDidSuspend = enableNewScheduler ? renderDidSuspend_new : renderDidSuspend_old; @@ -133,9 +136,6 @@ export const flushPassiveEffects = enableNewScheduler export const warnIfNotCurrentlyActingUpdatesInDev = enableNewScheduler ? warnIfNotCurrentlyActingUpdatesInDev_new : warnIfNotCurrentlyActingUpdatesInDev_old; -export const inferStartTimeFromExpirationTime = enableNewScheduler - ? inferStartTimeFromExpirationTime_new - : inferStartTimeFromExpirationTime_old; export type Thenable = { then(resolve: () => mixed, reject?: () => mixed): void | Thenable, diff --git a/packages/react-reconciler/src/ReactFiberScheduler.new.js b/packages/react-reconciler/src/ReactFiberScheduler.new.js index 26a2e12ed1d90..669f121c5433f 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.new.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.new.js @@ -194,7 +194,11 @@ let workInProgress: Fiber | null = null; let renderExpirationTime: ExpirationTime = NoWork; // Whether to root completed, errored, suspended, etc. let workInProgressRootExitStatus: RootExitStatus = RootIncomplete; -let workInProgressRootAbsoluteTimeoutMs: number = -1; +// 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; @@ -678,7 +682,7 @@ function prepareFreshStack(root, expirationTime) { workInProgress = createWorkInProgress(root.current, null, expirationTime); renderExpirationTime = expirationTime; workInProgressRootExitStatus = RootIncomplete; - workInProgressRootAbsoluteTimeoutMs = -1; + workInProgressRootMostRecentEventTime = Sync; if (__DEV__) { ReactStrictModeWarnings.discardPendingWarnings(); @@ -878,26 +882,30 @@ function renderRoot( 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, + 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) { + const msUntilTimeout = computeMsUntilTimeout( + workInProgressRootMostRecentEventTime, ); - return null; + 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. @@ -913,20 +921,15 @@ function renderRoot( } } -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 markRenderEventTime(expirationTime: ExpirationTime): void { + if (expirationTime < workInProgressRootMostRecentEventTime) { + workInProgressRootMostRecentEventTime = expirationTime; + } +} + +export function renderDidSuspend(): void { + if (workInProgressRootExitStatus === RootIncomplete) { + workInProgressRootExitStatus = RootSuspended; } } @@ -939,6 +942,13 @@ export function renderDidError() { } } +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) { @@ -1805,37 +1815,20 @@ export function resolveRetryThenable(boundaryFiber: Fiber, thenable: 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) { +function computeMsUntilTimeout(mostRecentEventTime: ExpirationTime) { 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; - } + const eventTimeMs: number = inferTimeFromExpirationTime(mostRecentEventTime); + const currentTimeMs: number = now(); + const timeElapsed = currentTimeMs - eventTimeMs; - // 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(); + // TODO: Account for the Just Noticeable Difference + const timeoutMs = 150; + const msUntilTimeout = timeoutMs - timeElapsed; + // This is the value that is passed to `setTimeout`. return msUntilTimeout < 0 ? 0 : msUntilTimeout; } diff --git a/packages/react-reconciler/src/ReactFiberScheduler.old.js b/packages/react-reconciler/src/ReactFiberScheduler.old.js index 965eaa1cdc62c..8614c2eb15de7 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.old.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.old.js @@ -91,7 +91,6 @@ import { markPingedPriorityLevel, hasLowerPriorityWork, isPriorityLevelSuspended, - findEarliestOutstandingPriorityLevel, didExpireAtExpirationTime, } from './ReactFiberPendingPriority'; import { @@ -272,7 +271,8 @@ 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 mostRecentEventTime: ExpirationTime = Sync; +let nextRenderDidSuspend: boolean = false; let nextRenderDidError: boolean = false; // The next fiber with an effect that we're currently committing. @@ -399,7 +399,8 @@ function resetStack() { nextRoot = null; nextRenderExpirationTime = NoWork; - nextLatestAbsoluteTimeoutMs = -1; + mostRecentEventTime = Sync; + nextRenderDidSuspend = false; nextRenderDidError = false; nextUnitOfWork = null; } @@ -1502,32 +1503,25 @@ function renderRoot(root: FiberRoot, isYieldy: boolean): void { } } - if (isYieldy && nextLatestAbsoluteTimeoutMs !== -1) { + // Check if we should suspend this commit. + // If mostRecentEventTime 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 (isYieldy && nextRenderDidSuspend && mostRecentEventTime !== Sync) { // 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 eventTimeMs: number = inferTimeFromExpirationTime( + mostRecentEventTime, ); - 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; + const currentTimeMs: number = now(); + const timeElapsed = currentTimeMs - eventTimeMs; // TODO: Account for the Just Noticeable Difference + const timeoutMs = 150; + let msUntilTimeout = timeoutMs - timeElapsed; + msUntilTimeout = msUntilTimeout < 0 ? 0 : msUntilTimeout; const rootExpirationTime = root.expirationTime; onSuspend( @@ -1662,41 +1656,27 @@ function computeExpirationForFiber(currentTime: ExpirationTime, fiber: Fiber) { return expirationTime; } -function renderDidSuspend( - root: FiberRoot, - absoluteTimeoutMs: number, - suspendedTime: ExpirationTime, -) { - // Schedule the timeout. - if ( - absoluteTimeoutMs >= 0 && - nextLatestAbsoluteTimeoutMs < absoluteTimeoutMs - ) { - nextLatestAbsoluteTimeoutMs = absoluteTimeoutMs; +function markRenderEventTime(expirationTime: ExpirationTime): void { + if (expirationTime < mostRecentEventTime) { + mostRecentEventTime = expirationTime; } } +function renderDidSuspend() { + nextRenderDidSuspend = true; +} + function renderDidError() { nextRenderDidError = true; } -function inferStartTimeFromExpirationTime( - root: FiberRoot, - expirationTime: ExpirationTime, -) { +function inferTimeFromExpirationTime(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, + // approximate start time from the expiration time. + const earliestExpirationTimeMs = expirationTimeToMs(expirationTime); + return ( + earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION + originalStartTimeMs ); - const earliestExpirationTimeMs = expirationTimeToMs(earliestExpirationTime); - return earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION; } function pingSuspendedRoot( @@ -2692,6 +2672,7 @@ export { computeExpirationForFiber, captureCommitPhaseError, onUncaughtError, + markRenderEventTime, renderDidSuspend, renderDidError, pingSuspendedRoot, @@ -2711,5 +2692,4 @@ export { 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/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/__tests__/ReactSuspensePlaceholder-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js index 0711e244f2dce..529bb33fc93f5 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js @@ -490,8 +490,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 +507,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); From c64b330032cef887ac5f787ac4a50bb4eeca4e1d Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Wed, 10 Apr 2019 09:55:56 +0100 Subject: [PATCH 24/41] Move EventTypes to ReactTypes (#15364) --- packages/events/EventTypes.js | 53 ------------------ .../src/events/DOMEventResponderSystem.js | 16 +++--- packages/react-events/src/Drag.js | 11 ++-- packages/react-events/src/Focus.js | 17 +++--- packages/react-events/src/Hover.js | 21 ++++---- packages/react-events/src/Press.js | 29 ++++++---- packages/react-events/src/Swipe.js | 11 ++-- packages/shared/ReactTypes.js | 54 ++++++++++++++++--- 8 files changed, 109 insertions(+), 103 deletions(-) delete mode 100644 packages/events/EventTypes.js diff --git a/packages/events/EventTypes.js b/packages/events/EventTypes.js deleted file mode 100644 index 6f1f5e3b4a631..0000000000000 --- a/packages/events/EventTypes.js +++ /dev/null @@ -1,53 +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 ResponderEvent = { - nativeEvent: AnyNativeEvent, - target: Element | Document, - type: string, - passive: boolean, - passiveSupported: boolean, -}; - -export type ResponderDispatchEventOptions = { - capture?: boolean, - discrete?: boolean, - stopPropagation?: boolean, -}; - -export type ResponderContext = { - dispatchEvent: ( - eventObject: Object, - otpions: ResponderDispatchEventOptions, - ) => 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) => TimeoutID, -}; diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 8b61c69d37426..ddadb18472328 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -6,11 +6,6 @@ * @flow */ -import type { - ResponderContext, - ResponderEvent, - ResponderDispatchEventOptions, -} from 'events/EventTypes'; import { type EventSystemFlags, IS_PASSIVE, @@ -24,6 +19,9 @@ import { import type { ReactEventResponderEventType, ReactEventComponentInstance, + ReactResponderContext, + ReactResponderEvent, + ReactResponderDispatchEventOptions, } from 'shared/ReactTypes'; import type {DOMTopLevelEventType} from 'events/TopLevelEventTypes'; import {batchedUpdates, interactiveUpdates} from 'events/ReactGenericBatching'; @@ -58,10 +56,10 @@ let currentOwner = null; let currentInstance: ReactEventComponentInstance; let currentEventQueue: EventQueue; -const eventResponderContext: ResponderContext = { +const eventResponderContext: ReactResponderContext = { dispatchEvent( possibleEventObject: Object, - {capture, discrete, stopPropagation}: ResponderDispatchEventOptions, + {capture, discrete, stopPropagation}: ReactResponderDispatchEventOptions, ): void { const eventQueue = currentEventQueue; const {listener, target, type} = possibleEventObject; @@ -260,7 +258,7 @@ function createResponderEvent( nativeEvent: AnyNativeEvent, nativeEventTarget: Element | Document, eventSystemFlags: EventSystemFlags, -): ResponderEvent { +): ReactResponderEvent { return { nativeEvent: nativeEvent, target: nativeEventTarget, @@ -342,7 +340,7 @@ function getTargetEventTypes( function handleTopLevelType( topLevelType: DOMTopLevelEventType, - responderEvent: ResponderEvent, + responderEvent: ReactResponderEvent, eventComponentInstance: ReactEventComponentInstance, isRootLevelEvent: boolean, ): void { diff --git a/packages/react-events/src/Drag.js b/packages/react-events/src/Drag.js index 86cb120af2667..80e1be4966d50 100644 --- a/packages/react-events/src/Drag.js +++ b/packages/react-events/src/Drag.js @@ -7,7 +7,10 @@ * @flow */ -import type {ResponderEvent, ResponderContext} from 'events/EventTypes'; +import type { + ReactResponderEvent, + ReactResponderContext, +} from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; const targetEventTypes = ['pointerdown', 'pointercancel']; @@ -62,7 +65,7 @@ function createDragEvent( } function dispatchDragEvent( - context: ResponderContext, + context: ReactResponderContext, name: DragEventType, listener: DragEvent => void, state: DragState, @@ -88,8 +91,8 @@ const DragResponder = { }; }, onEvent( - event: ResponderEvent, - context: ResponderContext, + event: ReactResponderEvent, + context: ReactResponderContext, props: Object, state: DragState, ): void { diff --git a/packages/react-events/src/Focus.js b/packages/react-events/src/Focus.js index 30ff7102ba4cc..19f555fe74ff2 100644 --- a/packages/react-events/src/Focus.js +++ b/packages/react-events/src/Focus.js @@ -7,7 +7,10 @@ * @flow */ -import type {ResponderEvent, ResponderContext} from 'events/EventTypes'; +import type { + ReactResponderEvent, + ReactResponderContext, +} from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; type FocusProps = { @@ -47,8 +50,8 @@ function createFocusEvent( } function dispatchFocusInEvents( - event: ResponderEvent, - context: ResponderContext, + event: ReactResponderEvent, + context: ReactResponderContext, props: FocusProps, ) { const {nativeEvent, target} = event; @@ -69,8 +72,8 @@ function dispatchFocusInEvents( } function dispatchFocusOutEvents( - event: ResponderEvent, - context: ResponderContext, + event: ReactResponderEvent, + context: ReactResponderContext, props: FocusProps, ) { const {nativeEvent, target} = event; @@ -98,8 +101,8 @@ const FocusResponder = { }; }, onEvent( - event: ResponderEvent, - context: ResponderContext, + event: ReactResponderEvent, + context: ReactResponderContext, props: Object, state: FocusState, ): void { diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index bc438d9c74ad8..116416bc7e865 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -7,7 +7,10 @@ * @flow */ -import type {ResponderEvent, ResponderContext} from 'events/EventTypes'; +import type { + ReactResponderEvent, + ReactResponderContext, +} from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; type HoverProps = { @@ -64,8 +67,8 @@ function createHoverEvent( } function dispatchHoverChangeEvent( - event: ResponderEvent, - context: ResponderContext, + event: ReactResponderEvent, + context: ReactResponderContext, props: HoverProps, state: HoverState, ): void { @@ -81,8 +84,8 @@ function dispatchHoverChangeEvent( } function dispatchHoverStartEvents( - event: ResponderEvent, - context: ResponderContext, + event: ReactResponderEvent, + context: ReactResponderContext, props: HoverProps, state: HoverState, ): void { @@ -132,8 +135,8 @@ function dispatchHoverStartEvents( } function dispatchHoverEndEvents( - event: ResponderEvent, - context: ResponderContext, + event: ReactResponderEvent, + context: ReactResponderContext, props: HoverProps, state: HoverState, ) { @@ -199,8 +202,8 @@ const HoverResponder = { }; }, onEvent( - event: ResponderEvent, - context: ResponderContext, + event: ReactResponderEvent, + context: ReactResponderContext, props: HoverProps, state: HoverState, ): void { diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index 5aacbbb44b018..b20acf52327ac 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -7,7 +7,10 @@ * @flow */ -import type {ResponderEvent, ResponderContext} from 'events/EventTypes'; +import type { + ReactResponderEvent, + ReactResponderContext, +} from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; type PressProps = { @@ -89,7 +92,7 @@ function createPressEvent( } function dispatchEvent( - context: ResponderContext, + context: ReactResponderContext, state: PressState, name: PressEventType, listener: (e: Object) => void, @@ -100,7 +103,7 @@ function dispatchEvent( } function dispatchPressChangeEvent( - context: ResponderContext, + context: ReactResponderContext, props: PressProps, state: PressState, ): void { @@ -111,7 +114,7 @@ function dispatchPressChangeEvent( } function dispatchLongPressChangeEvent( - context: ResponderContext, + context: ReactResponderContext, props: PressProps, state: PressState, ): void { @@ -150,7 +153,7 @@ function deactivate(context, props, state) { } function dispatchPressStartEvents( - context: ResponderContext, + context: ReactResponderContext, props: PressProps, state: PressState, ): void { @@ -212,7 +215,7 @@ function dispatchPressStartEvents( } function dispatchPressEndEvents( - context: ResponderContext, + context: ReactResponderContext, props: PressProps, state: PressState, ): void { @@ -265,7 +268,7 @@ function calculateDelayMS(delay: ?number, min = 0, fallback = 0) { } function unmountResponder( - context: ResponderContext, + context: ReactResponderContext, props: PressProps, state: PressState, ): void { @@ -293,8 +296,8 @@ const PressResponder = { }; }, onEvent( - event: ResponderEvent, - context: ResponderContext, + event: ReactResponderEvent, + context: ReactResponderContext, props: PressProps, state: PressState, ): void { @@ -491,12 +494,16 @@ const PressResponder = { } } }, - onUnmount(context: ResponderContext, props: PressProps, state: PressState) { + onUnmount( + context: ReactResponderContext, + props: PressProps, + state: PressState, + ) { unmountResponder(context, props, state); }, // TODO This method doesn't work as of yet onOwnershipChange( - context: ResponderContext, + context: ReactResponderContext, props: PressProps, state: PressState, ) { diff --git a/packages/react-events/src/Swipe.js b/packages/react-events/src/Swipe.js index 9d199d9fce69e..2b40e79d5a972 100644 --- a/packages/react-events/src/Swipe.js +++ b/packages/react-events/src/Swipe.js @@ -7,7 +7,10 @@ * @flow */ -import type {ResponderEvent, ResponderContext} from 'events/EventTypes'; +import type { + ReactResponderEvent, + ReactResponderContext, +} from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; const targetEventTypes = ['pointerdown', 'pointercancel']; @@ -52,7 +55,7 @@ function createSwipeEvent( } function dispatchSwipeEvent( - context: ResponderContext, + context: ReactResponderContext, name: SwipeEventType, listener: SwipeEvent => void, state: SwipeState, @@ -92,8 +95,8 @@ const SwipeResponder = { }; }, onEvent( - event: ResponderEvent, - context: ResponderContext, + event: ReactResponderEvent, + context: ReactResponderContext, props: Object, state: SwipeState, ): void { diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 22ffdcab6b811..7b5f2d1c66c52 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -7,8 +7,6 @@ * @flow */ -import type {ResponderEvent, ResponderContext} from 'events/EventTypes'; - export type ReactNode = | React$Element | ReactPortal @@ -91,18 +89,18 @@ export type ReactEventResponder = { targetEventTypes: Array, createInitialState?: (props: null | Object) => Object, onEvent: ( - event: ResponderEvent, - context: ResponderContext, + event: ReactResponderEvent, + context: ReactResponderContext, props: null | Object, state: null | Object, ) => void, onUnmount: ( - context: ResponderContext, + context: ReactResponderContext, props: null | Object, state: null | Object, ) => void, onOwnershipChange: ( - context: ResponderContext, + context: ReactResponderContext, props: null | Object, state: null | Object, ) => void, @@ -128,3 +126,47 @@ 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, + stopPropagation?: boolean, +}; + +export type ReactResponderContext = { + dispatchEvent: ( + eventObject: Object, + otpions: ReactResponderDispatchEventOptions, + ) => 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) => TimeoutID, +}; From dd9cef9fc001e174e177bac47d88fd28ff8de316 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Wed, 10 Apr 2019 18:52:34 +0100 Subject: [PATCH 25/41] Experimental Event API: Add targets and responder utility method for finding targets (#15372) --- .../src/events/DOMEventResponderSystem.js | 67 +++++ .../DOMEventResponderSystem-test.internal.js | 235 ++++++++++++++++++ packages/react-events/src/ReactEvents.js | 12 + packages/react-reconciler/src/ReactFiber.js | 4 + .../src/ReactFiberCompleteWork.js | 3 + packages/shared/ReactSymbols.js | 6 + packages/shared/ReactTypes.js | 8 + packages/shared/getComponentName.js | 34 ++- 8 files changed, 358 insertions(+), 11 deletions(-) diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index ddadb18472328..5d32eeac71b8c 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -15,6 +15,7 @@ import type {AnyNativeEvent} from 'events/PluginModuleType'; import { EventComponent, EventTarget as EventTargetWorkTag, + HostComponent, } from 'shared/ReactWorkTags'; import type { ReactEventResponderEventType, @@ -237,8 +238,74 @@ const eventResponderContext: ReactResponderContext = { } }, delay); }, + getEventTargetsFromTarget( + target: Element | Document, + queryType?: Symbol | number, + queryKey?: string, + ): Array<{ + node: Element, + props: null | Object, + }> { + 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 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; +} + const rootEventTypesToEventComponentInstances: Map< DOMTopLevelEventType | string, Set, 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 1fb5f3038ad9b..d92a9729204a5 100644 --- a/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js @@ -12,6 +12,7 @@ let React; let ReactFeatureFlags; let ReactDOM; +let ReactSymbols; function createReactEventComponent( targetEventTypes, @@ -42,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', () => { @@ -55,6 +64,7 @@ describe('DOMEventResponderSystem', () => { ReactDOM = require('react-dom'); container = document.createElement('div'); document.body.appendChild(container); + ReactSymbols = require('shared/ReactSymbols'); }); afterEach(() => { @@ -414,4 +424,229 @@ describe('DOMEventResponderSystem', () => { expect(ownershipGained).toEqual(true); expect(onOwnershipChangeFired).toEqual(1); }); + + it('should be possible to get event targets', () => { + 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 = Array.from( + context.getEventTargetsFromTarget(event.target), + ); + }, + ); + + 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-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-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index e4e4b77f81e27..8ca24a16a670c 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -638,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/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 41b4ba71c4dfc..56d9dd5b3c8da 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -841,6 +841,9 @@ function completeWork( 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); } 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 7b5f2d1c66c52..746f388db40ba 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -169,4 +169,12 @@ export type ReactResponderContext = { requestOwnership: () => boolean, releaseOwnership: () => boolean, setTimeout: (func: () => void, timeout: number) => TimeoutID, + getEventTargetsFromTarget: ( + target: Element | Document, + queryType?: Symbol | number, + queryKey?: string, + ) => Array<{ + node: Element, + props: null | Object, + }>, }; 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; + } } } } From 7fc91f17c99fbbd1497737fb8cbd8f45cbfd6d48 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Wed, 10 Apr 2019 10:52:50 -0700 Subject: [PATCH 26/41] React events: add onPressMove and pressRetentionOffset to Press (#15374) This implementation differs from equivalents in React Native in the following ways: 1. A move during a press will not cancel onLongPress. 2. A move to outside the retention target will cancel the press and not reactivate when moved back within the retention target. --- packages/react-events/README.md | 19 +- packages/react-events/src/Press.js | 130 ++++++++- .../src/__tests__/Press-test.internal.js | 276 +++++++++++++++--- 3 files changed, 381 insertions(+), 44 deletions(-) diff --git a/packages/react-events/README.md b/packages/react-events/README.md index 9173084e54a5c..cf607cd7f790e 100644 --- a/packages/react-events/README.md +++ b/packages/react-events/README.md @@ -4,7 +4,7 @@ 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 +dispatched on the host node of their child and transform those events into high-level events for applications. @@ -176,7 +176,8 @@ Disables all `Press` events. ### onLongPress: (e: PressEvent) => void -Called once the element has been pressed for the length of `delayLongPress`. +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 @@ -202,9 +203,15 @@ Called when the element changes press state (i.e., after `onPressStart` and ### onPressEnd: (e: PressEvent) => void -Called once the element is no longer pressed. If the press starts again before -the `delayPressEnd` threshold is exceeded then the delay is reset to prevent -`onPressEnd` being called during a press. +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 an active press moves within the hit bounds of the element. Never +called for keyboard-initiated press events. ### onPressStart: (e: PressEvent) => void @@ -212,7 +219,7 @@ 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. -### pressRententionOffset: PressOffset +### 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 diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index b20acf52327ac..3374fab65261a 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -24,8 +24,14 @@ 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, + }, }; type PressState = { @@ -35,15 +41,23 @@ type PressState = { isAnchorTouched: boolean, isLongPressed: boolean, isPressed: boolean, + isPressWithinResponderRegion: boolean, longPressTimeout: null | TimeoutID, pressTarget: null | Element | Document, pressEndTimeout: null | TimeoutID, pressStartTimeout: null | TimeoutID, + responderRegion: null | $ReadOnly<{| + bottom: number, + left: number, + right: number, + top: number, + |}>, shouldSkipMouseAfterTouch: boolean, }; type PressEventType = | 'press' + | 'pressmove' | 'pressstart' | 'pressend' | 'presschange' @@ -59,6 +73,12 @@ type PressEvent = {| 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}, @@ -70,13 +90,18 @@ const targetEventTypes = [ 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', 'mousedown', 'touchcancel'); - rootEventTypes.push({name: 'mouseup', passive: false}); + targetEventTypes.push('touchstart', 'touchend', 'touchcancel', 'mousedown'); + rootEventTypes.push( + {name: 'mouseup', passive: false}, + 'touchmove', + 'mousemove', + ); } function createPressEvent( @@ -232,8 +257,11 @@ function dispatchPressEndEvents( if (!wasActivePressStart && state.pressStartTimeout !== null) { clearTimeout(state.pressStartTimeout); state.pressStartTimeout = null; - // if we haven't yet activated (due to delays), activate now - activate(context, props, state); + // 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); + } } if (state.isActivePressed) { @@ -267,6 +295,59 @@ 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 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, @@ -288,10 +369,12 @@ const PressResponder = { isAnchorTouched: false, isLongPressed: false, isPressed: false, + isPressWithinResponderRegion: true, longPressTimeout: null, pressEndTimeout: null, pressStartTimeout: null, pressTarget: null, + responderRegion: null, shouldSkipMouseAfterTouch: false, }; }, @@ -333,11 +416,46 @@ const PressResponder = { } } state.pressTarget = target; + state.isPressWithinResponderRegion = true; dispatchPressStartEvents(context, props, state); context.addRootEventTypes(target.ownerDocument, rootEventTypes); } break; } + case 'pointermove': + case 'mousemove': + case 'touchmove': { + if (state.isPressed) { + if (state.shouldSkipMouseAfterTouch) { + return; + } + + 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); + } + } else { + state.isPressWithinResponderRegion = false; + dispatchPressEndEvents(context, props, state); + } + } + break; + } case 'pointerup': case 'mouseup': { if (state.isPressed) { @@ -373,6 +491,7 @@ const PressResponder = { context.removeRootEventTypes(rootEventTypes); } state.isAnchorTouched = false; + state.shouldSkipMouseAfterTouch = false; break; } @@ -389,6 +508,7 @@ const PressResponder = { return; } state.pressTarget = target; + state.isPressWithinResponderRegion = true; dispatchPressStartEvents(context, props, state); context.addRootEventTypes(target.ownerDocument, rootEventTypes); } diff --git a/packages/react-events/src/__tests__/Press-test.internal.js b/packages/react-events/src/__tests__/Press-test.internal.js index 2c83bcaafd338..8367c46c37b03 100644 --- a/packages/react-events/src/__tests__/Press-test.internal.js +++ b/packages/react-events/src/__tests__/Press-test.internal.js @@ -16,9 +16,14 @@ let Press; 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; }; @@ -592,36 +597,241 @@ 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('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', () => { From 3e2e930d62ac7166695e0c5b1538ebb7a41cd3da Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Wed, 10 Apr 2019 19:33:05 +0100 Subject: [PATCH 27/41] Fixes a Flow type merge conflict (#15378) --- packages/react-events/src/Press.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index 3374fab65261a..c8fefd3fbcd3b 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -333,7 +333,7 @@ function calculateResponderRegion(target, props) { } function isPressWithinResponderRegion( - nativeEvent: $PropertyType, + nativeEvent: $PropertyType, state: PressState, ): boolean { const {responderRegion} = state; From c25c59c808d90bfa78c40d0a19527d384a490cc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 10 Apr 2019 17:16:27 -0700 Subject: [PATCH 28/41] Apply the Just Noticeable Difference to suspense timeouts (#15367) * Apply the Just Noticeable Difference boundary * Clamp suspense timeout to expiration time --- .../src/ReactFiberCompleteWork.js | 4 +- .../src/ReactFiberScheduler.new.js | 56 ++++++- .../src/ReactFiberScheduler.old.js | 36 +++- ...tSuspenseWithNoopRenderer-test.internal.js | 156 ++++++++++++++++++ 4 files changed, 239 insertions(+), 13 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 56d9dd5b3c8da..da5385eb3b059 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -692,9 +692,9 @@ function completeWork( // 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 fallbackExpirationTimeExpTime: ExpirationTime = + const fallbackExpirationTime: ExpirationTime = prevState.fallbackExpirationTime; - markRenderEventTime(fallbackExpirationTimeExpTime); + markRenderEventTime(fallbackExpirationTime); // Delete the fallback. // TODO: Would it be better to store the fallback fragment on diff --git a/packages/react-reconciler/src/ReactFiberScheduler.new.js b/packages/react-reconciler/src/ReactFiberScheduler.new.js index 669f121c5433f..9c1065a568781 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.new.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.new.js @@ -160,6 +160,8 @@ import { } from 'shared/ReactErrorUtils'; import {onCommitRoot} from './ReactFiberDevToolsHook'; +const ceil = Math.ceil; + const { ReactCurrentDispatcher, ReactCurrentOwner, @@ -893,10 +895,12 @@ function renderRoot( // 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) { - const msUntilTimeout = computeMsUntilTimeout( + let msUntilTimeout = computeMsUntilTimeout( workInProgressRootMostRecentEventTime, + expirationTime, ); - if (msUntilTimeout > 0) { + // 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. @@ -1815,7 +1819,35 @@ export function resolveRetryThenable(boundaryFiber: Fiber, thenable: Thenable) { retryTimedOutBoundary(boundaryFiber); } -function computeMsUntilTimeout(mostRecentEventTime: ExpirationTime) { +// 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; @@ -1825,11 +1857,21 @@ function computeMsUntilTimeout(mostRecentEventTime: ExpirationTime) { const currentTimeMs: number = now(); const timeElapsed = currentTimeMs - eventTimeMs; - // TODO: Account for the Just Noticeable Difference - const timeoutMs = 150; - const msUntilTimeout = timeoutMs - timeElapsed; + 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 < 0 ? 0 : msUntilTimeout; + return msUntilTimeout; } function checkForNestedUpdates() { diff --git a/packages/react-reconciler/src/ReactFiberScheduler.old.js b/packages/react-reconciler/src/ReactFiberScheduler.old.js index 8614c2eb15de7..c4184fbb9465d 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.old.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.old.js @@ -1241,6 +1241,22 @@ function workLoop(isYieldy) { } } +function jnd(timeElapsed: number) { + return timeElapsed < 120 + ? 120 + : timeElapsed < 480 + ? 480 + : timeElapsed < 1080 + ? 1080 + : timeElapsed < 1920 + ? 1920 + : timeElapsed < 3000 + ? 3000 + : timeElapsed < 4320 + ? 4320 + : Math.ceil(timeElapsed / 1960) * 1960; +} + function renderRoot(root: FiberRoot, isYieldy: boolean): void { invariant( !isWorking, @@ -1518,10 +1534,22 @@ function renderRoot(root: FiberRoot, isYieldy: boolean): void { const currentTimeMs: number = now(); const timeElapsed = currentTimeMs - eventTimeMs; - // TODO: Account for the Just Noticeable Difference - const timeoutMs = 150; - let msUntilTimeout = timeoutMs - timeElapsed; - msUntilTimeout = msUntilTimeout < 0 ? 0 : msUntilTimeout; + let msUntilTimeout = jnd(timeElapsed) - timeElapsed; + + if (msUntilTimeout < 10) { + // Don't bother with a very short suspense time. + msUntilTimeout = 0; + } else { + // Compute the time until this render pass would expire. + const timeUntilExpirationMs = + expirationTimeToMs(suspendedExpirationTime) + + originalStartTimeMs - + currentTimeMs; + // Clamp the timeout to the expiration time. + if (timeUntilExpirationMs < msUntilTimeout) { + msUntilTimeout = timeUntilExpirationMs; + } + } const rootExpirationTime = root.expirationTime; onSuspend( diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js index 89f3f2a698c62..46ace8065bfca 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js @@ -1668,6 +1668,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: From c9841001b07200660de21a3275afd1cd7d9554a8 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Thu, 11 Apr 2019 12:04:39 +0100 Subject: [PATCH 29/41] Experimental Event API: preventDefault handling for anchors (#15383) --- packages/react-events/src/Press.js | 31 ++++++++++-------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index c8fefd3fbcd3b..0f79406a84234 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -32,10 +32,10 @@ type PressProps = { bottom: number, left: number, }, + preventDefault: boolean, }; type PressState = { - defaultPrevented: boolean, isActivePressed: boolean, isActivePressStart: boolean, isAnchorTouched: boolean, @@ -206,14 +206,7 @@ function dispatchPressStartEvents( state.isLongPressed = true; state.longPressTimeout = null; if (props.onLongPress) { - const listener = e => { - props.onLongPress(e); - // TODO address this again at some point - // if (e.nativeEvent.defaultPrevented) { - // state.defaultPrevented = true; - // } - }; - dispatchEvent(context, state, 'longpress', listener); + dispatchEvent(context, state, 'longpress', props.onLongPress); } if (props.onLongPressChange) { dispatchLongPressChangeEvent(context, props, state); @@ -363,7 +356,6 @@ const PressResponder = { targetEventTypes, createInitialState(): PressState { return { - defaultPrevented: false, isActivePressed: false, isActivePressStart: false, isAnchorTouched: false, @@ -477,14 +469,7 @@ const PressResponder = { props.onLongPressShouldCancelPress() ) ) { - const listener = e => { - props.onPress(e); - // TODO address this again at some point - // if (e.nativeEvent.defaultPrevented) { - // state.defaultPrevented = true; - // } - }; - dispatchEvent(context, state, 'press', listener); + dispatchEvent(context, state, 'press', props.onPress); } } } @@ -607,9 +592,13 @@ const PressResponder = { } case 'click': { - if (state.defaultPrevented) { - (nativeEvent: any).preventDefault(); - state.defaultPrevented = false; + if (isAnchorTagElement(target)) { + const {ctrlKey, metaKey, shiftKey} = ((nativeEvent: any): MouseEvent); + const preventDefault = props.preventDefault; + // Check "open in new window/tab" key modifiers + if (preventDefault !== false && !shiftKey && !ctrlKey && !metaKey) { + (nativeEvent: any).preventDefault(); + } } } } From a9eff329c6086cc12b7a61ff1b40ec9cc21c1c05 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Thu, 11 Apr 2019 12:05:26 +0100 Subject: [PATCH 30/41] Remove TouchHitTarget SSR logic to prevent issues with mouse events (#15381) --- .../src/server/ReactPartialRenderer.js | 27 ++++------- .../__tests__/TouchHitTarget-test.internal.js | 47 ++----------------- 2 files changed, 13 insertions(+), 61 deletions(-) diff --git a/packages/react-dom/src/server/ReactPartialRenderer.js b/packages/react-dom/src/server/ReactPartialRenderer.js index c493c0398ce5a..ce078dcb71779 100644 --- a/packages/react-dom/src/server/ReactPartialRenderer.js +++ b/packages/react-dom/src/server/ReactPartialRenderer.js @@ -1173,24 +1173,15 @@ class ReactDOMServerRenderer { elementType.$$typeof === REACT_EVENT_TARGET_TYPE && elementType.type === REACT_EVENT_TARGET_TOUCH_HIT ) { - const props = nextElement.props; - const bottom = props.bottom || 0; - const left = props.left || 0; - const right = props.right || 0; - const top = props.top || 0; - - if (bottom === 0 && left === 0 && right === 0 && top === 0) { - return ''; - } - let topString = top ? `-${top}px` : '0px'; - let leftString = left ? `-${left}px` : '0px'; - let rightString = right ? `-${right}px` : '0x'; - let bottomString = bottom ? `-${bottom}px` : '0px'; - - return ( - `
` - ); + // 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-events/src/__tests__/TouchHitTarget-test.internal.js b/packages/react-events/src/__tests__/TouchHitTarget-test.internal.js index e0bb517518be3..672e0b8bfc5ff 100644 --- a/packages/react-events/src/__tests__/TouchHitTarget-test.internal.js +++ b/packages/react-events/src/__tests__/TouchHitTarget-test.internal.js @@ -507,39 +507,6 @@ describe('TouchHitTarget', () => { ); }); - it('should hydrate TouchHitTarget hit slop elements correcty', () => { - const Test = () => ( - -
- -
-
- ); - - const container = document.createElement('div'); - container.innerHTML = '
'; - ReactDOM.hydrate(, container); - expect(Scheduler).toFlushWithoutYielding(); - expect(container.innerHTML).toBe('
'); - - const Test2 = () => ( - -
- -
-
- ); - - const container2 = document.createElement('div'); - container2.innerHTML = - '
'; - ReactDOM.hydrate(, container2); - expect(Scheduler).toFlushWithoutYielding(); - expect(container2.innerHTML).toBe( - '
', - ); - }); - it('should hydrate TouchHitTarget hit slop elements correcty and patch them', () => { const Test = () => ( @@ -586,7 +553,7 @@ describe('TouchHitTarget', () => { expect(output).toBe('
'); }); - it('should render a TouchHitTarget with hit slop values', () => { + it('should render a TouchHitTarget without hit slop values', () => { const Test = () => (
@@ -596,9 +563,7 @@ describe('TouchHitTarget', () => { ); let output = ReactDOMServer.renderToString(); - expect(output).toBe( - '
', - ); + expect(output).toBe('
'); const Test2 = () => ( @@ -609,9 +574,7 @@ describe('TouchHitTarget', () => { ); output = ReactDOMServer.renderToString(); - expect(output).toBe( - '
', - ); + expect(output).toBe('
'); const Test3 = () => ( @@ -622,9 +585,7 @@ describe('TouchHitTarget', () => { ); output = ReactDOMServer.renderToString(); - expect(output).toBe( - '
', - ); + expect(output).toBe('
'); }); }); }); From 9672cf621bccc799d1d86f45c86e2fbcb97be5aa Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Thu, 11 Apr 2019 20:00:20 +0100 Subject: [PATCH 31/41] Experimental Event API: adds `stopPropagation` by default to Press (#15384) --- .../src/events/DOMEventResponderSystem.js | 173 ++++++++++++------ packages/react-events/src/Hover.js | 8 +- packages/react-events/src/Press.js | 36 +++- .../src/__tests__/Press-test.internal.js | 97 +++++++++- packages/shared/ReactTypes.js | 5 +- 5 files changed, 249 insertions(+), 70 deletions(-) diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 5d32eeac71b8c..3572ca52aae31 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -41,9 +41,11 @@ export function setListenToResponderEventTypes( listenToResponderEventTypesImpl = _listenToResponderEventTypesImpl; } +type EventObjectTypes = {|stopPropagation: true|} | $Shape; + type EventQueue = { - bubble: null | Array<$Shape>, - capture: null | Array<$Shape>, + bubble: null | Array, + capture: null | Array, discrete: boolean, }; @@ -53,6 +55,29 @@ type PartialEventObject = { type: string, }; +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, +> = new Map(); +const targetEventTypeCached: Map< + Array, + Set, +> = new Map(); +const ownershipChangeListeners: Set = new Set(); + +let currentTimers = new Map(); let currentOwner = null; let currentInstance: ReactEventComponentInstance; let currentEventQueue: EventQueue; @@ -60,9 +85,8 @@ let currentEventQueue: EventQueue; const eventResponderContext: ReactResponderContext = { dispatchEvent( possibleEventObject: Object, - {capture, discrete, stopPropagation}: ReactResponderDispatchEventOptions, + {capture, discrete}: ReactResponderDispatchEventOptions, ): void { - const eventQueue = currentEventQueue; const {listener, target, type} = possibleEventObject; if (listener == null || target == null || type == null) { @@ -89,27 +113,15 @@ const eventResponderContext: ReactResponderContext = { const eventObject = ((possibleEventObject: any): $Shape< PartialEventObject, >); - let events; - - if (capture) { - events = eventQueue.capture; - if (events === null) { - events = eventQueue.capture = []; - } - } else { - events = eventQueue.bubble; - if (events === null) { - events = eventQueue.bubble = []; - } - } + const events = getEventsFromEventQueue(capture); if (discrete) { - eventQueue.discrete = true; + currentEventQueue.discrete = true; } events.push(eventObject); - - if (stopPropagation) { - eventsWithStopPropagation.add(eventObject); - } + }, + dispatchStopPropagation(capture?: boolean) { + const events = getEventsFromEventQueue(); + events.push({stopPropagation: true}); }, isPositionWithinTouchHitTarget(doc: Document, x: number, y: number): boolean { // This isn't available in some environments (JSDOM) @@ -222,21 +234,42 @@ const eventResponderContext: ReactResponderContext = { triggerOwnershipListeners(); return false; }, - setTimeout(func: () => void, delay): TimeoutID { - const contextInstance = currentInstance; - return setTimeout(() => { - const previousEventQueue = currentEventQueue; - const previousInstance = currentInstance; - currentEventQueue = createEventQueue(); - currentInstance = contextInstance; - try { - func(); - batchedUpdates(processEventQueue, currentEventQueue); - } finally { - currentInstance = previousInstance; - currentEventQueue = previousEventQueue; + setTimeout(func: () => void, delay): Symbol { + 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, + func, + id: timerId, + }); + activeTimeouts.set(timerId, timeout); + return timerId; + }, + clearTimeout(timerId: Symbol): void { + const timeout = activeTimeouts.get(timerId); + + if (timeout !== undefined) { + const timers = timeout.timers; + timers.delete(timerId); + if (timers.size === 0) { + clearTimeout(timeout.id); } - }, delay); + } }, getEventTargetsFromTarget( target: Element | Document, @@ -292,6 +325,46 @@ const eventResponderContext: ReactResponderContext = { }, }; +function getEventsFromEventQueue(capture?: boolean): Array { + let events; + if (capture) { + events = currentEventQueue.capture; + if (events === null) { + events = currentEventQueue.capture = []; + } + } else { + events = currentEventQueue.bubble; + if (events === null) { + events = currentEventQueue.bubble = []; + } + } + return events; +} + +function processTimers(timers: Map): void { + const previousEventQueue = currentEventQueue; + const previousInstance = currentInstance; + currentEventQueue = createEventQueue(); + + try { + const timersArr = Array.from(timers.values()); + 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 { + currentInstance = previousInstance; + currentEventQueue = previousEventQueue; + currentTimers = null; + } +} + function queryEventTarget( child: Fiber, queryType: void | Symbol | number, @@ -306,20 +379,6 @@ function queryEventTarget( return true; } -const rootEventTypesToEventComponentInstances: Map< - DOMTopLevelEventType | string, - Set, -> = new Map(); -const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set; -const eventsWithStopPropagation: - | WeakSet - | Set<$Shape> = new PossiblyWeakSet(); -const targetEventTypeCached: Map< - Array, - Set, -> = new Map(); -const ownershipChangeListeners: Set = new Set(); - function createResponderEvent( topLevelType: string, nativeEvent: AnyNativeEvent, @@ -350,27 +409,27 @@ function processEvent(event: $Shape): void { } 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)); } } } @@ -475,6 +534,7 @@ export function runResponderEventsInBatch( } } processEventQueue(); + currentTimers = null; } } @@ -518,6 +578,7 @@ export function unmountEventResponder( } finally { currentEventQueue = previousEventQueue; currentInstance = previousInstance; + currentTimers = null; } } if (currentOwner === eventComponentInstance) { diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index 116416bc7e865..1eebab060f5e7 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -27,8 +27,8 @@ type HoverState = { isHovered: boolean, isInHitSlop: boolean, isTouched: boolean, - hoverStartTimeout: null | TimeoutID, - hoverEndTimeout: null | TimeoutID, + hoverStartTimeout: null | Symbol, + hoverEndTimeout: null | Symbol, }; type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange'; @@ -97,7 +97,7 @@ function dispatchHoverStartEvents( state.isHovered = true; if (state.hoverEndTimeout !== null) { - clearTimeout(state.hoverEndTimeout); + context.clearTimeout(state.hoverEndTimeout); state.hoverEndTimeout = null; } @@ -148,7 +148,7 @@ function dispatchHoverEndEvents( state.isHovered = false; if (state.hoverStartTimeout !== null) { - clearTimeout(state.hoverStartTimeout); + context.clearTimeout(state.hoverStartTimeout); state.hoverStartTimeout = null; } diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index 0f79406a84234..ce58d7a8c6308 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -33,19 +33,21 @@ type PressProps = { left: number, }, preventDefault: boolean, + stopPropagation: boolean, }; type PressState = { + didDispatchEvent: boolean, isActivePressed: boolean, isActivePressStart: boolean, isAnchorTouched: boolean, isLongPressed: boolean, isPressed: boolean, isPressWithinResponderRegion: boolean, - longPressTimeout: null | TimeoutID, + longPressTimeout: null | Symbol, pressTarget: null | Element | Document, - pressEndTimeout: null | TimeoutID, - pressStartTimeout: null | TimeoutID, + pressEndTimeout: null | Symbol, + pressStartTimeout: null | Symbol, responderRegion: null | $ReadOnly<{| bottom: number, left: number, @@ -124,7 +126,10 @@ function dispatchEvent( ): void { const target = ((state.pressTarget: any): Element | Document); const syntheticEvent = createPressEvent(name, target, listener); - context.dispatchEvent(syntheticEvent, {discrete: true}); + context.dispatchEvent(syntheticEvent, { + discrete: true, + }); + state.didDispatchEvent = true; } function dispatchPressChangeEvent( @@ -185,7 +190,7 @@ function dispatchPressStartEvents( state.isPressed = true; if (state.pressEndTimeout !== null) { - clearTimeout(state.pressEndTimeout); + context.clearTimeout(state.pressEndTimeout); state.pressEndTimeout = null; } @@ -211,6 +216,14 @@ function dispatchPressStartEvents( if (props.onLongPressChange) { dispatchLongPressChangeEvent(context, props, state); } + if (state.didDispatchEvent) { + const shouldStopPropagation = + props.stopPropagation === undefined ? true : props.stopPropagation; + if (shouldStopPropagation) { + context.dispatchStopPropagation(); + } + state.didDispatchEvent = false; + } }, delayLongPress); } }; @@ -243,12 +256,12 @@ function dispatchPressEndEvents( state.isPressed = false; if (state.longPressTimeout !== null) { - clearTimeout(state.longPressTimeout); + context.clearTimeout(state.longPressTimeout); state.longPressTimeout = null; } if (!wasActivePressStart && state.pressStartTimeout !== null) { - clearTimeout(state.pressStartTimeout); + context.clearTimeout(state.pressStartTimeout); state.pressStartTimeout = null; // don't activate if a press has moved beyond the responder region if (state.isPressWithinResponderRegion) { @@ -356,6 +369,7 @@ const PressResponder = { targetEventTypes, createInitialState(): PressState { return { + didDispatchEvent: false, isActivePressed: false, isActivePressStart: false, isAnchorTouched: false, @@ -602,6 +616,14 @@ const PressResponder = { } } } + if (state.didDispatchEvent) { + const shouldStopPropagation = + props.stopPropagation === undefined ? true : props.stopPropagation; + if (shouldStopPropagation) { + context.dispatchStopPropagation(); + } + state.didDispatchEvent = false; + } }, onUnmount( context: ReactResponderContext, diff --git a/packages/react-events/src/__tests__/Press-test.internal.js b/packages/react-events/src/__tests__/Press-test.internal.js index 8367c46c37b03..331685eb4d1c9 100644 --- a/packages/react-events/src/__tests__/Press-test.internal.js +++ b/packages/react-events/src/__tests__/Press-test.internal.js @@ -381,6 +381,38 @@ describe('Event responder: Press', () => { 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', () => { @@ -429,6 +461,36 @@ describe('Event responder: Press', () => { // 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', () => { @@ -477,6 +539,38 @@ describe('Event responder: Press', () => { 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 = ( @@ -914,7 +1008,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}>
void, + dispatchStopPropagation: (passive?: boolean) => void, isTargetWithinElement: ( childTarget: Element | Document, parentTarget: Element | Document, @@ -168,7 +168,8 @@ export type ReactResponderContext = { hasOwnership: () => boolean, requestOwnership: () => boolean, releaseOwnership: () => boolean, - setTimeout: (func: () => void, timeout: number) => TimeoutID, + setTimeout: (func: () => void, timeout: number) => Symbol, + clearTimeout: (timerId: Symbol) => void, getEventTargetsFromTarget: ( target: Element | Document, queryType?: Symbol | number, From 45473c94cdb5d5d9642e5cde55ed3a5795720801 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Thu, 11 Apr 2019 13:20:21 -0700 Subject: [PATCH 32/41] React events: Press event fixes (#15386) 1. Fix hiding context menu for longpress via touch. 2. Fix scrolling of viewport for longpress via spacebar key. 3. Add tests for anchor-related behaviour and preventDefault. 4. Add a deactivation delay for forced activation 5. Add pointerType to Press events. NOTE: this currently extends pointerType to include `keyboard`. NOTE: React Native doesn't have a deactivation delay for forced activation, but this is possibly because of the async bridge meaning that the events aren't dispatched sync. --- packages/react-events/src/Hover.js | 3 +- packages/react-events/src/Press.js | 104 +++++++++--- .../src/__tests__/Press-test.internal.js | 160 +++++++++++++++++- 3 files changed, 240 insertions(+), 27 deletions(-) diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index 1eebab060f5e7..de4bcd2f41c18 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -72,8 +72,9 @@ function dispatchHoverChangeEvent( props: HoverProps, state: HoverState, ): void { + const bool = state.isActiveHovered; const listener = () => { - props.onHoverChange(state.isActiveHovered); + props.onHoverChange(bool); }; const syntheticEvent = createHoverEvent( 'hoverchange', diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index ce58d7a8c6308..74d3fcc78f3de 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -36,6 +36,8 @@ type PressProps = { stopPropagation: boolean, }; +type PointerType = '' | 'mouse' | 'keyboard' | 'pen' | 'touch'; + type PressState = { didDispatchEvent: boolean, isActivePressed: boolean, @@ -45,6 +47,7 @@ type PressState = { isPressed: boolean, isPressWithinResponderRegion: boolean, longPressTimeout: null | Symbol, + pointerType: PointerType, pressTarget: null | Element | Document, pressEndTimeout: null | Symbol, pressStartTimeout: null | Symbol, @@ -70,6 +73,7 @@ type PressEvent = {| listener: PressEvent => void, target: Element | Document, type: PressEventType, + pointerType: PointerType, |}; const DEFAULT_PRESS_END_DELAY_MS = 0; @@ -85,9 +89,10 @@ const DEFAULT_PRESS_RETENTION_OFFSET = { const targetEventTypes = [ {name: 'click', passive: false}, {name: 'keydown', passive: false}, + {name: 'keypress', passive: false}, + {name: 'contextmenu', passive: false}, 'pointerdown', 'pointercancel', - 'contextmenu', ]; const rootEventTypes = [ {name: 'keyup', passive: false}, @@ -110,11 +115,13 @@ function createPressEvent( type: PressEventType, target: Element | Document, listener: PressEvent => void, + pointerType: PointerType, ): PressEvent { return { listener, target, type, + pointerType, }; } @@ -125,7 +132,8 @@ function dispatchEvent( listener: (e: Object) => void, ): void { const target = ((state.pressTarget: any): Element | Document); - const syntheticEvent = createPressEvent(name, target, listener); + const pointerType = state.pointerType; + const syntheticEvent = createPressEvent(name, target, listener, pointerType); context.dispatchEvent(syntheticEvent, { discrete: true, }); @@ -137,8 +145,9 @@ function dispatchPressChangeEvent( props: PressProps, state: PressState, ): void { + const bool = state.isActivePressed; const listener = () => { - props.onPressChange(state.isActivePressed); + props.onPressChange(bool); }; dispatchEvent(context, state, 'presschange', listener); } @@ -148,8 +157,9 @@ function dispatchLongPressChangeEvent( props: PressProps, state: PressState, ): void { + const bool = state.isLongPressed; const listener = () => { - props.onLongPressChange(state.isLongPressed); + props.onLongPressChange(bool); }; dispatchEvent(context, state, 'longpresschange', listener); } @@ -251,6 +261,7 @@ function dispatchPressEndEvents( state: PressState, ): void { const wasActivePressStart = state.isActivePressStart; + let activationWasForced = false; state.isActivePressStart = false; state.isPressed = false; @@ -267,13 +278,17 @@ function dispatchPressEndEvents( if (state.isPressWithinResponderRegion) { // if we haven't yet activated (due to delays), activate now activate(context, props, state); + activationWasForced = true; } } if (state.isActivePressed) { const delayPressEnd = calculateDelayMS( props.delayPressEnd, - 0, + // 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) { @@ -338,6 +353,23 @@ function calculateResponderRegion(target, props) { }; } +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, @@ -377,6 +409,7 @@ const PressResponder = { isPressed: false, isPressWithinResponderRegion: true, longPressTimeout: null, + pointerType: '', pressEndTimeout: null, pressStartTimeout: null, pressTarget: null, @@ -403,10 +436,10 @@ const PressResponder = { !context.hasOwnership() && !state.shouldSkipMouseAfterTouch ) { - if ( - (nativeEvent: any).pointerType === 'mouse' || - type === 'mousedown' - ) { + const pointerType = getPointerType(nativeEvent); + state.pointerType = pointerType; + + if (pointerType === 'mouse' || type === 'mousedown') { if ( // Ignore right- and middle-clicks nativeEvent.button === 1 || @@ -436,6 +469,9 @@ const PressResponder = { return; } + const pointerType = getPointerType(nativeEvent); + state.pointerType = pointerType; + if (state.responderRegion == null) { let currentTarget = (target: any); while ( @@ -470,6 +506,9 @@ const PressResponder = { return; } + const pointerType = getPointerType(nativeEvent); + state.pointerType = pointerType; + const wasLongPressed = state.isLongPressed; dispatchPressEndEvents(context, props, state); @@ -506,6 +545,8 @@ const PressResponder = { state.isAnchorTouched = true; return; } + const pointerType = getPointerType(nativeEvent); + state.pointerType = pointerType; state.pressTarget = target; state.isPressWithinResponderRegion = true; dispatchPressStartEvents(context, props, state); @@ -519,6 +560,9 @@ const PressResponder = { return; } if (state.isPressed) { + const pointerType = getPointerType(nativeEvent); + state.pointerType = pointerType; + const wasLongPressed = state.isLongPressed; dispatchPressEndEvents(context, props, state); @@ -556,20 +600,24 @@ const PressResponder = { * Keyboard interaction support * TODO: determine UX for metaKey + validKeyPress interactions */ - case 'keydown': { + case 'keydown': + case 'keypress': { if ( - !state.isPressed && - !state.isLongPressed && !context.hasOwnership() && isValidKeyPress((nativeEvent: any).key) ) { - // Prevent spacebar press from scrolling the window - if ((nativeEvent: any).key === ' ') { - (nativeEvent: any).preventDefault(); + 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 = target; - dispatchPressStartEvents(context, props, state); - context.addRootEventTypes(target.ownerDocument, rootEventTypes); } break; } @@ -593,7 +641,6 @@ const PressResponder = { break; } - case 'contextmenu': case 'pointercancel': case 'scroll': case 'touchcancel': { @@ -608,14 +655,29 @@ const PressResponder = { case 'click': { 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; - // Check "open in new window/tab" key modifiers - if (preventDefault !== false && !shiftKey && !ctrlKey && !metaKey) { + 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; diff --git a/packages/react-events/src/__tests__/Press-test.internal.js b/packages/react-events/src/__tests__/Press-test.internal.js index 331685eb4d1c9..f82ecb287ac93 100644 --- a/packages/react-events/src/__tests__/Press-test.internal.js +++ b/packages/react-events/src/__tests__/Press-test.internal.js @@ -70,8 +70,13 @@ 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 browser emulated "mousedown" event', () => { @@ -85,13 +90,20 @@ describe('Event responder: Press', () => { 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('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', () => { @@ -103,10 +115,16 @@ describe('Event responder: Press', () => { 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'}), + ); }); describe('delayPressStart', () => { @@ -191,8 +209,13 @@ 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 browser emulated "mouseup" event', () => { @@ -200,18 +223,27 @@ describe('Event responder: Press', () => { 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', () => { @@ -225,11 +257,17 @@ 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'}), + ); }); describe('delayPressEnd', () => { @@ -342,6 +380,9 @@ describe('Event responder: Press', () => { 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); }); @@ -431,14 +472,22 @@ 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(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', () => { @@ -508,11 +557,16 @@ describe('Event responder: Press', () => { }); it('is called if "pointerdown" lasts default delay', () => { - ref.current.dispatchEvent(createPointerEvent('pointerdown')); + 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 "pointerup" is dispatched before delay', () => { @@ -529,6 +583,9 @@ describe('Event responder: Press', () => { 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', () => { @@ -691,6 +748,38 @@ describe('Event responder: Press', () => { }); }); + 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, @@ -1042,6 +1131,67 @@ describe('Event responder: Press', () => { }); }); + 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(); + }); + }); + it('expect displayName to show up for event component', () => { expect(Press.displayName).toBe('Press'); }); From 687e4fb6f7dcb13ca3c668bf5f3df4c0b281f58c Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 11 Apr 2019 13:41:43 -0700 Subject: [PATCH 33/41] Bump scheduler version to 0.14.0 Releasing this early for React Native --- packages/scheduler/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/scheduler/package.json b/packages/scheduler/package.json index 9adc2278a34dc..fc624eadb1b0f 100644 --- a/packages/scheduler/package.json +++ b/packages/scheduler/package.json @@ -1,6 +1,6 @@ { "name": "scheduler", - "version": "0.13.6", + "version": "0.14.0", "description": "Cooperative scheduler for the browser environment.", "main": "index.js", "repository": { From de75903272d114c412fea9cdcd725468cf05a06c Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 11 Apr 2019 16:43:33 -0700 Subject: [PATCH 34/41] Fix CI (#15393) * Revert "Bump scheduler version to 0.14.0" This reverts commit 687e4fb6f7dcb13ca3c668bf5f3df4c0b281f58c. * Store results.json as CI build artifact --- .circleci/config.yml | 3 ++ dangerfile.js | 59 ++++++++++++++++++++++++++++----- packages/scheduler/package.json | 2 +- scripts/circleci/build.sh | 15 ++------- 4 files changed, 57 insertions(+), 22 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a913ed226873e..c54ff75b6573b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -46,5 +46,8 @@ jobs: - store_artifacts: 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..ff68706501ef2 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,61 @@ 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' && status.state === 'success') { + baseCIBuildId = /\/facebook\/react\/([0-9]+)/.exec( + status.target_url + )[1]; + } + } + + if (baseCIBuildId === null) { + warn(`Base commit is broken: ${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/results.json') { + resultsResponse = await fetch(info.url); + } + } + } 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 +255,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/packages/scheduler/package.json b/packages/scheduler/package.json index fc624eadb1b0f..9adc2278a34dc 100644 --- a/packages/scheduler/package.json +++ b/packages/scheduler/package.json @@ -1,6 +1,6 @@ { "name": "scheduler", - "version": "0.14.0", + "version": "0.13.6", "description": "Cooperative scheduler for the browser environment.", "main": "index.js", "repository": { 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, From cdfb06e38b311f8869241bfd1623aeece80e18d0 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 11 Apr 2019 17:20:14 -0700 Subject: [PATCH 35/41] Fix path to results.json --- dangerfile.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dangerfile.js b/dangerfile.js index ff68706501ef2..86e0330ec50d4 100644 --- a/dangerfile.js +++ b/dangerfile.js @@ -146,8 +146,9 @@ function git(args) { for (let i = 0; i < baseArtifactsInfo.length; i++) { const info = baseArtifactsInfo[i]; - if (info.path === 'home/circleci/project/scripts/results.json') { + if (info.path === 'home/circleci/project/scripts/rollup/results.json') { resultsResponse = await fetch(info.url); + break; } } } catch (error) { From 4e59d4f5d26a620a2c6e8804a589b227481a80aa Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Thu, 11 Apr 2019 18:59:05 -0700 Subject: [PATCH 36/41] React events: add onHoverMove support (#15388) --- packages/react-events/README.md | 40 ++++++++++++++-- packages/react-events/src/Hover.js | 48 ++++++++++++++----- packages/react-events/src/Press.js | 15 ++++-- .../src/__tests__/Hover-test.internal.js | 30 ++++++++++++ 4 files changed, 111 insertions(+), 22 deletions(-) diff --git a/packages/react-events/README.md b/packages/react-events/README.md index cf607cd7f790e..57157f720dfaf 100644 --- a/packages/react-events/README.md +++ b/packages/react-events/README.md @@ -28,7 +28,9 @@ const TextField = (props) => ( ```js // Types -type FocusEvent = {} +type FocusEvent = { + type: 'blur' | 'focus' | 'focuschange' +} ``` ### disabled: boolean @@ -76,7 +78,10 @@ const Link = (props) => ( ```js // Types -type HoverEvent = {} +type HoverEvent = { + pointerType: 'mouse', + type: 'hoverstart' | 'hoverend' | 'hovermove' | 'hoverchange' +} ``` ### delayHoverEnd: number @@ -103,12 +108,25 @@ Called when the element changes hover state (i.e., after `onHoverStart` and 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 @@ -145,7 +163,10 @@ const Button = (props) => ( ```js // Types -type PressEvent = {} +type PressEvent = { + pointerType: 'mouse' | 'touch' | 'pen' | 'keyboard', + type: 'press' | 'pressstart' | 'pressend' | 'presschange' | 'pressmove' | 'longpress' | 'longpresschange' +} type PressOffset = { top: number, @@ -210,8 +231,9 @@ called during a press. ### onPressMove: (e: PressEvent) => void -Called when an active press moves within the hit bounds of the element. Never -called for keyboard-initiated press events. +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 @@ -225,3 +247,11 @@ 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/Hover.js b/packages/react-events/src/Hover.js index de4bcd2f41c18..60b90676d8b78 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -19,6 +19,7 @@ type HoverProps = { delayHoverStart: number, onHoverChange: boolean => void, onHoverEnd: (e: HoverEvent) => void, + onHoverMove: (e: HoverEvent) => void, onHoverStart: (e: HoverEvent) => void, }; @@ -29,9 +30,10 @@ type HoverState = { 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, @@ -51,7 +53,7 @@ const targetEventTypes = [ // 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', 'mouseout'); + targetEventTypes.push('touchstart', 'mouseover', 'mousemove', 'mouseout'); } function createHoverEvent( @@ -200,6 +202,7 @@ const HoverResponder = { isTouched: false, hoverStartTimeout: null, hoverEndTimeout: null, + skipMouseAfterPointer: false, }; }, onEvent( @@ -228,6 +231,9 @@ const HoverResponder = { state.isTouched = true; return; } + if (type === 'pointerover') { + state.skipMouseAfterPointer = true; + } if ( context.isPositionWithinTouchHitTarget( target.ownerDocument, @@ -249,10 +255,16 @@ const HoverResponder = { } state.isInHitSlop = false; state.isTouched = false; + state.skipMouseAfterPointer = false; break; } - case 'pointermove': { + case 'pointermove': + case 'mousemove': { + if (type === 'mousemove' && state.skipMouseAfterPointer === true) { + return; + } + if (state.isHovered && !state.isTouched) { if (state.isInHitSlop) { if ( @@ -265,16 +277,26 @@ const HoverResponder = { dispatchHoverStartEvents(event, context, props, state); state.isInHitSlop = false; } - } else if ( - state.isHovered && - context.isPositionWithinTouchHitTarget( - target.ownerDocument, - (nativeEvent: any).x, - (nativeEvent: any).y, - ) - ) { - dispatchHoverEndEvents(event, context, props, state); - 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, + props.onHoverMove, + ); + context.dispatchEvent(syntheticEvent, {discrete: false}); + } + } } } break; diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index 74d3fcc78f3de..1753301a40013 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -10,6 +10,7 @@ import type { ReactResponderEvent, ReactResponderContext, + ReactResponderDispatchEventOptions, } from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; @@ -130,13 +131,17 @@ function dispatchEvent( state: PressState, name: PressEventType, listener: (e: Object) => void, + options?: ReactResponderDispatchEventOptions, ): void { const target = ((state.pressTarget: any): Element | Document); const pointerType = state.pointerType; const syntheticEvent = createPressEvent(name, target, listener, pointerType); - context.dispatchEvent(syntheticEvent, { - discrete: true, - }); + context.dispatchEvent( + syntheticEvent, + options || { + discrete: true, + }, + ); state.didDispatchEvent = true; } @@ -489,7 +494,9 @@ const PressResponder = { if (isPressWithinResponderRegion(nativeEvent, state)) { state.isPressWithinResponderRegion = true; if (props.onPressMove) { - dispatchEvent(context, state, 'pressmove', props.onPressMove); + dispatchEvent(context, state, 'pressmove', props.onPressMove, { + discrete: false, + }); } } else { state.isPressWithinResponderRegion = false; diff --git a/packages/react-events/src/__tests__/Hover-test.internal.js b/packages/react-events/src/__tests__/Hover-test.internal.js index 0d29036f988f3..151631eaa1f97 100644 --- a/packages/react-events/src/__tests__/Hover-test.internal.js +++ b/packages/react-events/src/__tests__/Hover-test.internal.js @@ -331,6 +331,36 @@ describe('Hover event responder', () => { }); }); + 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'}), + ); + }); + }); + it('expect displayName to show up for event component', () => { expect(Hover.displayName).toBe('Hover'); }); From 9055e31e5c82d03f0a365c459f7bc79e402dbef5 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 11 Apr 2019 19:15:34 -0700 Subject: [PATCH 37/41] Replace old Fiber Scheduler with new one (#15387) The new Fiber Scheduler has been running in Facebook for several days without issues. Let's switch to it. --- package.json | 1 - ...test.internal.js => ReactDOMHooks-test.js} | 37 +- .../react-dom/unstable-new-scheduler.fb.js | 14 - packages/react-dom/unstable-new-scheduler.js | 16 - .../src/ReactFiberPendingPriority.js | 265 -- .../react-reconciler/src/ReactFiberRoot.js | 61 +- .../src/ReactFiberScheduler.js | 2354 +++++++++++++- .../src/ReactFiberScheduler.new.js | 2244 -------------- .../src/ReactFiberScheduler.old.js | 2723 ----------------- .../src/SchedulerWithReactIntegration.js | 21 +- ...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 | 26 +- ...tSuspenseWithNoopRenderer-test.internal.js | 211 +- ...ReactIncrementalPerf-test.internal.js.snap | 546 +--- .../__tests__/ReactProfiler-test.internal.js | 46 +- packages/shared/ReactFeatureFlags.js | 4 - .../forks/ReactFeatureFlags.native-fb.js | 1 - .../forks/ReactFeatureFlags.native-oss.js | 1 - .../forks/ReactFeatureFlags.new-scheduler.js | 31 - .../forks/ReactFeatureFlags.persistent.js | 1 - .../forks/ReactFeatureFlags.test-renderer.js | 1 - .../ReactFeatureFlags.test-renderer.www.js | 1 - .../ReactFeatureFlags.www-new-scheduler.js | 40 - .../shared/forks/ReactFeatureFlags.www.js | 5 - scripts/circleci/test_entry_point.sh | 1 - scripts/jest/config.source-new-scheduler.js | 11 - scripts/jest/setupNewScheduler.js | 7 - scripts/rollup/bundles.js | 16 - scripts/rollup/forks.js | 19 - scripts/shared/inlinedHostConfigs.js | 6 +- 34 files changed, 2917 insertions(+), 7085 deletions(-) rename packages/react-dom/src/__tests__/{ReactDOMHooks-test.internal.js => ReactDOMHooks-test.js} (79%) delete mode 100644 packages/react-dom/unstable-new-scheduler.fb.js delete mode 100644 packages/react-dom/unstable-new-scheduler.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 delete mode 100644 packages/shared/forks/ReactFeatureFlags.new-scheduler.js delete mode 100644 packages/shared/forks/ReactFeatureFlags.www-new-scheduler.js delete mode 100644 scripts/jest/config.source-new-scheduler.js delete mode 100644 scripts/jest/setupNewScheduler.js diff --git a/package.json b/package.json index 92beff532f100..5824c7298a9de 100644 --- a/package.json +++ b/package.json @@ -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/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/unstable-new-scheduler.fb.js b/packages/react-dom/unstable-new-scheduler.fb.js deleted file mode 100644 index ea901748d6949..0000000000000 --- a/packages/react-dom/unstable-new-scheduler.fb.js +++ /dev/null @@ -1,14 +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. - */ - -'use strict'; - -const ReactDOMFB = require('./src/client/ReactDOMFB'); - -// TODO: decide on the top-level export form. -// This is hacky but makes it work with both Rollup and Jest. -module.exports = ReactDOMFB.default || ReactDOMFB; 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-reconciler/src/ReactFiberPendingPriority.js b/packages/react-reconciler/src/ReactFiberPendingPriority.js deleted file mode 100644 index 43e241d3a2683..0000000000000 --- a/packages/react-reconciler/src/ReactFiberPendingPriority.js +++ /dev/null @@ -1,265 +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 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/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 f58aaa7913c9a..9c1065a568781 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -7,136 +7,2238 @@ * @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, - markRenderEventTime as markRenderEventTime_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, - warnIfNotCurrentlyActingUpdatesInDev as warnIfNotCurrentlyActingUpdatesInDev_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, - markRenderEventTime as markRenderEventTime_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, - warnIfNotCurrentlyActingUpdatesInDev as warnIfNotCurrentlyActingUpdatesInDev_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 markRenderEventTime = enableNewScheduler - ? markRenderEventTime_new - : markRenderEventTime_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 warnIfNotCurrentlyActingUpdatesInDev = enableNewScheduler - ? warnIfNotCurrentlyActingUpdatesInDev_new - : warnIfNotCurrentlyActingUpdatesInDev_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 { + 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; + } + } + } +} + +function warnIfNotCurrentlyActingUpdatesInDEV(fiber: Fiber): void { + if (__DEV__) { + if ( + workPhase === NotWorking && + ReactShouldWarnActingUpdates.current === false + ) { + 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 9c1065a568781..0000000000000 --- a/packages/react-reconciler/src/ReactFiberScheduler.new.js +++ /dev/null @@ -1,2244 +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'; - -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): 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; - } - } - } -} - -function warnIfNotCurrentlyActingUpdatesInDEV(fiber: Fiber): void { - if (__DEV__) { - if ( - workPhase === NotWorking && - ReactShouldWarnActingUpdates.current === false - ) { - 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 c4184fbb9465d..0000000000000 --- a/packages/react-reconciler/src/ReactFiberScheduler.old.js +++ /dev/null @@ -1,2723 +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, - 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'; - -// 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 mostRecentEventTime: ExpirationTime = Sync; -let nextRenderDidSuspend: boolean = false; -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; - mostRecentEventTime = Sync; - nextRenderDidSuspend = false; - 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 jnd(timeElapsed: number) { - return timeElapsed < 120 - ? 120 - : timeElapsed < 480 - ? 480 - : timeElapsed < 1080 - ? 1080 - : timeElapsed < 1920 - ? 1920 - : timeElapsed < 3000 - ? 3000 - : timeElapsed < 4320 - ? 4320 - : Math.ceil(timeElapsed / 1960) * 1960; -} - -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; - } - } - - // Check if we should suspend this commit. - // If mostRecentEventTime 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 (isYieldy && nextRenderDidSuspend && mostRecentEventTime !== Sync) { - // The tree was suspended. - const suspendedExpirationTime = expirationTime; - markSuspendedPriorityLevel(root, suspendedExpirationTime); - - const eventTimeMs: number = inferTimeFromExpirationTime( - mostRecentEventTime, - ); - const currentTimeMs: number = now(); - const timeElapsed = currentTimeMs - eventTimeMs; - - let msUntilTimeout = jnd(timeElapsed) - timeElapsed; - - if (msUntilTimeout < 10) { - // Don't bother with a very short suspense time. - msUntilTimeout = 0; - } else { - // Compute the time until this render pass would expire. - const timeUntilExpirationMs = - expirationTimeToMs(suspendedExpirationTime) + - originalStartTimeMs - - currentTimeMs; - // Clamp the timeout to the expiration time. - if (timeUntilExpirationMs < msUntilTimeout) { - msUntilTimeout = timeUntilExpirationMs; - } - } - - 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 markRenderEventTime(expirationTime: ExpirationTime): void { - if (expirationTime < mostRecentEventTime) { - mostRecentEventTime = expirationTime; - } -} - -function renderDidSuspend() { - nextRenderDidSuspend = true; -} - -function renderDidError() { - nextRenderDidError = true; -} - -function inferTimeFromExpirationTime(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(expirationTime); - return ( - earliestExpirationTimeMs - LOW_PRIORITY_EXPIRATION + originalStartTimeMs - ); -} - -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; -} - -// 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 - -export function warnIfNotCurrentlyActingUpdatesInDev(fiber: Fiber): void { - if (__DEV__) { - if ( - isBatchingUpdates === false && - isRendering === false && - ReactShouldWarnActingUpdates.current === false - ) { - 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, - markRenderEventTime, - renderDidSuspend, - renderDidError, - pingSuspendedRoot, - retryTimedOutBoundary, - resolveRetryThenable, - markLegacyErrorBoundaryAsFailed, - isAlreadyFailedLegacyErrorBoundary, - scheduleWork, - flushRoot, - batchedUpdates, - unbatchedUpdates, - flushSync, - flushControlled, - deferredUpdates, - syncUpdates, - interactiveUpdates, - flushInteractiveUpdates, - computeUniqueAsyncExpiration, - flushPassiveEffects, -}; 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__/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 529bb33fc93f5..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); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js index 46ace8065bfca..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) => @@ -889,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')]); }); @@ -935,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( @@ -987,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( @@ -1054,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( @@ -1135,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( @@ -1208,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( @@ -1289,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( @@ -1376,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( @@ -1434,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')]); }); @@ -1487,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')]); }); @@ -1536,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 () => { @@ -1580,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']); }); } }); 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/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/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index bab2d9a2cfff0..398aa209707d4 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -65,9 +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/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 16b811bbbab2f..285ea06bbc64f 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -31,7 +31,6 @@ 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. diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 3d26ebea941d8..60f29acdc7797 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -28,7 +28,6 @@ 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. diff --git a/packages/shared/forks/ReactFeatureFlags.new-scheduler.js b/packages/shared/forks/ReactFeatureFlags.new-scheduler.js deleted file mode 100644 index ab43d3f3081f4..0000000000000 --- a/packages/shared/forks/ReactFeatureFlags.new-scheduler.js +++ /dev/null @@ -1,31 +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 enableJSXTransformAPI = false; - -export const enableNewScheduler = true; diff --git a/packages/shared/forks/ReactFeatureFlags.persistent.js b/packages/shared/forks/ReactFeatureFlags.persistent.js index 8e96b43623d20..14b8716b96342 100644 --- a/packages/shared/forks/ReactFeatureFlags.persistent.js +++ b/packages/shared/forks/ReactFeatureFlags.persistent.js @@ -28,7 +28,6 @@ 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. diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 178a542430a5f..40c982f3e7cc3 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -28,7 +28,6 @@ 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. diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index dd3bfd274f44a..f6f80c8985350 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -26,7 +26,6 @@ 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. 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 cd6b303a75660..0000000000000 --- a/packages/shared/forks/ReactFeatureFlags.www-new-scheduler.js +++ /dev/null @@ -1,40 +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; -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; -// 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 e77c28389d027..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__) { 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/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/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/bundles.js b/scripts/rollup/bundles.js index 2d6babd5760fd..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, 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/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, }, From ed6798405d610a121b21a4cbf6f74c030e913e35 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 11 Apr 2019 19:23:31 -0700 Subject: [PATCH 38/41] Better message when CI for base commit is pending --- dangerfile.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/dangerfile.js b/dangerfile.js index 86e0330ec50d4..bc09c29574d5e 100644 --- a/dangerfile.js +++ b/dangerfile.js @@ -127,15 +127,22 @@ function git(args) { const statuses = await statusesResponse.json(); for (let i = 0; i < statuses.length; i++) { const status = statuses[i]; - if (status.context === 'ci/circleci' && status.state === 'success') { - baseCIBuildId = /\/facebook\/react\/([0-9]+)/.exec( - status.target_url - )[1]; + 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(`Base commit is broken: ${baseCommit}`); + warn(`Could not find build artifacts for base commit: ${baseCommit}`); return; } From 543353a043a9f2ffe4d3e8b20383c05877d0a87e Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Fri, 12 Apr 2019 11:53:40 +0100 Subject: [PATCH 39/41] Experimental Event API: Remove "listener" from event objects (#15391) --- .../src/events/DOMEventResponderSystem.js | 170 +++++++++++------- .../DOMEventResponderSystem-test.internal.js | 20 ++- packages/react-events/src/Drag.js | 7 +- packages/react-events/src/Focus.js | 19 +- packages/react-events/src/Hover.js | 34 ++-- packages/react-events/src/Press.js | 6 +- packages/react-events/src/Swipe.js | 7 +- packages/shared/ReactTypes.js | 1 + 8 files changed, 139 insertions(+), 125 deletions(-) diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 3572ca52aae31..f0e98569278a5 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -30,6 +30,7 @@ import type {Fiber} from 'react-reconciler/src/ReactFiber'; import warning from 'shared/warning'; import {enableEventAPI} from 'shared/ReactFeatureFlags'; import {invokeGuardedCallbackAndCatchFirstError} from 'shared/ReactErrorUtils'; +import invariant from 'shared/invariant'; import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; @@ -50,7 +51,6 @@ type EventQueue = { }; type PartialEventObject = { - listener: ($Shape) => void, target: Element | Document, type: string, }; @@ -76,22 +76,31 @@ const targetEventTypeCached: Map< Set, > = new Map(); 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: ReactEventComponentInstance; -let currentEventQueue: EventQueue; +let currentInstance: null | ReactEventComponentInstance = null; +let currentEventQueue: null | EventQueue = null; const eventResponderContext: ReactResponderContext = { dispatchEvent( possibleEventObject: Object, + listener: ($Shape) => void, {capture, discrete}: ReactResponderDispatchEventOptions, ): void { - const {listener, target, type} = possibleEventObject; + validateResponderContext(); + const {target, type} = possibleEventObject; - if (listener == null || target == null || type == null) { + if (target == null || type == null) { throw new Error( - 'context.dispatchEvent: "listener", "target" and "type" fields on event object are required.', + 'context.dispatchEvent: "target" and "type" fields on event object are required.', ); } if (__DEV__) { @@ -115,15 +124,18 @@ const eventResponderContext: ReactResponderContext = { >); const events = getEventsFromEventQueue(capture); if (discrete) { - currentEventQueue.discrete = true; + ((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; @@ -151,6 +163,7 @@ const eventResponderContext: ReactResponderContext = { return false; }, isTargetWithinEventComponent(target: Element | Document): boolean { + validateResponderContext(); if (target != null) { let fiber = getClosestInstanceFromNode(target); while (fiber !== null) { @@ -182,6 +195,7 @@ const eventResponderContext: ReactResponderContext = { doc: Document, rootEventTypes: Array, ): void { + validateResponderContext(); listenToResponderEventTypesImpl(rootEventTypes, doc); for (let i = 0; i < rootEventTypes.length; i++) { const rootEventType = rootEventTypes[i]; @@ -197,12 +211,15 @@ const eventResponderContext: ReactResponderContext = { rootEventComponentInstances, ); } - rootEventComponentInstances.add(currentInstance); + rootEventComponentInstances.add( + ((currentInstance: any): ReactEventComponentInstance), + ); } }, removeRootEventTypes( rootEventTypes: Array, ): void { + validateResponderContext(); for (let i = 0; i < rootEventTypes.length; i++) { const rootEventType = rootEventTypes[i]; const topLevelEventType = @@ -211,14 +228,18 @@ const eventResponderContext: ReactResponderContext = { topLevelEventType, ); if (rootEventComponents !== undefined) { - rootEventComponents.delete(currentInstance); + rootEventComponents.delete( + ((currentInstance: any): ReactEventComponentInstance), + ); } } }, hasOwnership(): boolean { + validateResponderContext(); return currentOwner === currentInstance; }, requestOwnership(): boolean { + validateResponderContext(); if (currentOwner !== null) { return false; } @@ -227,6 +248,7 @@ const eventResponderContext: ReactResponderContext = { return true; }, releaseOwnership(): boolean { + validateResponderContext(); if (currentOwner !== currentInstance) { return false; } @@ -235,6 +257,7 @@ const eventResponderContext: ReactResponderContext = { return false; }, setTimeout(func: () => void, delay): Symbol { + validateResponderContext(); if (currentTimers === null) { currentTimers = new Map(); } @@ -253,7 +276,7 @@ const eventResponderContext: ReactResponderContext = { currentTimers.set(delay, timeout); } timeout.timers.set(timerId, { - instance: currentInstance, + instance: ((currentInstance: any): ReactEventComponentInstance), func, id: timerId, }); @@ -261,6 +284,7 @@ const eventResponderContext: ReactResponderContext = { return timerId; }, clearTimeout(timerId: Symbol): void { + validateResponderContext(); const timeout = activeTimeouts.get(timerId); if (timeout !== undefined) { @@ -279,6 +303,7 @@ const eventResponderContext: ReactResponderContext = { node: Element, props: null | Object, }> { + validateResponderContext(); const eventTargetHostComponents = []; let node = getClosestInstanceFromNode(target); // We traverse up the fiber tree from the target fiber, to the @@ -326,28 +351,26 @@ const eventResponderContext: ReactResponderContext = { }; function getEventsFromEventQueue(capture?: boolean): Array { + const eventQueue = ((currentEventQueue: any): EventQueue); let events; if (capture) { - events = currentEventQueue.capture; + events = eventQueue.capture; if (events === null) { - events = currentEventQueue.capture = []; + events = eventQueue.capture = []; } } else { - events = currentEventQueue.bubble; + events = eventQueue.bubble; if (events === null) { - events = currentEventQueue.bubble = []; + events = eventQueue.bubble = []; } } return events; } function processTimers(timers: Map): void { - const previousEventQueue = currentEventQueue; - const previousInstance = currentInstance; + const timersArr = Array.from(timers.values()); currentEventQueue = createEventQueue(); - try { - const timersArr = Array.from(timers.values()); for (let i = 0; i < timersArr.length; i++) { const {instance, func, id} = timersArr[i]; currentInstance = instance; @@ -359,9 +382,9 @@ function processTimers(timers: Map): void { } batchedUpdates(processEventQueue, currentEventQueue); } finally { - currentInstance = previousInstance; - currentEventQueue = previousEventQueue; currentTimers = null; + currentInstance = null; + currentEventQueue = null; } } @@ -404,7 +427,9 @@ 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); } @@ -435,7 +460,7 @@ function processEvents( } export function processEventQueue(): void { - const {bubble, capture, discrete} = currentEventQueue; + const {bubble, capture, discrete} = ((currentEventQueue: any): EventQueue); if (discrete) { interactiveUpdates(() => { @@ -478,13 +503,8 @@ function handleTopLevelType( return; } } - const previousInstance = currentInstance; currentInstance = eventComponentInstance; - try { - responder.onEvent(responderEvent, eventResponderContext, props, state); - } finally { - currentInstance = previousInstance; - } + responder.onEvent(responderEvent, eventResponderContext, props, state); } export function runResponderEventsInBatch( @@ -502,54 +522,60 @@ export function runResponderEventsInBatch( ((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) { - const eventComponentInstance = node.stateNode; - handleTopLevelType( - topLevelType, - responderEvent, - eventComponentInstance, - 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 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, - ); + // 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; } - processEventQueue(); - currentTimers = null; } } function triggerOwnershipListeners(): void { const listeningInstances = Array.from(ownershipChangeListeners); const previousInstance = currentInstance; - for (let i = 0; i < listeningInstances.length; i++) { - const instance = listeningInstances[i]; - const {props, responder, state} = instance; - currentInstance = instance; - try { + 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); - } finally { - currentInstance = previousInstance; } + } finally { + currentInstance = previousInstance; } } @@ -569,15 +595,13 @@ export function unmountEventResponder( const onUnmount = responder.onUnmount; if (onUnmount !== undefined) { let {props, state} = eventComponentInstance; - const previousEventQueue = currentEventQueue; - const previousInstance = currentInstance; currentEventQueue = createEventQueue(); currentInstance = eventComponentInstance; try { onUnmount(eventResponderContext, props, state); } finally { - currentEventQueue = previousEventQueue; - currentInstance = previousInstance; + currentEventQueue = null; + currentInstance = null; currentTimers = null; } } @@ -589,3 +613,11 @@ export function unmountEventResponder( 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 d92a9729204a5..38ca51d5414d4 100644 --- a/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js @@ -255,11 +255,12 @@ describe('DOMEventResponderSystem', () => { (event, context, props) => { if (props.onMagicClick) { const syntheticEvent = { - listener: props.onMagicClick, target: event.target, type: 'magicclick', }; - context.dispatchEvent(syntheticEvent, {discrete: true}); + context.dispatchEvent(syntheticEvent, props.onMagicClick, { + discrete: true, + }); } }, ); @@ -292,29 +293,32 @@ describe('DOMEventResponderSystem', () => { undefined, (event, context, props) => { const pressEvent = { - listener: props.onPress, target: event.target, type: 'press', }; - context.dispatchEvent(pressEvent, {discrete: true}); + context.dispatchEvent(pressEvent, props.onPress, {discrete: true}); context.setTimeout(() => { if (props.onLongPress) { const longPressEvent = { - listener: props.onLongPress, target: event.target, type: 'longpress', }; - context.dispatchEvent(longPressEvent, {discrete: true}); + context.dispatchEvent(longPressEvent, props.onLongPress, { + discrete: true, + }); } if (props.onLongPressChange) { const longPressChangeEvent = { - listener: props.onLongPressChange, target: event.target, type: 'longpresschange', }; - context.dispatchEvent(longPressChangeEvent, {discrete: true}); + context.dispatchEvent( + longPressChangeEvent, + props.onLongPressChange, + {discrete: true}, + ); } }, 500); }, diff --git a/packages/react-events/src/Drag.js b/packages/react-events/src/Drag.js index 80e1be4966d50..b06831851a30f 100644 --- a/packages/react-events/src/Drag.js +++ b/packages/react-events/src/Drag.js @@ -43,7 +43,6 @@ type EventData = { type DragEventType = 'dragstart' | 'dragend' | 'dragchange' | 'dragmove'; type DragEvent = {| - listener: DragEvent => void, target: Element | Document, type: DragEventType, diffX?: number, @@ -53,11 +52,9 @@ type DragEvent = {| function createDragEvent( type: DragEventType, target: Element | Document, - listener: DragEvent => void, eventData?: EventData, ): DragEvent { return { - listener, target, type, ...eventData, @@ -73,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 = { diff --git a/packages/react-events/src/Focus.js b/packages/react-events/src/Focus.js index 19f555fe74ff2..355c217b56006 100644 --- a/packages/react-events/src/Focus.js +++ b/packages/react-events/src/Focus.js @@ -27,7 +27,6 @@ type FocusState = { type FocusEventType = 'focus' | 'blur' | 'focuschange'; type FocusEvent = {| - listener: FocusEvent => void, target: Element | Document, type: FocusEventType, |}; @@ -40,10 +39,8 @@ const targetEventTypes = [ function createFocusEvent( type: FocusEventType, target: Element | Document, - listener: FocusEvent => void, ): FocusEvent { return { - listener, target, type, }; @@ -59,15 +56,15 @@ function dispatchFocusInEvents( return; } if (props.onFocus) { - const syntheticEvent = createFocusEvent('focus', target, props.onFocus); - context.dispatchEvent(syntheticEvent, {discrete: true}); + const syntheticEvent = createFocusEvent('focus', target); + context.dispatchEvent(syntheticEvent, props.onFocus, {discrete: true}); } if (props.onFocusChange) { const listener = () => { props.onFocusChange(true); }; - const syntheticEvent = createFocusEvent('focuschange', target, listener); - context.dispatchEvent(syntheticEvent, {discrete: true}); + const syntheticEvent = createFocusEvent('focuschange', target); + context.dispatchEvent(syntheticEvent, listener, {discrete: true}); } } @@ -81,15 +78,15 @@ function dispatchFocusOutEvents( return; } if (props.onBlur) { - const syntheticEvent = createFocusEvent('blur', target, props.onBlur); - context.dispatchEvent(syntheticEvent, {discrete: true}); + const syntheticEvent = createFocusEvent('blur', target); + context.dispatchEvent(syntheticEvent, props.onBlur, {discrete: true}); } if (props.onFocusChange) { const listener = () => { props.onFocusChange(false); }; - const syntheticEvent = createFocusEvent('focuschange', target, listener); - context.dispatchEvent(syntheticEvent, {discrete: true}); + const syntheticEvent = createFocusEvent('focuschange', target); + context.dispatchEvent(syntheticEvent, listener, {discrete: true}); } } diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index 60b90676d8b78..a7c3a20d2daf7 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -36,7 +36,6 @@ type HoverState = { type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange' | 'hovermove'; type HoverEvent = {| - listener: HoverEvent => void, target: Element | Document, type: HoverEventType, |}; @@ -59,10 +58,8 @@ if (typeof window !== 'undefined' && window.PointerEvent === undefined) { function createHoverEvent( type: HoverEventType, target: Element | Document, - listener: HoverEvent => void, ): HoverEvent { return { - listener, target, type, }; @@ -78,12 +75,8 @@ function dispatchHoverChangeEvent( const listener = () => { props.onHoverChange(bool); }; - const syntheticEvent = createHoverEvent( - 'hoverchange', - event.target, - listener, - ); - context.dispatchEvent(syntheticEvent, {discrete: true}); + const syntheticEvent = createHoverEvent('hoverchange', event.target); + context.dispatchEvent(syntheticEvent, listener, {discrete: true}); } function dispatchHoverStartEvents( @@ -108,12 +101,10 @@ function dispatchHoverStartEvents( state.isActiveHovered = true; if (props.onHoverStart) { - const syntheticEvent = createHoverEvent( - 'hoverstart', - target, - props.onHoverStart, - ); - context.dispatchEvent(syntheticEvent, {discrete: true}); + const syntheticEvent = createHoverEvent('hoverstart', target); + context.dispatchEvent(syntheticEvent, props.onHoverStart, { + discrete: true, + }); } if (props.onHoverChange) { dispatchHoverChangeEvent(event, context, props, state); @@ -159,12 +150,8 @@ function dispatchHoverEndEvents( state.isActiveHovered = false; if (props.onHoverEnd) { - const syntheticEvent = createHoverEvent( - 'hoverend', - target, - props.onHoverEnd, - ); - context.dispatchEvent(syntheticEvent, {discrete: true}); + const syntheticEvent = createHoverEvent('hoverend', target); + context.dispatchEvent(syntheticEvent, props.onHoverEnd, {discrete: true}); } if (props.onHoverChange) { dispatchHoverChangeEvent(event, context, props, state); @@ -292,9 +279,10 @@ const HoverResponder = { const syntheticEvent = createHoverEvent( 'hovermove', event.target, - props.onHoverMove, ); - context.dispatchEvent(syntheticEvent, {discrete: false}); + context.dispatchEvent(syntheticEvent, props.onHoverMove, { + discrete: false, + }); } } } diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index 1753301a40013..c747212719afb 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -71,7 +71,6 @@ type PressEventType = | 'longpresschange'; type PressEvent = {| - listener: PressEvent => void, target: Element | Document, type: PressEventType, pointerType: PointerType, @@ -115,11 +114,9 @@ if (typeof window !== 'undefined' && window.PointerEvent === undefined) { function createPressEvent( type: PressEventType, target: Element | Document, - listener: PressEvent => void, pointerType: PointerType, ): PressEvent { return { - listener, target, type, pointerType, @@ -135,9 +132,10 @@ function dispatchEvent( ): void { const target = ((state.pressTarget: any): Element | Document); const pointerType = state.pointerType; - const syntheticEvent = createPressEvent(name, target, listener, pointerType); + const syntheticEvent = createPressEvent(name, target, pointerType); context.dispatchEvent( syntheticEvent, + listener, options || { discrete: true, }, diff --git a/packages/react-events/src/Swipe.js b/packages/react-events/src/Swipe.js index 2b40e79d5a972..eea03c6d22f9a 100644 --- a/packages/react-events/src/Swipe.js +++ b/packages/react-events/src/Swipe.js @@ -33,7 +33,6 @@ type EventData = { type SwipeEventType = 'swipeleft' | 'swiperight' | 'swipeend' | 'swipemove'; type SwipeEvent = {| - listener: SwipeEvent => void, target: Element | Document, type: SwipeEventType, diffX?: number, @@ -43,11 +42,9 @@ type SwipeEvent = {| function createSwipeEvent( type: SwipeEventType, target: Element | Document, - listener: SwipeEvent => void, eventData?: EventData, ): SwipeEvent { return { - listener, target, type, ...eventData, @@ -63,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 = { diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 7739ce924f062..cc6a9c3473781 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -145,6 +145,7 @@ export type ReactResponderDispatchEventOptions = { export type ReactResponderContext = { dispatchEvent: ( eventObject: Object, + listener: (Object) => void, otpions: ReactResponderDispatchEventOptions, ) => void, dispatchStopPropagation: (passive?: boolean) => void, From 805e7f87334551c559ab5162c701f82c105ad0b9 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Fri, 12 Apr 2019 04:23:03 -0700 Subject: [PATCH 40/41] React events: add unmounting to Focus (#15396) --- packages/react-events/src/Focus.js | 80 ++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/packages/react-events/src/Focus.js b/packages/react-events/src/Focus.js index 355c217b56006..a710fbe3bc942 100644 --- a/packages/react-events/src/Focus.js +++ b/packages/react-events/src/Focus.js @@ -22,6 +22,7 @@ type FocusProps = { type FocusState = { isFocused: boolean, + focusTarget: null | Element | Document, }; type FocusEventType = 'focus' | 'blur' | 'focuschange'; @@ -47,54 +48,87 @@ function createFocusEvent( } function dispatchFocusInEvents( - event: ReactResponderEvent, + event: null | ReactResponderEvent, context: ReactResponderContext, props: FocusProps, + state: FocusState, ) { - const {nativeEvent, target} = event; - if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) { - return; + if (event != null) { + const {nativeEvent} = event; + if ( + context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget) + ) { + return; + } } if (props.onFocus) { - const syntheticEvent = createFocusEvent('focus', target); + const syntheticEvent = createFocusEvent( + 'focus', + ((state.focusTarget: any): Element | Document), + ); context.dispatchEvent(syntheticEvent, props.onFocus, {discrete: true}); } if (props.onFocusChange) { const listener = () => { props.onFocusChange(true); }; - const syntheticEvent = createFocusEvent('focuschange', target); + const syntheticEvent = createFocusEvent( + 'focuschange', + ((state.focusTarget: any): Element | Document), + ); context.dispatchEvent(syntheticEvent, listener, {discrete: true}); } } function dispatchFocusOutEvents( - event: ReactResponderEvent, + event: null | ReactResponderEvent, context: ReactResponderContext, props: FocusProps, + state: FocusState, ) { - const {nativeEvent, target} = event; - if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) { - return; + if (event != null) { + const {nativeEvent} = event; + if ( + context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget) + ) { + return; + } } if (props.onBlur) { - const syntheticEvent = createFocusEvent('blur', target); + const syntheticEvent = createFocusEvent( + 'blur', + ((state.focusTarget: any): Element | Document), + ); context.dispatchEvent(syntheticEvent, props.onBlur, {discrete: true}); } if (props.onFocusChange) { const listener = () => { props.onFocusChange(false); }; - const syntheticEvent = createFocusEvent('focuschange', target); + const syntheticEvent = createFocusEvent( + 'focuschange', + ((state.focusTarget: any): Element | Document), + ); context.dispatchEvent(syntheticEvent, listener, {discrete: true}); } } +function unmountResponder( + context: ReactResponderContext, + props: FocusProps, + state: FocusState, +): void { + if (state.isFocused) { + dispatchFocusOutEvents(null, context, props, state); + } +} + const FocusResponder = { targetEventTypes, createInitialState(): FocusState { return { isFocused: false, + focusTarget: null, }; }, onEvent( @@ -103,25 +137,41 @@ const FocusResponder = { props: Object, state: FocusState, ): void { - const {type} = event; + const {type, target} = event; switch (type) { case 'focus': { if (!state.isFocused && !context.hasOwnership()) { - dispatchFocusInEvents(event, context, props); + state.focusTarget = target; + dispatchFocusInEvents(event, context, props, state); state.isFocused = true; } break; } case 'blur': { if (state.isFocused) { - dispatchFocusOutEvents(event, 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 { From 3438e5ce879883590f68370b9ad6448a8ffdfdc1 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Fri, 12 Apr 2019 13:26:27 +0100 Subject: [PATCH 41/41] Experimental Event API: Add Hover onUnmount support (#15394) --- packages/react-events/src/Hover.js | 72 ++++++++++++++++++++++++------ packages/react-events/src/Press.js | 1 - 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index a7c3a20d2daf7..dbb90f45380f1 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -24,6 +24,7 @@ type HoverProps = { }; type HoverState = { + hoverTarget: null | Element | Document, isActiveHovered: boolean, isHovered: boolean, isInHitSlop: boolean, @@ -66,7 +67,6 @@ function createHoverEvent( } function dispatchHoverChangeEvent( - event: ReactResponderEvent, context: ReactResponderContext, props: HoverProps, state: HoverState, @@ -75,7 +75,10 @@ function dispatchHoverChangeEvent( const listener = () => { props.onHoverChange(bool); }; - const syntheticEvent = createHoverEvent('hoverchange', event.target); + const syntheticEvent = createHoverEvent( + 'hoverchange', + ((state.hoverTarget: any): Element | Document), + ); context.dispatchEvent(syntheticEvent, listener, {discrete: true}); } @@ -85,9 +88,14 @@ function dispatchHoverStartEvents( props: HoverProps, state: HoverState, ): void { - const {nativeEvent, target} = event; - if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) { - return; + const target = state.hoverTarget; + if (event !== null) { + const {nativeEvent} = event; + if ( + context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget) + ) { + return; + } } state.isHovered = true; @@ -101,13 +109,16 @@ function dispatchHoverStartEvents( state.isActiveHovered = true; if (props.onHoverStart) { - const syntheticEvent = createHoverEvent('hoverstart', target); + const syntheticEvent = createHoverEvent( + 'hoverstart', + ((target: any): Element | Document), + ); context.dispatchEvent(syntheticEvent, props.onHoverStart, { discrete: true, }); } if (props.onHoverChange) { - dispatchHoverChangeEvent(event, context, props, state); + dispatchHoverChangeEvent(context, props, state); } }; @@ -129,14 +140,19 @@ function dispatchHoverStartEvents( } function dispatchHoverEndEvents( - event: ReactResponderEvent, + event: null | ReactResponderEvent, context: ReactResponderContext, props: HoverProps, state: HoverState, ) { - const {nativeEvent, target} = event; - if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) { - return; + const target = state.hoverTarget; + if (event !== null) { + const {nativeEvent} = event; + if ( + context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget) + ) { + return; + } } state.isHovered = false; @@ -150,11 +166,14 @@ function dispatchHoverEndEvents( state.isActiveHovered = false; if (props.onHoverEnd) { - const syntheticEvent = createHoverEvent('hoverend', target); + const syntheticEvent = createHoverEvent( + 'hoverend', + ((target: any): Element | Document), + ); context.dispatchEvent(syntheticEvent, props.onHoverEnd, {discrete: true}); } if (props.onHoverChange) { - dispatchHoverChangeEvent(event, context, props, state); + dispatchHoverChangeEvent(context, props, state); } }; @@ -179,6 +198,16 @@ function calculateDelayMS(delay: ?number, min = 0, fallback = 0) { 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); + } +} + const HoverResponder = { targetEventTypes, createInitialState() { @@ -231,6 +260,7 @@ const HoverResponder = { state.isInHitSlop = true; return; } + state.hoverTarget = target; dispatchHoverStartEvents(event, context, props, state); } break; @@ -241,6 +271,7 @@ const HoverResponder = { dispatchHoverEndEvents(event, context, props, state); } state.isInHitSlop = false; + state.hoverTarget = null; state.isTouched = false; state.skipMouseAfterPointer = false; break; @@ -293,12 +324,27 @@ const HoverResponder = { case 'pointercancel': { if (state.isHovered && !state.isTouched) { 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 c747212719afb..f41638f02bb7b 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -699,7 +699,6 @@ const PressResponder = { ) { unmountResponder(context, props, state); }, - // TODO This method doesn't work as of yet onOwnershipChange( context: ReactResponderContext, props: PressProps,