diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js index 635196f8db529..c6fc1746e1351 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRulesOfHooks-test.js @@ -19,8 +19,17 @@ ESLintTester.setDefaultConfig({ }, }); -const eslintTester = new ESLintTester(); -eslintTester.run('react-hooks', ReactHooksESLintRule, { +// *************************************************** +// For easier local testing, you can add to any case: +// { +// skip: true, +// --or-- +// only: true, +// ... +// } +// *************************************************** + +const tests = { valid: [ ` // Valid because components can use hooks. @@ -223,21 +232,20 @@ eslintTester.run('react-hooks', ReactHooksESLintRule, { (class {i() { useState(); }}); `, ` - // Currently valid although we *could* consider these invalid. - // It doesn't make a lot of difference because it would crash early. + // Valid because they're not matching use[A-Z]. + fooState(); use(); _use(); - useState(); _useState(); - use42(); - useHook(); use_hook(); - React.useState(); `, ` - // Regression test for the popular "history" library - const {createHistory, useBasename} = require('history-2.1.2'); - const browserHistory = useBasename(createHistory)({ + // This is grey area. + // Currently it's valid (although React.useCallback would fail here). + // We could also get stricter and disallow it, just like we did + // with non-namespace use*() top-level calls. + const History = require('history-2.1.2'); + const browserHistory = History.useBasename(History.createHistory)({ basename: '/', }); `, @@ -669,8 +677,63 @@ eslintTester.run('react-hooks', ReactHooksESLintRule, { conditionalError('useState'), ], }, + { + code: ` + // Invalid because it's dangerous and might not warn otherwise. + // This *must* be invalid. + function useHook({ bar }) { + let foo1 = bar && useState(); + let foo2 = bar || useState(); + let foo3 = bar ?? useState(); + } + `, + errors: [ + conditionalError('useState'), + conditionalError('useState'), + // TODO: ideally this *should* warn, but ESLint + // doesn't plan full support for ?? until it advances. + // conditionalError('useState'), + ], + }, + { + code: ` + // Invalid because it's dangerous. + // Normally, this would crash, but not if you use inline requires. + // This *must* be invalid. + // It's expected to have some false positives, but arguably + // they are confusing anyway due to the use*() convention + // already being associated with Hooks. + useState(); + if (foo) { + const foo = React.useCallback(() => {}); + } + useCustomHook(); + `, + errors: [ + topLevelError('useState'), + topLevelError('React.useCallback'), + topLevelError('useCustomHook'), + ], + }, + { + code: ` + // Technically this is a false positive. + // We *could* make it valid (and it used to be). + // + // However, top-level Hook-like calls can be very dangerous + // in environments with inline requires because they can mask + // the runtime error by accident. + // So we prefer to disallow it despite the false positive. + + const {createHistory, useBasename} = require('history-2.1.2'); + const browserHistory = useBasename(createHistory)({ + basename: '/', + }); + `, + errors: [topLevelError('useBasename')], + }, ], -}); +}; function conditionalError(hook, hasPreviousFinalizer = false) { return { @@ -708,3 +771,42 @@ function genericError(hook) { 'Hook function.', }; } + +function topLevelError(hook) { + return { + message: + `React Hook "${hook}" cannot be called at the top level. React Hooks ` + + 'must be called in a React function component or a custom React ' + + 'Hook function.', + }; +} + +// For easier local testing +if (!process.env.CI) { + let only = []; + let skipped = []; + [...tests.valid, ...tests.invalid].forEach(t => { + if (t.skip) { + delete t.skip; + skipped.push(t); + } + if (t.only) { + delete t.only; + only.push(t); + } + }); + const predicate = t => { + if (only.length > 0) { + return only.indexOf(t) !== -1; + } + if (skipped.length > 0) { + return skipped.indexOf(t) === -1; + } + return true; + }; + tests.valid = tests.valid.filter(predicate); + tests.invalid = tests.invalid.filter(predicate); +} + +const eslintTester = new ESLintTester(); +eslintTester.run('react-hooks', ReactHooksESLintRule, tests); diff --git a/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js b/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js index 405a6641a335d..fff4566cfbe61 100644 --- a/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js +++ b/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js @@ -432,9 +432,13 @@ export default { 'React Hook function.'; context.report({node: hook, message}); } else if (codePathNode.type === 'Program') { - // For now, ignore if it's in top level scope. // We could warn here but there are false positives related // configuring libraries like `history`. + const message = + `React Hook "${context.getSource(hook)}" cannot be called ` + + 'at the top level. React Hooks must be called in a ' + + 'React function component or a custom React Hook function.'; + context.report({node: hook, message}); } else { // Assume in all other cases the user called a hook in some // random function callback. This should usually be true for diff --git a/packages/legacy-events/ReactGenericBatching.js b/packages/legacy-events/ReactGenericBatching.js index e92f15b7af95d..d3cf009c4571a 100644 --- a/packages/legacy-events/ReactGenericBatching.js +++ b/packages/legacy-events/ReactGenericBatching.js @@ -11,6 +11,8 @@ import { } from './ReactControlledComponent'; import {enableFlareAPI} from 'shared/ReactFeatureFlags'; +import {invokeGuardedCallbackAndCatchFirstError} from 'shared/ReactErrorUtils'; + // Used as a way to call batchedUpdates when we don't have a reference to // the renderer. Such as when we're dispatching events or if third party // libraries need to call batchedUpdates. Eventually, this API will go away when @@ -28,6 +30,7 @@ let flushDiscreteUpdatesImpl = function() {}; let batchedEventUpdatesImpl = batchedUpdatesImpl; let isInsideEventHandler = false; +let isBatchingEventUpdates = false; function finishEventHandler() { // Here we wait until all updates have propagated, which is important @@ -60,20 +63,31 @@ export function batchedUpdates(fn, bookkeeping) { } export function batchedEventUpdates(fn, a, b) { - if (isInsideEventHandler) { + if (isBatchingEventUpdates) { // If we are currently inside another batch, we need to wait until it // fully completes before restoring state. return fn(a, b); } - isInsideEventHandler = true; + isBatchingEventUpdates = true; try { return batchedEventUpdatesImpl(fn, a, b); } finally { - isInsideEventHandler = false; + isBatchingEventUpdates = false; finishEventHandler(); } } +export function executeUserEventHandler(fn: any => void, value: any) { + const previouslyInEventHandler = isInsideEventHandler; + try { + isInsideEventHandler = true; + const type = typeof value === 'object' && value !== null ? value.type : ''; + invokeGuardedCallbackAndCatchFirstError(type, fn, undefined, value); + } finally { + isInsideEventHandler = previouslyInEventHandler; + } +} + export function discreteUpdates(fn, a, b, c) { const prevIsInsideEventHandler = isInsideEventHandler; isInsideEventHandler = true; diff --git a/packages/react-art/src/ReactART.js b/packages/react-art/src/ReactART.js index bf97e65031ffa..9b2c5382e1db3 100644 --- a/packages/react-art/src/ReactART.js +++ b/packages/react-art/src/ReactART.js @@ -66,7 +66,7 @@ class Surface extends React.Component { this._surface = Mode.Surface(+width, +height, this._tagRef); - this._mountNode = createContainer(this._surface, LegacyRoot, false); + this._mountNode = createContainer(this._surface, LegacyRoot, false, null); updateContainer(this.props.children, this._mountNode, this); } diff --git a/packages/react-devtools/CHANGELOG.md b/packages/react-devtools/CHANGELOG.md index 8604ca7a83efc..bc10a248baca1 100644 --- a/packages/react-devtools/CHANGELOG.md +++ b/packages/react-devtools/CHANGELOG.md @@ -1,23 +1,47 @@ # React DevTools changelog - + + -## 4.0.0 (release date TBD) +## 4.0.5 (August 19, 2019) +#### Bug fixes +* Props, state, and context values are alpha sorted. +* Standalone DevTools properly serves backend script over localhost:8097 + +## 4.0.4 (August 18, 2019) +#### Bug fixes +* Bugfix for potential error if a min-duration commit filter is applied after selecting a fiber in the Profiler UI. + +## 4.0.3 (August 17, 2019) +#### Bug fixes +* ES6 `Map` and `Set`, typed arrays, and other unserializable types (e.g. Immutable JS) can now be inspected. +* Empty objects and arrays now display an "(empty)" label to the right to reduce confusion. +* Components that use only the `useContext` hook now properly display hooks values in side panel. +* Style editor now supports single quotes around string values (e.g. both `"red"` and `'red'`). +* Fixed edge case bug that prevented profiling when both React v16 and v15 were present on a page. + +## 4.0.2 (August 15, 2019) +#### Permissions cleanup +* Removed unnecessary `webNavigation ` permission from Chrome and Firefox extensions. + +## 4.0.1 (August 15, 2019) +#### Permissions cleanup +* Removed unnecessary ``, `background`, and `tabs` permissions from Chrome and Firefox extensions. + +## 4.0.0 (August 15, 2019) ### General changes #### Improved performance The legacy DevTools extension used to add significant performance overhead, making it unusable for some larger React applications. That overhead has been effectively eliminated in version 4. -[Learn more](https://github.com/bvaughn/react-devtools-experimental/blob/master/OVERVIEW.md) about the performance optimizations that made this possible. +[Learn more](https://github.com/facebook/react/blob/master/packages/react-devtools/OVERVIEW.md) about the performance optimizations that made this possible. #### Component stacks @@ -41,9 +65,9 @@ Host nodes (e.g. HTML `
`, React Native `View`) are now hidden by default, b Filter preferences are remembered between sessions. -#### No more in-line props +#### No more inline props -Components in the tree no longer show in-line props. This was done to [make DevTools faster](https://github.com/bvaughn/react-devtools-experimental/blob/master/OVERVIEW.md) and to make it easier to browse larger component trees. +Components in the tree no longer show inline props. This was done to [make DevTools faster](https://github.com/facebook/react/blob/master/packages/react-devtools/OVERVIEW.md) and to make it easier to browse larger component trees. You can view a component's props, state, and hooks by selecting it: @@ -51,7 +75,7 @@ You can view a component's props, state, and hooks by selecting it: #### "Rendered by" list -In React, an element's "owner" refers the thing that rendered it. Sometimes an element's parent is also its owner, but usually they're different. This distinction is important because props come from owners. +In React, an element's "owner" refers to the thing that rendered it. Sometimes an element's parent is also its owner, but usually they're different. This distinction is important because props come from owners. ![Example code](https://user-images.githubusercontent.com/29597/62229551-bbcf1600-b374-11e9-8411-8ff411f4f847.png) @@ -101,6 +125,12 @@ Components decorated with multiple HOCs show the topmost badge and a count. Sele ![Screenshot showing a component with multiple HOC badges](https://user-images.githubusercontent.com/29597/62303729-7fadbb00-b431-11e9-8685-45f5ab52b30b.png) +#### Restoring selection between reloads + +DevTools now attempts to restore the previously selected element when you reload the page. + +![Video demonstrating selection persistence](https://user-images.githubusercontent.com/810438/63130054-2c02ac00-bfb1-11e9-92fa-382e9e433638.gif) + #### Suspense toggle React's experimental [Suspense API](https://reactjs.org/docs/react-api.html#suspense) lets components "wait" for something before rendering. `` components can be used to specify loading states when components deeper in the tree are waiting to render. diff --git a/packages/react-devtools/OVERVIEW.md b/packages/react-devtools/OVERVIEW.md index b7ec0639ca9e0..7c8228791393a 100644 --- a/packages/react-devtools/OVERVIEW.md +++ b/packages/react-devtools/OVERVIEW.md @@ -229,7 +229,7 @@ Even when dealing with a single component, serializing deeply nested properties Hooks present a unique challenge for the DevTools because of the concept of _custom_ hooks. (A custom hook is essentially any function that calls at least one of the built-in hooks. By convention custom hooks also have names that begin with "use".) -So how does DevTools identify custom functions called from within third party components? It does this by temporarily overriding React's built-in hooks and shallow rendering the component in question. Whenever one of the (overridden) built-in hooks are called, it parses the call stack to spot potential custom hooks (functions between the component itself and the built-in hook). This approach enables it to build a tree structure describing all of the calls to both the built-in _and_ custom hooks, along with the values passed to those hooks. (If you're interested in learning more about this, [here is the source code](https://github.com/bvaughn/react-devtools-experimental/blob/master/src/backend/ReactDebugHooks.js).) +So how does DevTools identify custom functions called from within third party components? It does this by temporarily overriding React's built-in hooks and shallow rendering the component in question. Whenever one of the (overridden) built-in hooks are called, it parses the call stack to spot potential custom hooks (functions between the component itself and the built-in hook). This approach enables it to build a tree structure describing all of the calls to both the built-in _and_ custom hooks, along with the values passed to those hooks. (If you're interested in learning more about this, [here is the source code](https://github.com/facebook/react/blob/master/packages/react-debug-tools/src/ReactDebugHooks.js).) > **Note**: DevTools obtains hooks info by re-rendering a component. > Breakpoints will be invoked during this additional (shallow) render, diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 96381177b487e..11c6ad904b9b1 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -24,6 +24,7 @@ describe('ReactDOMServerPartialHydration', () => { ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.enableSuspenseServerRenderer = true; + ReactFeatureFlags.enableSuspenseCallback = true; React = require('react'); ReactDOM = require('react-dom'); @@ -92,6 +93,153 @@ describe('ReactDOMServerPartialHydration', () => { expect(ref.current).toBe(span); }); + it('calls the hydration callbacks after hydration or deletion', async () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + function Child() { + if (suspend) { + throw promise; + } else { + return 'Hello'; + } + } + + let suspend2 = false; + let promise2 = new Promise(() => {}); + function Child2() { + if (suspend2) { + throw promise2; + } else { + return 'World'; + } + } + + function App({value}) { + return ( +
+ + + + + + +
+ ); + } + + // First we render the final HTML. With the streaming renderer + // this may have suspense points on the server but here we want + // to test the completed HTML. Don't suspend on the server. + suspend = false; + suspend2 = false; + let finalHTML = ReactDOMServer.renderToString(); + + let container = document.createElement('div'); + container.innerHTML = finalHTML; + + let hydrated = []; + let deleted = []; + + // On the client we don't have all data yet but we want to start + // hydrating anyway. + suspend = true; + suspend2 = true; + let root = ReactDOM.unstable_createRoot(container, { + hydrate: true, + hydrationOptions: { + onHydrated(node) { + hydrated.push(node); + }, + onDeleted(node) { + deleted.push(node); + }, + }, + }); + act(() => { + root.render(); + }); + + expect(hydrated.length).toBe(0); + expect(deleted.length).toBe(0); + + await act(async () => { + // Resolving the promise should continue hydration + suspend = false; + resolve(); + await promise; + }); + + expect(hydrated.length).toBe(1); + expect(deleted.length).toBe(0); + + // Performing an update should force it to delete the boundary + root.render(); + + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + expect(hydrated.length).toBe(1); + expect(deleted.length).toBe(1); + }); + + it('calls the onDeleted hydration callback if the parent gets deleted', async () => { + let suspend = false; + let promise = new Promise(() => {}); + function Child() { + if (suspend) { + throw promise; + } else { + return 'Hello'; + } + } + + function App({deleted}) { + if (deleted) { + return null; + } + return ( +
+ + + +
+ ); + } + + suspend = false; + let finalHTML = ReactDOMServer.renderToString(); + + let container = document.createElement('div'); + container.innerHTML = finalHTML; + + let deleted = []; + + // On the client we don't have all data yet but we want to start + // hydrating anyway. + suspend = true; + let root = ReactDOM.unstable_createRoot(container, { + hydrate: true, + hydrationOptions: { + onDeleted(node) { + deleted.push(node); + }, + }, + }); + act(() => { + root.render(); + }); + + expect(deleted.length).toBe(0); + + act(() => { + root.render(); + }); + + // The callback should have been invoked. + expect(deleted.length).toBe(1); + }); + it('warns and replaces the boundary content in legacy mode', async () => { let suspend = false; let resolve; diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js index b5ecccca7e9df..742bd4156533e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js @@ -37,9 +37,11 @@ function initModules() { }; } -const {resetModules, serverRender} = ReactDOMServerIntegrationUtils( - initModules, -); +const { + itThrowsWhenRendering, + resetModules, + serverRender, +} = ReactDOMServerIntegrationUtils(initModules); describe('ReactDOMServerSuspense', () => { beforeEach(() => { @@ -133,4 +135,86 @@ describe('ReactDOMServerSuspense', () => { expect(divA).toBe(divA2); expect(divB).toBe(divB2); }); + + itThrowsWhenRendering( + 'a suspending component outside a Suspense node', + async render => { + await render( +
+ + + +
, + 1, + ); + }, + 'Add a component higher in the tree', + ); + + itThrowsWhenRendering( + 'a suspending component without a Suspense above', + async render => { + await render( +
+ +
, + 1, + ); + }, + 'Add a component higher in the tree', + ); + + it('does not get confused by throwing null', () => { + function Bad() { + // eslint-disable-next-line no-throw-literal + throw null; + } + + let didError; + let error; + try { + ReactDOMServer.renderToString(); + } catch (err) { + didError = true; + error = err; + } + expect(didError).toBe(true); + expect(error).toBe(null); + }); + + it('does not get confused by throwing undefined', () => { + function Bad() { + // eslint-disable-next-line no-throw-literal + throw undefined; + } + + let didError; + let error; + try { + ReactDOMServer.renderToString(); + } catch (err) { + didError = true; + error = err; + } + expect(didError).toBe(true); + expect(error).toBe(undefined); + }); + + it('does not get confused by throwing a primitive', () => { + function Bad() { + // eslint-disable-next-line no-throw-literal + throw 'foo'; + } + + let didError; + let error; + try { + ReactDOMServer.renderToString(); + } catch (err) { + didError = true; + error = err; + } + expect(didError).toBe(true); + expect(error).toBe('foo'); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactServerRendering-test.js b/packages/react-dom/src/__tests__/ReactServerRendering-test.js index 4cd37d469ad67..bb29d665a47eb 100644 --- a/packages/react-dom/src/__tests__/ReactServerRendering-test.js +++ b/packages/react-dom/src/__tests__/ReactServerRendering-test.js @@ -705,6 +705,70 @@ describe('ReactDOMServer', () => { }).toThrow('ReactDOMServer does not yet support lazy-loaded components.'); }); + it('throws when suspending on the server', () => { + function AsyncFoo() { + throw new Promise(() => {}); + } + + expect(() => { + ReactDOMServer.renderToString(); + }).toThrow('ReactDOMServer does not yet support Suspense.'); + }); + + it('does not get confused by throwing null', () => { + function Bad() { + // eslint-disable-next-line no-throw-literal + throw null; + } + + let didError; + let error; + try { + ReactDOMServer.renderToString(); + } catch (err) { + didError = true; + error = err; + } + expect(didError).toBe(true); + expect(error).toBe(null); + }); + + it('does not get confused by throwing undefined', () => { + function Bad() { + // eslint-disable-next-line no-throw-literal + throw undefined; + } + + let didError; + let error; + try { + ReactDOMServer.renderToString(); + } catch (err) { + didError = true; + error = err; + } + expect(didError).toBe(true); + expect(error).toBe(undefined); + }); + + it('does not get confused by throwing a primitive', () => { + function Bad() { + // eslint-disable-next-line no-throw-literal + throw 'foo'; + } + + let didError; + let error; + try { + ReactDOMServer.renderToString(); + } catch (err) { + didError = true; + error = err; + } + expect(didError).toBe(true); + expect(error).toBe('foo'); + }); + it('should throw (in dev) when children are mutated during render', () => { function Wrapper(props) { props.children[1] =

; // Mutation is illegal diff --git a/packages/react-dom/src/client/ReactDOM.js b/packages/react-dom/src/client/ReactDOM.js index 2e3ccea8de18f..e11756cb6bf88 100644 --- a/packages/react-dom/src/client/ReactDOM.js +++ b/packages/react-dom/src/client/ReactDOM.js @@ -367,15 +367,26 @@ ReactWork.prototype._onCommit = function(): void { function ReactSyncRoot( container: DOMContainer, tag: RootTag, - hydrate: boolean, + options: void | RootOptions, ) { // Tag is either LegacyRoot or Concurrent Root - const root = createContainer(container, tag, hydrate); + const hydrate = options != null && options.hydrate === true; + const hydrationCallbacks = + (options != null && options.hydrationOptions) || null; + const root = createContainer(container, tag, hydrate, hydrationCallbacks); this._internalRoot = root; } -function ReactRoot(container: DOMContainer, hydrate: boolean) { - const root = createContainer(container, ConcurrentRoot, hydrate); +function ReactRoot(container: DOMContainer, options: void | RootOptions) { + const hydrate = options != null && options.hydrate === true; + const hydrationCallbacks = + (options != null && options.hydrationOptions) || null; + const root = createContainer( + container, + ConcurrentRoot, + hydrate, + hydrationCallbacks, + ); this._internalRoot = root; } @@ -532,7 +543,15 @@ function legacyCreateRootFromDOMContainer( } // Legacy roots are not batched. - return new ReactSyncRoot(container, LegacyRoot, shouldHydrate); + return new ReactSyncRoot( + container, + LegacyRoot, + shouldHydrate + ? { + hydrate: true, + } + : undefined, + ); } function legacyRenderSubtreeIntoContainer( @@ -824,6 +843,10 @@ const ReactDOM: Object = { type RootOptions = { hydrate?: boolean, + hydrationOptions?: { + onHydrated?: (suspenseNode: Comment) => void, + onDeleted?: (suspenseNode: Comment) => void, + }, }; function createRoot( @@ -839,8 +862,7 @@ function createRoot( functionName, ); warnIfReactDOMContainerInDEV(container); - const hydrate = options != null && options.hydrate === true; - return new ReactRoot(container, hydrate); + return new ReactRoot(container, options); } function createSyncRoot( @@ -856,8 +878,7 @@ function createSyncRoot( functionName, ); warnIfReactDOMContainerInDEV(container); - const hydrate = options != null && options.hydrate === true; - return new ReactSyncRoot(container, BatchedRoot, hydrate); + return new ReactSyncRoot(container, BatchedRoot, options); } function warnIfReactDOMContainerInDEV(container) { diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 432ff1ba32e36..cc82d658b6e36 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -25,12 +25,12 @@ import { batchedEventUpdates, discreteUpdates, flushDiscreteUpdatesIfNeeded, + executeUserEventHandler, } from 'legacy-events/ReactGenericBatching'; import {enqueueStateRestore} from 'legacy-events/ReactControlledComponent'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; import warning from 'shared/warning'; import {enableFlareAPI} from 'shared/ReactFeatureFlags'; -import {invokeGuardedCallbackAndCatchFirstError} from 'shared/ReactErrorUtils'; import invariant from 'shared/invariant'; import { isFiberSuspenseAndTimedOut, @@ -61,12 +61,6 @@ export function setListenToResponderEventTypes( listenToResponderEventTypesImpl = _listenToResponderEventTypesImpl; } -type EventQueueItem = {| - listener: (val: any) => void, - value: any, -|}; -type EventQueue = Array; - type ResponderTimeout = {| id: TimeoutID, timers: Map, @@ -84,15 +78,10 @@ const rootEventTypesToEventResponderInstances: Map< DOMTopLevelEventType | string, Set, > = new Map(); -const ownershipChangeListeners: Set = new Set(); - -let globalOwner = null; let currentTimeStamp = 0; let currentTimers = new Map(); let currentInstance: null | ReactDOMEventResponderInstance = null; -let currentEventQueue: null | EventQueue = null; -let currentEventQueuePriority: EventPriority = ContinuousEvent; let currentTimerIDCounter = 0; let currentDocument: null | Document = null; @@ -104,12 +93,29 @@ const eventResponderContext: ReactDOMResponderContext = { ): void { validateResponderContext(); validateEventValue(eventValue); - if (eventPriority < currentEventQueuePriority) { - currentEventQueuePriority = eventPriority; + switch (eventPriority) { + case DiscreteEvent: { + flushDiscreteUpdatesIfNeeded(currentTimeStamp); + discreteUpdates(() => + executeUserEventHandler(eventListener, eventValue), + ); + break; + } + case UserBlockingEvent: { + if (enableUserBlockingEvents) { + runWithPriority(UserBlockingPriority, () => + executeUserEventHandler(eventListener, eventValue), + ); + } else { + executeUserEventHandler(eventListener, eventValue); + } + break; + } + case ContinuousEvent: { + executeUserEventHandler(eventListener, eventValue); + break; + } } - ((currentEventQueue: any): EventQueue).push( - createEventQueueItem(eventValue, eventListener), - ); }, isTargetWithinResponder(target: Element | Document): boolean { validateResponderContext(); @@ -196,25 +202,6 @@ const eventResponderContext: ReactDOMResponderContext = { } } }, - hasOwnership(): boolean { - validateResponderContext(); - return globalOwner === currentInstance; - }, - requestGlobalOwnership(): boolean { - validateResponderContext(); - if (globalOwner !== null) { - return false; - } - globalOwner = currentInstance; - triggerOwnershipListeners(); - return true; - }, - releaseOwnership(): boolean { - validateResponderContext(); - return releaseOwnershipForEventResponderInstance( - ((currentInstance: any): ReactDOMEventResponderInstance), - ); - }, setTimeout(func: () => void, delay): number { validateResponderContext(); if (currentTimers === null) { @@ -379,16 +366,6 @@ function collectFocusableElements( } } -function createEventQueueItem( - value: any, - listener: (val: any) => void, -): EventQueueItem { - return { - value, - listener, - }; -} - function doesFiberHaveResponder( fiber: Fiber, responder: ReactDOMEventResponder, @@ -409,17 +386,6 @@ function getActiveDocument(): Document { return ((currentDocument: any): Document); } -function releaseOwnershipForEventResponderInstance( - eventResponderInstance: ReactDOMEventResponderInstance, -): boolean { - if (globalOwner === eventResponderInstance) { - globalOwner = null; - triggerOwnershipListeners(); - return true; - } - return false; -} - function isFiberHostComponentFocusable(fiber: Fiber): boolean { if (fiber.tag !== HostComponent) { return false; @@ -452,24 +418,22 @@ function processTimers( delay: number, ): void { const timersArr = Array.from(timers.values()); - currentEventQueuePriority = ContinuousEvent; try { - for (let i = 0; i < timersArr.length; i++) { - const {instance, func, id, timeStamp} = timersArr[i]; - currentInstance = instance; - currentEventQueue = []; - currentTimeStamp = timeStamp + delay; - try { - func(); - } finally { - activeTimeouts.delete(id); + batchedEventUpdates(() => { + for (let i = 0; i < timersArr.length; i++) { + const {instance, func, id, timeStamp} = timersArr[i]; + currentInstance = instance; + currentTimeStamp = timeStamp + delay; + try { + func(); + } finally { + activeTimeouts.delete(id); + } } - } - processEventQueue(); + }); } finally { currentTimers = null; currentInstance = null; - currentEventQueue = null; currentTimeStamp = 0; } } @@ -508,45 +472,6 @@ function createDOMResponderEvent( }; } -function processEvents(eventQueue: EventQueue): void { - for (let i = 0, length = eventQueue.length; i < length; i++) { - const {value, listener} = eventQueue[i]; - const type = typeof value === 'object' && value !== null ? value.type : ''; - invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, value); - } -} - -function processEventQueue(): void { - const eventQueue = ((currentEventQueue: any): EventQueue); - if (eventQueue.length === 0) { - return; - } - switch (currentEventQueuePriority) { - case DiscreteEvent: { - flushDiscreteUpdatesIfNeeded(currentTimeStamp); - discreteUpdates(() => { - batchedEventUpdates(processEvents, eventQueue); - }); - break; - } - case UserBlockingEvent: { - if (enableUserBlockingEvents) { - runWithPriority( - UserBlockingPriority, - batchedEventUpdates.bind(null, processEvents, eventQueue), - ); - } else { - batchedEventUpdates(processEvents, eventQueue); - } - break; - } - case ContinuousEvent: { - batchedEventUpdates(processEvents, eventQueue); - break; - } - } -} - function responderEventTypesContainType( eventTypes: Array, type: string, @@ -571,12 +496,6 @@ function validateResponderTargetEventTypes( return false; } -function validateOwnership( - responderInstance: ReactDOMEventResponderInstance, -): boolean { - return globalOwner === null || globalOwner === responderInstance; -} - function traverseAndHandleEventResponderInstances( topLevelType: string, targetFiber: null | Fiber, @@ -610,22 +529,19 @@ function traverseAndHandleEventResponderInstances( const responderInstances = Array.from(respondersMap.values()); for (let i = 0, length = responderInstances.length; i < length; i++) { const responderInstance = responderInstances[i]; - - if (validateOwnership(responderInstance)) { - const {props, responder, state, target} = responderInstance; - if ( - !visitedResponders.has(responder) && - validateResponderTargetEventTypes(eventType, responder) - ) { - visitedResponders.add(responder); - const onEvent = responder.onEvent; - if (onEvent !== null) { - currentInstance = responderInstance; - responderEvent.responderTarget = ((target: any): - | Element - | Document); - onEvent(responderEvent, eventResponderContext, props, state); - } + const {props, responder, state, target} = responderInstance; + if ( + !visitedResponders.has(responder) && + validateResponderTargetEventTypes(eventType, responder) + ) { + visitedResponders.add(responder); + const onEvent = responder.onEvent; + if (onEvent !== null) { + currentInstance = responderInstance; + responderEvent.responderTarget = ((target: any): + | Element + | Document); + onEvent(responderEvent, eventResponderContext, props, state); } } } @@ -642,9 +558,6 @@ function traverseAndHandleEventResponderInstances( for (let i = 0; i < responderInstances.length; i++) { const responderInstance = responderInstances[i]; - if (!validateOwnership(responderInstance)) { - continue; - } const {props, responder, state, target} = responderInstance; const onRootEvent = responder.onRootEvent; if (onRootEvent !== null) { @@ -656,51 +569,20 @@ function traverseAndHandleEventResponderInstances( } } -function triggerOwnershipListeners(): void { - const listeningInstances = Array.from(ownershipChangeListeners); - const previousInstance = currentInstance; - const previousEventQueuePriority = currentEventQueuePriority; - const previousEventQueue = currentEventQueue; - try { - for (let i = 0; i < listeningInstances.length; i++) { - const instance = listeningInstances[i]; - const {props, responder, state} = instance; - currentInstance = instance; - currentEventQueuePriority = ContinuousEvent; - currentEventQueue = []; - const onOwnershipChange = ((responder: any): ReactDOMEventResponder) - .onOwnershipChange; - if (onOwnershipChange !== null) { - onOwnershipChange(eventResponderContext, props, state); - } - } - processEventQueue(); - } finally { - currentInstance = previousInstance; - currentEventQueue = previousEventQueue; - currentEventQueuePriority = previousEventQueuePriority; - } -} - export function mountEventResponder( responder: ReactDOMEventResponder, responderInstance: ReactDOMEventResponderInstance, props: Object, state: Object, ) { - if (responder.onOwnershipChange !== null) { - ownershipChangeListeners.add(responderInstance); - } const onMount = responder.onMount; if (onMount !== null) { - currentEventQueuePriority = ContinuousEvent; currentInstance = responderInstance; - currentEventQueue = []; try { - onMount(eventResponderContext, props, state); - processEventQueue(); + batchedEventUpdates(() => { + onMount(eventResponderContext, props, state); + }); } finally { - currentEventQueue = null; currentInstance = null; currentTimers = null; } @@ -714,22 +596,16 @@ export function unmountEventResponder( const onUnmount = responder.onUnmount; if (onUnmount !== null) { let {props, state} = responderInstance; - currentEventQueue = []; - currentEventQueuePriority = ContinuousEvent; currentInstance = responderInstance; try { - onUnmount(eventResponderContext, props, state); - processEventQueue(); + batchedEventUpdates(() => { + onUnmount(eventResponderContext, props, state); + }); } finally { - currentEventQueue = null; currentInstance = null; currentTimers = null; } } - releaseOwnershipForEventResponderInstance(responderInstance); - if (responder.onOwnershipChange !== null) { - ownershipChangeListeners.delete(responderInstance); - } const rootEventTypesSet = responderInstance.rootEventTypes; if (rootEventTypesSet !== null) { const rootEventTypes = Array.from(rootEventTypesSet); @@ -762,15 +638,11 @@ export function dispatchEventForResponderEventSystem( eventSystemFlags: EventSystemFlags, ): void { if (enableFlareAPI) { - const previousEventQueue = currentEventQueue; const previousInstance = currentInstance; const previousTimers = currentTimers; const previousTimeStamp = currentTimeStamp; const previousDocument = currentDocument; - const previousEventQueuePriority = currentEventQueuePriority; currentTimers = null; - currentEventQueue = []; - currentEventQueuePriority = ContinuousEvent; // nodeType 9 is DOCUMENT_NODE currentDocument = (nativeEventTarget: any).nodeType === 9 @@ -779,21 +651,20 @@ export function dispatchEventForResponderEventSystem( // We might want to control timeStamp another way here currentTimeStamp = (nativeEvent: any).timeStamp; try { - traverseAndHandleEventResponderInstances( - topLevelType, - targetFiber, - nativeEvent, - nativeEventTarget, - eventSystemFlags, - ); - processEventQueue(); + batchedEventUpdates(() => { + traverseAndHandleEventResponderInstances( + topLevelType, + targetFiber, + nativeEvent, + nativeEventTarget, + eventSystemFlags, + ); + }); } finally { currentTimers = previousTimers; currentInstance = previousInstance; - currentEventQueue = previousEventQueue; currentTimeStamp = previousTimeStamp; currentDocument = previousDocument; - currentEventQueuePriority = previousEventQueuePriority; } } } 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 14c52faefd48f..80f3a0a492b5c 100644 --- a/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js @@ -26,7 +26,6 @@ function createEventResponder({ targetEventTypes, onMount, onUnmount, - onOwnershipChange, getInitialState, }) { return React.unstable_createResponder('TestEventResponder', { @@ -36,7 +35,6 @@ function createEventResponder({ onRootEvent, onMount, onUnmount, - onOwnershipChange, getInitialState, }); } @@ -644,37 +642,6 @@ describe('DOMEventResponderSystem', () => { expect(counter).toEqual(5); }); - it('the event responder onOwnershipChange() function should fire', () => { - let onOwnershipChangeFired = 0; - let ownershipGained = false; - const buttonRef = React.createRef(); - - const TestResponder = createEventResponder({ - targetEventTypes: ['click'], - onEvent: (event, context, props, state) => { - ownershipGained = context.requestGlobalOwnership(); - }, - onOwnershipChange: () => { - onOwnershipChangeFired++; - }, - }); - - const Test = () => { - const listener = React.unstable_useResponder(TestResponder, {}); - return -

- ); - } - - const newContainer = document.createElement('div'); - const root = ReactDOM.unstable_createRoot(newContainer); - document.body.appendChild(newContainer); - root.render(); - Scheduler.unstable_flushAll(); - - dispatchEventWithTimeStamp(ref.current, 'pointerdown', 100); - dispatchEventWithTimeStamp(ref.current, 'pointerup', 100); - dispatchEventWithTimeStamp(ref.current, 'click', 100); - - if (__DEV__) { - expect(renderCounts).toBe(2); - } else { - expect(renderCounts).toBe(1); - } - Scheduler.unstable_flushAll(); - if (__DEV__) { - expect(renderCounts).toBe(4); - } else { - expect(renderCounts).toBe(2); - } - - dispatchEventWithTimeStamp(ref.current, 'pointerdown', 100); - dispatchEventWithTimeStamp(ref.current, 'pointerup', 100); - // Ensure the timeStamp logic works - dispatchEventWithTimeStamp(ref.current, 'click', 101); - - if (__DEV__) { - expect(renderCounts).toBe(6); - } else { - expect(renderCounts).toBe(3); - } + if (hasPointerEvents) { + // TODO(T46067442): move these tests to core + /* + it('should properly only flush sync once when the event systems are mixed', () => { + const ref = React.createRef(); + let renderCounts = 0; - Scheduler.unstable_flushAll(); - document.body.removeChild(newContainer); - }); + function MyComponent() { + const [, updateCounter] = React.useState(0); + renderCounts++; - it('should properly flush sync when the event systems are mixed with unstable_flushDiscreteUpdates', () => { - const ref = React.createRef(); - let renderCounts = 0; + function handlePress() { + updateCounter(count => count + 1); + } - function MyComponent() { - const [, updateCounter] = React.useState(0); - renderCounts++; + const listener = usePressResponder({ + onPress: handlePress, + }); - function handlePress() { - updateCounter(count => count + 1); + return ( +
+ +
+ ); } - const listener = usePressResponder({ - onPress: handlePress, - }); - - return ( -
- -
- ); - } - - const newContainer = document.createElement('div'); - const root = ReactDOM.unstable_createRoot(newContainer); - document.body.appendChild(newContainer); - root.render(); - Scheduler.unstable_flushAll(); - - dispatchEventWithTimeStamp(ref.current, 'pointerdown', 100); - dispatchEventWithTimeStamp(ref.current, 'pointerup', 100); - dispatchEventWithTimeStamp(ref.current, 'click', 100); - - if (__DEV__) { - expect(renderCounts).toBe(4); - } else { - expect(renderCounts).toBe(2); - } - Scheduler.unstable_flushAll(); - if (__DEV__) { - expect(renderCounts).toBe(6); - } else { - expect(renderCounts).toBe(3); - } + const newContainer = document.createElement('div'); + const root = ReactDOM.unstable_createRoot(newContainer); + document.body.appendChild(newContainer); + root.render(); + Scheduler.unstable_flushAll(); - dispatchEventWithTimeStamp(ref.current, 'pointerdown', 100); - dispatchEventWithTimeStamp(ref.current, 'pointerup', 100); - // Ensure the timeStamp logic works - dispatchEventWithTimeStamp(ref.current, 'click', 101); + const target = ref.current; + target.dispatchEvent(pointerdown({timeStamp: 100})); + target.dispatchEvent(pointerup({timeStamp: 100})); + target.dispatchEvent(click({timeStamp: 100})); - if (__DEV__) { - expect(renderCounts).toBe(8); - } else { - expect(renderCounts).toBe(4); - } + if (__DEV__) { + expect(renderCounts).toBe(2); + } else { + expect(renderCounts).toBe(1); + } + Scheduler.unstable_flushAll(); + if (__DEV__) { + expect(renderCounts).toBe(4); + } else { + expect(renderCounts).toBe(2); + } - Scheduler.unstable_flushAll(); - document.body.removeChild(newContainer); - }); + target.dispatchEvent(pointerdown({timeStamp: 100})); + target.dispatchEvent(pointerup({timeStamp: 100})); + // Ensure the timeStamp logic works + target.dispatchEvent(click({timeStamp: 101})); - it( - 'should only flush before outermost discrete event handler when mixing ' + - 'event systems', - async () => { - const {useState} = React; + if (__DEV__) { + expect(renderCounts).toBe(6); + } else { + expect(renderCounts).toBe(3); + } - const button = React.createRef(); + Scheduler.unstable_flushAll(); + document.body.removeChild(newContainer); + }); - const ops = []; + it('should properly flush sync when the event systems are mixed with unstable_flushDiscreteUpdates', () => { + const ref = React.createRef(); + let renderCounts = 0; function MyComponent() { - const [pressesCount, updatePressesCount] = useState(0); - const [clicksCount, updateClicksCount] = useState(0); + const [, updateCounter] = React.useState(0); + renderCounts++; function handlePress() { - // This dispatches a synchronous, discrete event in the legacy event - // system. However, because it's nested inside the new event system, - // its updates should not flush until the end of the outer handler. - button.current.click(); - // Text context should not have changed - ops.push(newContainer.textContent); - updatePressesCount(pressesCount + 1); + updateCounter(count => count + 1); } const listener = usePressResponder({ @@ -2427,76 +1314,131 @@ describe('Event responder: Press', () => { return (
); } const newContainer = document.createElement('div'); - document.body.appendChild(newContainer); const root = ReactDOM.unstable_createRoot(newContainer); - + document.body.appendChild(newContainer); root.render(); Scheduler.unstable_flushAll(); - expect(newContainer.textContent).toEqual('Presses: 0, Clicks: 0'); - dispatchEventWithTimeStamp(button.current, 'pointerdown', 100); - dispatchEventWithTimeStamp(button.current, 'pointerup', 100); - dispatchEventWithTimeStamp(button.current, 'click', 100); + const target = ref.current; + target.dispatchEvent(pointerdown({timeStamp: 100})); + target.dispatchEvent(pointerup({timeStamp: 100})); + target.dispatchEvent(click({timeStamp: 100})); + + if (__DEV__) { + expect(renderCounts).toBe(4); + } else { + expect(renderCounts).toBe(2); + } Scheduler.unstable_flushAll(); - expect(newContainer.textContent).toEqual('Presses: 1, Clicks: 1'); + if (__DEV__) { + expect(renderCounts).toBe(6); + } else { + expect(renderCounts).toBe(3); + } - expect(ops).toEqual(['Presses: 0, Clicks: 0']); - }, - ); + target.dispatchEvent(pointerdown({timeStamp: 100})); + target.dispatchEvent(pointerup({timeStamp: 100})); + // Ensure the timeStamp logic works + target.dispatchEvent(click({timeStamp: 101})); - it('should work correctly with stopPropagation set to true', () => { - const ref = React.createRef(); - const pointerDownEvent = jest.fn(); + if (__DEV__) { + expect(renderCounts).toBe(8); + } else { + expect(renderCounts).toBe(4); + } - const Component = () => { - const listener = usePressResponder({stopPropagation: true}); + Scheduler.unstable_flushAll(); + document.body.removeChild(newContainer); + }); - return
; - }; + it( + 'should only flush before outermost discrete event handler when mixing ' + + 'event systems', + async () => { + const {useState} = React; - container.addEventListener('pointerdown', pointerDownEvent); - ReactDOM.render(, container); + const button = React.createRef(); - ref.current.dispatchEvent( - createEvent('pointerdown', {pointerType: 'mouse', button: 0}), - ); - container.removeEventListener('pointerdown', pointerDownEvent); - expect(pointerDownEvent).toHaveBeenCalledTimes(0); - }); + const ops = []; - it('has the correct press target when used with event hook', () => { - const ref = React.createRef(); - const onPress = jest.fn(); - const Component = () => { - const listener = usePressResponder({onPress}); + function MyComponent() { + const [pressesCount, updatePressesCount] = useState(0); + const [clicksCount, updateClicksCount] = useState(0); - return ( -
- -
- ); - }; - ReactDOM.render(, container); + function handlePress() { + // This dispatches a synchronous, discrete event in the legacy event + // system. However, because it's nested inside the new event system, + // its updates should not flush until the end of the outer handler. + button.current.click(); + // Text context should not have changed + ops.push(newContainer.textContent); + updatePressesCount(pressesCount + 1); + } - ref.current.dispatchEvent( - createEvent('pointerdown', {pointerType: 'mouse', button: 0}), - ); - ref.current.dispatchEvent( - createEvent('pointerup', {pointerType: 'mouse', button: 0}), - ); - expect(onPress).toHaveBeenCalledTimes(1); - expect(onPress).toHaveBeenCalledWith( - expect.objectContaining({target: ref.current}), + const listener = usePressResponder({ + onPress: handlePress, + }); + + return ( +
+ +
+ ); + } + + const newContainer = document.createElement('div'); + document.body.appendChild(newContainer); + const root = ReactDOM.unstable_createRoot(newContainer); + + root.render(); + Scheduler.unstable_flushAll(); + expect(newContainer.textContent).toEqual('Presses: 0, Clicks: 0'); + + const target = button.current; + target.dispatchEvent(pointerdown({timeStamp: 100})); + target.dispatchEvent(pointerup({timeStamp: 100})); + target.dispatchEvent(click({timeStamp: 100})); + + Scheduler.unstable_flushAll(); + expect(newContainer.textContent).toEqual('Presses: 1, Clicks: 1'); + + expect(ops).toEqual(['Presses: 0, Clicks: 0']); + }, ); - }); + */ + it('should work correctly with stopPropagation set to true', () => { + const ref = React.createRef(); + const pointerDownEvent = jest.fn(); + + const Component = () => { + const listener = usePressResponder({stopPropagation: true}); + return
; + }; + + container.addEventListener('pointerdown', pointerDownEvent); + ReactDOM.render(, container); + createEventTarget(ref.current).pointerdown(); + container.removeEventListener('pointerdown', pointerDownEvent); + expect(pointerDownEvent).toHaveBeenCalledTimes(0); + }); + } }); diff --git a/packages/react-events/src/dom/__tests__/Scroll-test.internal.js b/packages/react-events/src/dom/__tests__/Scroll-test.internal.js index aa55e788ee84f..3c61944e7c5ee 100644 --- a/packages/react-events/src/dom/__tests__/Scroll-test.internal.js +++ b/packages/react-events/src/dom/__tests__/Scroll-test.internal.js @@ -9,33 +9,31 @@ 'use strict'; +import {createEventTarget, setPointerEvent} from '../testing-library'; + let React; let ReactFeatureFlags; let ReactDOM; let useScrollResponder; -const createEvent = (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; +const forcePointerEvents = true; +const table = [[forcePointerEvents], [!forcePointerEvents]]; + +const initializeModules = hasPointerEvents => { + setPointerEvent(hasPointerEvents); + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableFlareAPI = true; + React = require('react'); + ReactDOM = require('react-dom'); + useScrollResponder = require('react-events/scroll').useScrollResponder; }; -describe('Scroll event responder', () => { +describe.each(table)('Scroll responder', hasPointerEvents => { let container; beforeEach(() => { - jest.resetModules(); - ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.enableFlareAPI = true; - React = require('react'); - ReactDOM = require('react-dom'); - useScrollResponder = require('react-events/scroll').useScrollResponder; - + initializeModules(hasPointerEvents); container = document.createElement('div'); document.body.appendChild(container); }); @@ -63,7 +61,8 @@ describe('Scroll event responder', () => { }); it('prevents custom events being dispatched', () => { - ref.current.dispatchEvent(createEvent('scroll')); + const target = createEventTarget(ref.current); + target.scroll(); expect(onScroll).not.toBeCalled(); }); }); @@ -84,129 +83,49 @@ describe('Scroll event responder', () => { }); describe('is called after "scroll" event', () => { - it('with a mouse pointerType', () => { - ref.current.dispatchEvent( - createEvent('pointerdown', { - pointerType: 'mouse', - }), - ); - ref.current.dispatchEvent(createEvent('scroll')); + const pointerTypesTable = hasPointerEvents + ? [['mouse'], ['touch'], ['pen']] + : [['mouse'], ['touch']]; + it.each(pointerTypesTable)('with pointerType: %s', pointerType => { + const node = ref.current; + const target = createEventTarget(node); + target.pointerdown({pointerType}); + target.scroll(); expect(onScroll).toHaveBeenCalledTimes(1); expect(onScroll).toHaveBeenCalledWith( expect.objectContaining({ - pointerType: 'mouse', + pointerType, type: 'scroll', direction: '', }), ); onScroll.mockReset(); - ref.current.scrollTop = -1; - ref.current.dispatchEvent(createEvent('scroll')); + node.scrollTop = -1; + target.scroll(); expect(onScroll).toHaveBeenCalledWith( expect.objectContaining({ - pointerType: 'mouse', + pointerType, type: 'scroll', direction: 'up', }), ); onScroll.mockReset(); - ref.current.scrollTop = 1; - ref.current.dispatchEvent(createEvent('scroll')); + node.scrollTop = 1; + target.scroll(); expect(onScroll).toHaveBeenCalledWith( expect.objectContaining({ - pointerType: 'mouse', + pointerType, type: 'scroll', direction: 'down', }), ); }); - it('with a touch pointerType', () => { - ref.current.dispatchEvent( - createEvent('pointerdown', { - pointerType: 'touch', - }), - ); - ref.current.dispatchEvent(createEvent('scroll')); - expect(onScroll).toHaveBeenCalledTimes(1); - expect(onScroll).toHaveBeenCalledWith( - expect.objectContaining({ - pointerType: 'touch', - type: 'scroll', - direction: '', - }), - ); - onScroll.mockReset(); - ref.current.scrollTop = -1; - ref.current.dispatchEvent(createEvent('scroll')); - expect(onScroll).toHaveBeenCalledWith( - expect.objectContaining({ - pointerType: 'touch', - type: 'scroll', - direction: 'up', - }), - ); - onScroll.mockReset(); - ref.current.scrollTop = 1; - ref.current.dispatchEvent(createEvent('scroll')); - expect(onScroll).toHaveBeenCalledWith( - expect.objectContaining({ - pointerType: 'touch', - type: 'scroll', - direction: 'down', - }), - ); - }); - - it('with a pen pointerType', () => { - ref.current.dispatchEvent( - createEvent('pointerdown', { - pointerType: 'pen', - }), - ); - ref.current.dispatchEvent(createEvent('scroll')); - expect(onScroll).toHaveBeenCalledTimes(1); - expect(onScroll).toHaveBeenCalledWith( - expect.objectContaining({ - pointerType: 'pen', - type: 'scroll', - direction: '', - }), - ); - onScroll.mockReset(); - ref.current.scrollTop = -1; - ref.current.dispatchEvent(createEvent('scroll')); - expect(onScroll).toHaveBeenCalledWith( - expect.objectContaining({ - pointerType: 'pen', - type: 'scroll', - direction: 'up', - }), - ); - onScroll.mockReset(); - ref.current.scrollTop = 1; - ref.current.dispatchEvent(createEvent('scroll')); - expect(onScroll).toHaveBeenCalledWith( - expect.objectContaining({ - pointerType: 'pen', - type: 'scroll', - direction: 'down', - }), - ); - }); - - it('with a keyboard pointerType', () => { - ref.current.dispatchEvent( - createEvent('keydown', { - key: 'A', - }), - ); - ref.current.dispatchEvent( - createEvent('keyup', { - key: 'A', - }), - ); - ref.current.dispatchEvent(createEvent('scroll')); + it('with pointerType: keyboard', () => { + const target = createEventTarget(ref.current); + target.keydown({key: 'A'}); + target.keyup({key: 'A'}); + target.scroll(); expect(onScroll).toHaveBeenCalledTimes(1); expect(onScroll).toHaveBeenCalledWith( expect.objectContaining({ @@ -235,13 +154,9 @@ describe('Scroll event responder', () => { }); it('works as expected with touch events', () => { - ref.current.dispatchEvent( - createEvent('pointerdown', { - pointerType: 'touch', - }), - ); - ref.current.dispatchEvent(createEvent('touchstart')); - ref.current.dispatchEvent(createEvent('scroll')); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'touch'}); + target.scroll(); expect(onScrollDragStart).toHaveBeenCalledTimes(1); expect(onScrollDragStart).toHaveBeenCalledWith( expect.objectContaining({ @@ -269,14 +184,10 @@ describe('Scroll event responder', () => { }); it('works as expected with touch events', () => { - ref.current.dispatchEvent( - createEvent('pointerdown', { - pointerType: 'touch', - }), - ); - ref.current.dispatchEvent(createEvent('touchstart')); - ref.current.dispatchEvent(createEvent('scroll')); - ref.current.dispatchEvent(createEvent('touchend')); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'touch'}); + target.scroll(); + target.pointerup({pointerType: 'touch'}); expect(onScrollDragEnd).toHaveBeenCalledTimes(1); expect(onScrollDragEnd).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/react-events/src/dom/test-utils.js b/packages/react-events/src/dom/test-utils.js deleted file mode 100644 index 826ac219ce58f..0000000000000 --- a/packages/react-events/src/dom/test-utils.js +++ /dev/null @@ -1,400 +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. - * - * @emails react-core - */ - -'use strict'; - -/* eslint-disable no-unused-vars */ - -/** - * Change environment support for PointerEvent. - */ - -function hasPointerEvent(bool) { - return global != null && global.PointerEvent != null; -} - -function setPointerEvent(bool) { - global.PointerEvent = bool ? function() {} : undefined; -} - -/** - * Change environment host platform. - */ - -const platformGetter = jest.spyOn(global.navigator, 'platform', 'get'); - -const platform = { - clear() { - platformGetter.mockClear(); - }, - get() { - return global.navigator.platform === 'MacIntel' ? 'mac' : 'windows'; - }, - set(name: 'mac' | 'windows') { - switch (name) { - case 'mac': { - platformGetter.mockReturnValue('MacIntel'); - break; - } - case 'windows': { - platformGetter.mockReturnValue('Win32'); - break; - } - default: { - break; - } - } - }, -}; - -/** - * Mock native events - */ - -function createEvent(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; -} - -function createTouchEvent(type, data, id) { - return createEvent(type, { - changedTouches: [ - { - ...data, - identifier: id, - }, - ], - }); -} - -const createKeyboardEvent = (type, data) => { - return new KeyboardEvent(type, { - bubbles: true, - cancelable: true, - ...data, - }); -}; - -function blur(data) { - return createEvent('blur', data); -} - -function click(data) { - return createEvent('click', data); -} - -function contextmenu(data) { - return createEvent('contextmenu', data); -} - -function dragstart(data) { - return createEvent('dragstart', data); -} - -function focus(data) { - return createEvent('focus', data); -} - -function gotpointercapture(data) { - return createEvent('gotpointercapture', data); -} - -function keydown(data) { - return createKeyboardEvent('keydown', data); -} - -function keyup(data) { - return createKeyboardEvent('keyup', data); -} - -function lostpointercapture(data) { - return createEvent('lostpointercapture', data); -} - -function pointercancel(data) { - return createEvent('pointercancel', data); -} - -function pointerdown(data) { - return createEvent('pointerdown', data); -} - -function pointerenter(data) { - return createEvent('pointerenter', data); -} - -function pointerleave(data) { - return createEvent('pointerleave', data); -} - -function pointermove(data) { - return createEvent('pointermove', data); -} - -function pointerout(data) { - return createEvent('pointerout', data); -} - -function pointerover(data) { - return createEvent('pointerover', data); -} - -function pointerup(data) { - return createEvent('pointerup', data); -} - -function mousedown(data) { - return createEvent('mousedown', data); -} - -function mouseenter(data) { - return createEvent('mouseenter', data); -} - -function mouseleave(data) { - return createEvent('mouseleave', data); -} - -function mousemove(data) { - return createEvent('mousemove', data); -} - -function mouseout(data) { - return createEvent('mouseout', data); -} - -function mouseover(data) { - return createEvent('mouseover', data); -} - -function mouseup(data) { - return createEvent('mouseup', data); -} - -function touchcancel(data, id) { - return createTouchEvent('touchcancel', data, id); -} - -function touchend(data, id) { - return createTouchEvent('touchend', data, id); -} - -function touchmove(data, id) { - return createTouchEvent('touchmove', data, id); -} - -function touchstart(data, id) { - return createTouchEvent('touchstart', data, id); -} - -/** - * Dispatch high-level event sequences - */ - -function emptyFunction() {} - -function dispatchLongPressContextMenu( - target, - {preventDefault = emptyFunction} = {}, -) { - const dispatch = arg => target.dispatchEvent(arg); - const button = 0; - if (hasPointerEvent()) { - dispatch(pointerdown({button, pointerType: 'touch'})); - } - dispatch(touchstart()); - dispatch(contextmenu({button, preventDefault})); -} - -function dispatchRightClickContextMenu( - target, - {preventDefault = emptyFunction} = {}, -) { - const dispatch = arg => target.dispatchEvent(arg); - const button = 2; - if (hasPointerEvent()) { - dispatch(pointerdown({button, pointerType: 'mouse'})); - } - dispatch(mousedown({button})); - dispatch(contextmenu({button, preventDefault})); -} - -function dispatchModifiedClickContextMenu( - target, - {preventDefault = emptyFunction} = {}, -) { - const dispatch = arg => target.dispatchEvent(arg); - const button = 0; - const ctrlKey = true; - if (hasPointerEvent()) { - dispatch(pointerdown({button, ctrlKey, pointerType: 'mouse'})); - } - dispatch(mousedown({button, ctrlKey})); - if (platform.get() === 'mac') { - dispatch(contextmenu({button, ctrlKey, preventDefault})); - } -} - -function dispatchPointerHoverEnter(target, {relatedTarget, x, y} = {}) { - const dispatch = arg => target.dispatchEvent(arg); - const button = -1; - const pointerType = 'mouse'; - const event = { - button, - relatedTarget, - clientX: x, - clientY: y, - pageX: x, - pageY: y, - }; - if (hasPointerEvent()) { - dispatch(pointerover({pointerType, ...event})); - dispatch(pointerenter({pointerType, ...event})); - } - dispatch(mouseover(event)); - dispatch(mouseover(event)); -} - -function dispatchPointerHoverMove(target, {from, to} = {}) { - const dispatch = arg => target.dispatchEvent(arg); - const button = -1; - const pointerId = 1; - const pointerType = 'mouse'; - function dispatchMove({x, y}) { - const event = { - button, - clientX: x, - clientY: y, - pageX: x, - pageY: y, - }; - if (hasPointerEvent()) { - dispatch(pointermove({pointerId, pointerType, ...event})); - } - dispatch(mousemove(event)); - } - dispatchMove({x: from.x, y: from.y}); - dispatchMove({x: to.x, y: to.y}); -} - -function dispatchPointerHoverExit(target, {relatedTarget, x, y} = {}) { - const dispatch = arg => target.dispatchEvent(arg); - const button = -1; - const pointerType = 'mouse'; - const event = { - button, - relatedTarget, - clientX: x, - clientY: y, - pageX: x, - pageY: y, - }; - if (hasPointerEvent()) { - dispatch(pointerout({pointerType, ...event})); - dispatch(pointerleave({pointerType, ...event})); - } - dispatch(mouseout(event)); - dispatch(mouseleave(event)); -} - -function dispatchPointerCancel(target, options) { - const dispatchEvent = arg => target.dispatchEvent(arg); - dispatchEvent(pointercancel({pointerType: 'mouse'})); - dispatchEvent(dragstart({pointerType: 'mouse'})); -} - -function dispatchPointerPressDown( - target, - {button = 0, pointerType = 'mouse'} = {}, -) { - const dispatch = arg => target.dispatchEvent(arg); - const pointerId = 1; - if (pointerType !== 'mouse') { - if (hasPointerEvent()) { - dispatch(pointerover({button, pointerId, pointerType})); - dispatch(pointerenter({button, pointerId, pointerType})); - dispatch(pointerdown({button, pointerId, pointerType})); - } - dispatch(touchstart(null, pointerId)); - if (hasPointerEvent()) { - dispatch(gotpointercapture({button, pointerId, pointerType})); - } - } else { - if (hasPointerEvent()) { - dispatch(pointerdown({button, pointerId, pointerType})); - } - dispatch(mousedown({button})); - if (document.activeElement !== target) { - dispatch(focus({button})); - } - } -} - -function dispatchPointerPressRelease( - target, - {button = 0, pointerType = 'mouse'} = {}, -) { - const dispatch = arg => target.dispatchEvent(arg); - const pointerId = 1; - if (pointerType !== 'mouse') { - if (hasPointerEvent()) { - dispatch(pointerup({button, pointerId, pointerType})); - dispatch(lostpointercapture({button, pointerId, pointerType})); - dispatch(pointerout({button, pointerId, pointerType})); - dispatch(pointerleave({button, pointerId, pointerType})); - } - dispatch(touchend(null, pointerId)); - dispatch(mouseover({button})); - dispatch(mousemove({button})); - dispatch(mousedown({button})); - if (document.activeElement !== target) { - dispatch(focus({button})); - } - dispatch(mouseup({button})); - dispatch(click({button})); - } else { - if (hasPointerEvent()) { - dispatch(pointerup({button, pointerId, pointerType})); - } - dispatch(mouseup({button})); - dispatch(click({button})); - } -} - -function dispatchTouchTap(target) { - dispatchPointerPressDown(target, {pointerType: 'touch'}); - dispatchPointerPressRelease(target, {pointerType: 'touch'}); -} - -module.exports = { - blur, - focus, - createEvent, - dispatchLongPressContextMenu, - dispatchRightClickContextMenu, - dispatchModifiedClickContextMenu, - dispatchPointerCancel, - dispatchPointerHoverEnter, - dispatchPointerHoverExit, - dispatchPointerHoverMove, - dispatchPointerPressDown, - dispatchPointerPressRelease, - dispatchTouchTap, - keydown, - keyup, - platform, - hasPointerEvent, - setPointerEvent, -}; diff --git a/packages/react-events/src/dom/testing-library/domEnvironment.js b/packages/react-events/src/dom/testing-library/domEnvironment.js new file mode 100644 index 0000000000000..035673ae81ace --- /dev/null +++ b/packages/react-events/src/dom/testing-library/domEnvironment.js @@ -0,0 +1,51 @@ +/** + * 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'; +/** + * Change environment support for PointerEvent. + */ + +export function hasPointerEvent() { + return global != null && global.PointerEvent != null; +} + +export function setPointerEvent(bool) { + global.PointerEvent = bool ? function() {} : undefined; +} + +/** + * Change environment host platform. + */ + +const platformGetter = jest.spyOn(global.navigator, 'platform', 'get'); + +export const platform = { + clear() { + platformGetter.mockClear(); + }, + get() { + return global.navigator.platform === 'MacIntel' ? 'mac' : 'windows'; + }, + set(name: 'mac' | 'windows') { + switch (name) { + case 'mac': { + platformGetter.mockReturnValue('MacIntel'); + break; + } + case 'windows': { + platformGetter.mockReturnValue('Win32'); + break; + } + default: { + break; + } + } + }, +}; diff --git a/packages/react-events/src/dom/testing-library/domEventSequences.js b/packages/react-events/src/dom/testing-library/domEventSequences.js new file mode 100644 index 0000000000000..d84feacc5ebf3 --- /dev/null +++ b/packages/react-events/src/dom/testing-library/domEventSequences.js @@ -0,0 +1,175 @@ +/** + * 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'; + +import * as domEvents from './domEvents'; +import {hasPointerEvent, platform} from './domEnvironment'; + +function emptyFunction() {} + +function getPointerType(payload) { + let pointerType = 'mouse'; + if (payload != null && payload.pointerType != null) { + pointerType = payload.pointerType; + } + return pointerType; +} + +export function contextmenu( + target, + {preventDefault = emptyFunction} = {}, + {pointerType = 'mouse', modified} = {}, +) { + const dispatch = arg => target.dispatchEvent(arg); + if (pointerType === 'touch') { + const button = 0; + if (hasPointerEvent()) { + dispatch(domEvents.pointerdown({button, pointerType})); + } + dispatch(domEvents.touchstart()); + dispatch(domEvents.contextmenu({button, preventDefault})); + } else if (pointerType === 'mouse') { + if (modified === true) { + const button = 0; + const ctrlKey = true; + if (hasPointerEvent()) { + dispatch(domEvents.pointerdown({button, ctrlKey, pointerType})); + } + dispatch(domEvents.mousedown({button, ctrlKey})); + if (platform.get() === 'mac') { + dispatch(domEvents.contextmenu({button, ctrlKey, preventDefault})); + } + } else { + const button = 2; + if (hasPointerEvent()) { + dispatch(domEvents.pointerdown({button, pointerType})); + } + dispatch(domEvents.mousedown({button})); + dispatch(domEvents.contextmenu({button, preventDefault})); + } + } +} + +export function pointercancel(target, payload) { + const dispatchEvent = arg => target.dispatchEvent(arg); + const pointerType = getPointerType(payload); + if (hasPointerEvent()) { + dispatchEvent(domEvents.pointercancel(payload)); + } else { + if (pointerType === 'mouse') { + dispatchEvent(domEvents.dragstart(payload)); + } else { + dispatchEvent(domEvents.touchcancel(payload)); + } + } +} + +export function pointerdown(target, defaultPayload) { + const dispatch = arg => target.dispatchEvent(arg); + const pointerType = getPointerType(defaultPayload); + const payload = {button: 0, buttons: 2, ...defaultPayload}; + + if (pointerType === 'mouse') { + if (hasPointerEvent()) { + dispatch(domEvents.pointerover(payload)); + dispatch(domEvents.pointerenter(payload)); + } + dispatch(domEvents.mouseover(payload)); + dispatch(domEvents.mouseenter(payload)); + if (hasPointerEvent()) { + dispatch(domEvents.pointerdown(payload)); + } + dispatch(domEvents.mousedown(payload)); + if (document.activeElement !== target) { + dispatch(domEvents.focus()); + } + } else { + if (hasPointerEvent()) { + dispatch(domEvents.pointerover(payload)); + dispatch(domEvents.pointerenter(payload)); + dispatch(domEvents.pointerdown(payload)); + } + dispatch(domEvents.touchstart(payload)); + if (hasPointerEvent()) { + dispatch(domEvents.gotpointercapture(payload)); + } + } +} + +export function pointerenter(target, payload) { + const dispatch = arg => target.dispatchEvent(arg); + if (hasPointerEvent()) { + dispatch(domEvents.pointerover(payload)); + dispatch(domEvents.pointerenter(payload)); + } + dispatch(domEvents.mouseover(payload)); + dispatch(domEvents.mouseenter(payload)); +} + +export function pointerexit(target, payload) { + const dispatch = arg => target.dispatchEvent(arg); + if (hasPointerEvent()) { + dispatch(domEvents.pointerout(payload)); + dispatch(domEvents.pointerleave(payload)); + } + dispatch(domEvents.mouseout(payload)); + dispatch(domEvents.mouseleave(payload)); +} + +export function pointerhover(target, payload) { + const dispatch = arg => target.dispatchEvent(arg); + if (hasPointerEvent()) { + dispatch(domEvents.pointermove(payload)); + } + dispatch(domEvents.mousemove(payload)); +} + +export function pointermove(target, payload) { + const dispatch = arg => target.dispatchEvent(arg); + const pointerType = getPointerType(payload); + if (hasPointerEvent()) { + dispatch(domEvents.pointermove(payload)); + } + if (pointerType === 'mouse') { + dispatch(domEvents.mousemove(payload)); + } else { + dispatch(domEvents.touchmove(payload)); + } +} + +export function pointerup(target, defaultPayload) { + const dispatch = arg => target.dispatchEvent(arg); + const pointerType = getPointerType(defaultPayload); + const payload = {button: 0, buttons: 2, ...defaultPayload}; + + if (pointerType === 'mouse') { + if (hasPointerEvent()) { + dispatch(domEvents.pointerup(payload)); + } + dispatch(domEvents.mouseup(payload)); + dispatch(domEvents.click(payload)); + } else { + if (hasPointerEvent()) { + dispatch(domEvents.pointerup(payload)); + dispatch(domEvents.lostpointercapture(payload)); + dispatch(domEvents.pointerout(payload)); + dispatch(domEvents.pointerleave(payload)); + } + dispatch(domEvents.touchend(payload)); + dispatch(domEvents.mouseover(payload)); + dispatch(domEvents.mousemove(payload)); + dispatch(domEvents.mousedown(payload)); + if (document.activeElement !== target) { + dispatch(domEvents.focus()); + } + dispatch(domEvents.mouseup(payload)); + dispatch(domEvents.click(payload)); + } +} diff --git a/packages/react-events/src/dom/testing-library/domEvents.js b/packages/react-events/src/dom/testing-library/domEvents.js new file mode 100644 index 0000000000000..01ed26a568908 --- /dev/null +++ b/packages/react-events/src/dom/testing-library/domEvents.js @@ -0,0 +1,370 @@ +/** + * 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'; + +/** + * Native event object mocks for higher-level events. + * + * 1. Each event type defines the exact object that it accepts. This ensures + * that no arbitrary properties can be assigned to events, and the properties + * that don't exist on specific event types (e.g., 'pointerType') are not added + * to the respective native event. + * + * 2. Properties that cannot be relied on due to inconsistent browser support (e.g., 'x' and 'y') are not + * added to the native event. Others that shouldn't be arbitrarily customized (e.g., 'screenX') + * are automatically inferred from associated values. + * + * 3. PointerEvent and TouchEvent fields are normalized (e.g., 'rotationAngle' -> 'twist') + */ + +function emptyFunction() {} + +function createEvent(type, data = {}) { + const event = document.createEvent('CustomEvent'); + event.initCustomEvent(type, true, true); + if (data != null) { + Object.keys(data).forEach(key => { + const value = data[key]; + Object.defineProperty(event, key, {value}); + }); + } + return event; +} + +function createGetModifierState(keyArg, data) { + if (keyArg === 'Alt') { + return data.altKey || false; + } + if (keyArg === 'Control') { + return data.ctrlKey || false; + } + if (keyArg === 'Meta') { + return data.metaKey || false; + } + if (keyArg === 'Shift') { + return data.shiftKey || false; + } +} + +function createPointerEvent( + type, + { + altKey = false, + button = -1, + buttons = 0, + ctrlKey = false, + height, + metaKey = false, + movementX = 0, + movementY = 0, + offsetX = 0, + offsetY = 0, + pageX, + pageY, + pointerId = 1, + pressure = 0, + preventDefault = emptyFunction, + pointerType = 'mouse', + shiftKey = false, + tangentialPressure = 0, + tiltX = 0, + tiltY = 0, + twist = 0, + width, + x = 0, + y = 0, + } = {}, +) { + const modifierState = {altKey, ctrlKey, metaKey, shiftKey}; + + return createEvent(type, { + altKey, + button, + buttons, + clientX: x, + clientY: y, + ctrlKey, + getModifierState(keyArg) { + createGetModifierState(keyArg, modifierState); + }, + height: pointerType === 'mouse' ? 1 : height != null ? height : 11.5, + metaKey, + movementX, + movementY, + offsetX, + offsetY, + pageX: pageX || x, + pageY: pageY || y, + pointerId, + pointerType, + pressure, + preventDefault, + screenX: x, + screenY: y + 50, // arbitrary value to emulate browser chrome, etc + shiftKey, + tangentialPressure, + tiltX, + tiltY, + twist, + width: pointerType === 'mouse' ? 1 : width != null ? width : 11.5, + }); +} + +function createKeyboardEvent( + type, + { + altKey = false, + ctrlKey = false, + key = '', + metaKey = false, + preventDefault = emptyFunction, + shiftKey = false, + } = {}, +) { + const modifierState = {altKey, ctrlKey, metaKey, shiftKey}; + + return createEvent(type, { + altKey, + ctrlKey, + getModifierState(keyArg) { + createGetModifierState(keyArg, modifierState); + }, + key, + metaKey, + preventDefault, + shiftKey, + }); +} + +function createMouseEvent( + type, + { + altKey = false, + button = -1, + buttons = 0, + ctrlKey = false, + metaKey = false, + movementX = 0, + movementY = 0, + offsetX = 0, + offsetY = 0, + pageX, + pageY, + preventDefault = emptyFunction, + shiftKey = false, + x = 0, + y = 0, + } = {}, +) { + const modifierState = {altKey, ctrlKey, metaKey, shiftKey}; + + return createEvent(type, { + altKey, + button, + buttons, + clientX: x, + clientY: y, + ctrlKey, + getModifierState(keyArg) { + createGetModifierState(keyArg, modifierState); + }, + metaKey, + movementX, + movementY, + offsetX, + offsetY, + pageX: pageX || x, + pageY: pageY || y, + preventDefault, + screenX: x, + screenY: y + 50, // arbitrary value to emulate browser chrome, etc + shiftKey, + }); +} + +function createTouchEvent( + type, + { + altKey = false, + ctrlKey = false, + height = 11.5, + metaKey = false, + pageX, + pageY, + pointerId = 1, + preventDefault = emptyFunction, + shiftKey = false, + twist = 0, + width = 11.5, + x = 0, + y = 0, + } = {}, +) { + const touch = { + clientX: x, + clientY: y, + force: 1, + identifier: pointerId, + pageX: pageX || x, + pageY: pageY || y, + radiusX: width, + radiusY: height, + rotationAngle: twist, + screenX: x, + screenY: y + 50, // arbitrary value to emulate browser chrome, etc + }; + + return createEvent(type, { + altKey, + changedTouches: [touch], + ctrlKey, + metaKey, + preventDefault, + shiftKey, + targetTouches: type !== 'touchend' ? [touch] : null, + touches: [touch], + }); +} + +/** + * Mock event objects + */ + +export function blur({relatedTarget} = {}) { + return createEvent('blur', {relatedTarget}); +} + +export function click(payload) { + return createMouseEvent('click', payload); +} + +export function contextmenu(payload) { + return createMouseEvent('contextmenu', payload); +} + +export function dragstart(payload) { + return createMouseEvent('dragstart', payload); +} + +export function focus({relatedTarget} = {}) { + return createEvent('focus', {relatedTarget}); +} + +export function scroll() { + return createEvent('scroll'); +} + +/** + * Key events + */ + +export function keydown(payload) { + return createKeyboardEvent('keydown', payload); +} + +export function keyup(payload) { + return createKeyboardEvent('keyup', payload); +} + +/** + * Pointer events + */ + +export function gotpointercapture(payload) { + return createPointerEvent('gotpointercapture', payload); +} + +export function lostpointercapture(payload) { + return createPointerEvent('lostpointercapture', payload); +} + +export function pointercancel(payload) { + return createPointerEvent('pointercancel', payload); +} + +export function pointerdown(payload) { + return createPointerEvent('pointerdown', {button: 0, buttons: 2, ...payload}); +} + +export function pointerenter(payload) { + return createPointerEvent('pointerenter', payload); +} + +export function pointerleave(payload) { + return createPointerEvent('pointerleave', payload); +} + +export function pointermove(payload) { + return createPointerEvent('pointermove', payload); +} + +export function pointerout(payload) { + return createPointerEvent('pointerout', payload); +} + +export function pointerover(payload) { + return createPointerEvent('pointerover', payload); +} + +export function pointerup(payload) { + return createPointerEvent('pointerup', {button: 0, buttons: 2, ...payload}); +} + +/** + * Mouse events + */ + +export function mousedown(payload) { + return createMouseEvent('mousedown', {button: 0, buttons: 2, ...payload}); +} + +export function mouseenter(payload) { + return createMouseEvent('mouseenter', payload); +} + +export function mouseleave(payload) { + return createMouseEvent('mouseleave', payload); +} + +export function mousemove(payload) { + return createMouseEvent('mousemove', payload); +} + +export function mouseout(payload) { + return createMouseEvent('mouseout', payload); +} + +export function mouseover(payload) { + return createMouseEvent('mouseover', payload); +} + +export function mouseup(payload) { + return createMouseEvent('mouseup', {button: 0, buttons: 2, ...payload}); +} + +/** + * Touch events + */ + +export function touchcancel(payload) { + return createTouchEvent('touchcancel', payload); +} + +export function touchend(payload) { + return createTouchEvent('touchend', payload); +} + +export function touchmove(payload) { + return createTouchEvent('touchmove', payload); +} + +export function touchstart(payload) { + return createTouchEvent('touchstart', payload); +} diff --git a/packages/react-events/src/dom/testing-library/index.js b/packages/react-events/src/dom/testing-library/index.js new file mode 100644 index 0000000000000..cb549dc5b53c7 --- /dev/null +++ b/packages/react-events/src/dom/testing-library/index.js @@ -0,0 +1,104 @@ +/** + * 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'; + +import * as domEvents from './domEvents'; +import * as domEventSequences from './domEventSequences'; +import {hasPointerEvent, setPointerEvent, platform} from './domEnvironment'; + +const createEventTarget = node => ({ + node, + /** + * General events abstraction. + */ + blur(payload) { + node.dispatchEvent(domEvents.blur(payload)); + }, + click(payload) { + node.dispatchEvent(domEvents.click(payload)); + }, + contextmenu(payload, options) { + domEventSequences.contextmenu(node, payload, options); + }, + focus(payload) { + node.dispatchEvent(domEvents.focus(payload)); + }, + /** + * KeyboardEvent abstraction. + */ + keydown(payload) { + node.dispatchEvent(domEvents.keydown(payload)); + }, + keyup(payload) { + node.dispatchEvent(domEvents.keyup(payload)); + }, + scroll(payload) { + node.dispatchEvent(domEvents.scroll(payload)); + }, + /** + * PointerEvent abstraction. + * Dispatches the expected sequence of PointerEvents, MouseEvents, and + * TouchEvents for a given environment. + */ + // node no longer receives events for the pointer + pointercancel(payload) { + domEventSequences.pointercancel(node, payload); + }, + // node dispatches down events + pointerdown(payload) { + domEventSequences.pointerdown(node, payload); + }, + // node dispatches move events (pointer is not down) + pointerhover(payload) { + domEventSequences.pointerhover(node, payload); + }, + // node dispatches move events (pointer is down) + pointermove(payload) { + domEventSequences.pointermove(node, payload); + }, + // node dispatches enter & over events + pointerenter(payload) { + domEventSequences.pointerenter(node, payload); + }, + // node dispatches exit & out events + pointerexit(payload) { + domEventSequences.pointerexit(node, payload); + }, + // node dispatches up events + pointerup(payload) { + domEventSequences.pointerup(node, payload); + }, + /** + * Gesture abstractions. + * Helpers for event sequences expected in a gesture. + * target.tap({ pointerType: 'touch' }) + */ + tap(payload) { + domEventSequences.pointerdown(payload); + domEventSequences.pointerup(payload); + }, + /** + * Utilities + */ + setBoundingClientRect({x, y, width, height}) { + node.getBoundingClientRect = function() { + return { + width, + height, + left: x, + right: x + width, + top: y, + bottom: y + height, + }; + }; + }, +}); + +export {createEventTarget, platform, hasPointerEvent, setPointerEvent}; diff --git a/packages/react-native-renderer/src/ReactFabric.js b/packages/react-native-renderer/src/ReactFabric.js index 4637f9a0bc80d..72f7a46098191 100644 --- a/packages/react-native-renderer/src/ReactFabric.js +++ b/packages/react-native-renderer/src/ReactFabric.js @@ -144,7 +144,7 @@ const ReactFabric: ReactFabricType = { if (!root) { // TODO (bvaughn): If we decide to keep the wrapper component, // We could create a wrapper for containerTag as well to reduce special casing. - root = createContainer(containerTag, LegacyRoot, false); + root = createContainer(containerTag, LegacyRoot, false, null); roots.set(containerTag, root); } updateContainer(element, root, null, callback); diff --git a/packages/react-native-renderer/src/ReactFabricEventResponderSystem.js b/packages/react-native-renderer/src/ReactFabricEventResponderSystem.js index 2a9cd8a482f33..4ffaf534cf2c4 100644 --- a/packages/react-native-renderer/src/ReactFabricEventResponderSystem.js +++ b/packages/react-native-renderer/src/ReactFabricEventResponderSystem.js @@ -13,6 +13,7 @@ import { batchedEventUpdates, discreteUpdates, flushDiscreteUpdatesIfNeeded, + executeUserEventHandler, } from 'legacy-events/ReactGenericBatching'; import type { ReactEventResponder, @@ -30,7 +31,6 @@ import { UserBlockingEvent, DiscreteEvent, } from './ReactNativeTypes'; -import {invokeGuardedCallbackAndCatchFirstError} from 'shared/ReactErrorUtils'; import {enableUserBlockingEvents} from 'shared/ReactFeatureFlags'; import warning from 'shared/warning'; import invariant from 'shared/invariant'; @@ -43,12 +43,6 @@ const { unstable_runWithPriority: runWithPriority, } = Scheduler; -type EventQueueItem = {| - listener: (val: any) => void, - value: any, -|}; -type EventQueue = Array; - type ResponderTimeout = {| id: TimeoutID, timers: Map, @@ -78,17 +72,10 @@ const rootEventTypesToEventResponderInstances: Map< string, Set, > = new Map(); -const ownershipChangeListeners: Set< - ReactNativeEventResponderInstance, -> = new Set(); - -let globalOwner = null; let currentTimeStamp = 0; let currentTimers = new Map(); let currentInstance: null | ReactNativeEventResponderInstance = null; -let currentEventQueue: null | EventQueue = null; -let currentEventQueuePriority: EventPriority = ContinuousEvent; let currentTimerIDCounter = 0; const eventResponderContext: ReactNativeResponderContext = { @@ -99,12 +86,29 @@ const eventResponderContext: ReactNativeResponderContext = { ): void { validateResponderContext(); validateEventValue(eventValue); - if (eventPriority < currentEventQueuePriority) { - currentEventQueuePriority = eventPriority; + switch (eventPriority) { + case DiscreteEvent: { + flushDiscreteUpdatesIfNeeded(currentTimeStamp); + discreteUpdates(() => + executeUserEventHandler(eventListener, eventValue), + ); + break; + } + case UserBlockingEvent: { + if (enableUserBlockingEvents) { + runWithPriority(UserBlockingPriority, () => + executeUserEventHandler(eventListener, eventValue), + ); + } else { + executeUserEventHandler(eventListener, eventValue); + } + break; + } + case ContinuousEvent: { + executeUserEventHandler(eventListener, eventValue); + break; + } } - ((currentEventQueue: any): EventQueue).push( - createEventQueueItem(eventValue, eventListener), - ); }, isTargetWithinNode( childTarget: ReactNativeEventTarget, @@ -215,16 +219,6 @@ const eventResponderContext: ReactNativeResponderContext = { }, }; -function createEventQueueItem( - value: any, - listener: (val: any) => void, -): EventQueueItem { - return { - value, - listener, - }; -} - function validateEventValue(eventValue: any): void { if (typeof eventValue === 'object' && eventValue !== null) { const {target, type, timeStamp} = eventValue; @@ -290,24 +284,22 @@ function processTimers( delay: number, ): void { const timersArr = Array.from(timers.values()); - currentEventQueuePriority = ContinuousEvent; try { - for (let i = 0; i < timersArr.length; i++) { - const {instance, func, id, timeStamp} = timersArr[i]; - currentInstance = instance; - currentEventQueue = []; - currentTimeStamp = timeStamp + delay; - try { - func(); - } finally { - activeTimeouts.delete(id); + batchedEventUpdates(() => { + for (let i = 0; i < timersArr.length; i++) { + const {instance, func, id, timeStamp} = timersArr[i]; + currentInstance = instance; + currentTimeStamp = timeStamp + delay; + try { + func(); + } finally { + activeTimeouts.delete(id); + } } - } - processEventQueue(); + }); } finally { currentTimers = null; currentInstance = null; - currentEventQueue = null; currentTimeStamp = 0; } } @@ -327,68 +319,12 @@ function createFabricResponderEvent( function validateResponderContext(): void { invariant( - currentEventQueue && currentInstance, + currentInstance, 'An event responder context was used outside of an event cycle. ' + 'Use context.setTimeout() to use asynchronous responder context outside of event cycle .', ); } -// TODO this function is almost an exact copy of the DOM version, we should -// somehow share the logic -function processEventQueue(): void { - const eventQueue = ((currentEventQueue: any): EventQueue); - if (eventQueue.length === 0) { - return; - } - switch (currentEventQueuePriority) { - case DiscreteEvent: { - flushDiscreteUpdatesIfNeeded(currentTimeStamp); - discreteUpdates(() => { - batchedEventUpdates(processEvents, eventQueue); - }); - break; - } - case UserBlockingEvent: { - if (enableUserBlockingEvents) { - runWithPriority( - UserBlockingPriority, - batchedEventUpdates.bind(null, processEvents, eventQueue), - ); - } else { - batchedEventUpdates(processEvents, eventQueue); - } - break; - } - case ContinuousEvent: { - batchedEventUpdates(processEvents, eventQueue); - break; - } - } -} - -// TODO this function is almost an exact copy of the DOM version, we should -// somehow share the logic -function releaseOwnershipForEventResponderInstance( - eventResponderInstance: ReactNativeEventResponderInstance, -): boolean { - if (globalOwner === eventResponderInstance) { - globalOwner = null; - triggerOwnershipListeners(); - return true; - } - return false; -} - -// TODO this function is almost an exact copy of the DOM version, we should -// somehow share the logic -function processEvents(eventQueue: EventQueue): void { - for (let i = 0, length = eventQueue.length; i < length; i++) { - const {value, listener} = eventQueue[i]; - const type = typeof value === 'object' && value !== null ? value.type : ''; - invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, value); - } -} - // TODO this function is almost an exact copy of the DOM version, we should // somehow share the logic function responderEventTypesContainType( @@ -415,12 +351,6 @@ function validateResponderTargetEventTypes( return false; } -function validateOwnership( - responderInstance: ReactNativeEventResponderInstance, -): boolean { - return globalOwner === null || globalOwner === responderInstance; -} - // TODO this function is almost an exact copy of the DOM version, we should // somehow share the logic function traverseAndHandleEventResponderInstances( @@ -449,20 +379,17 @@ function traverseAndHandleEventResponderInstances( const responderInstances = Array.from(respondersMap.values()); for (let i = 0, length = responderInstances.length; i < length; i++) { const responderInstance = responderInstances[i]; - - if (validateOwnership(responderInstance)) { - const {props, responder, state, target} = responderInstance; - if ( - !visitedResponders.has(responder) && - validateResponderTargetEventTypes(eventType, responder) - ) { - const onEvent = responder.onEvent; - visitedResponders.add(responder); - if (onEvent !== null) { - currentInstance = responderInstance; - responderEvent.responderTarget = ((target: any): ReactNativeEventTarget); - onEvent(responderEvent, eventResponderContext, props, state); - } + const {props, responder, state, target} = responderInstance; + if ( + !visitedResponders.has(responder) && + validateResponderTargetEventTypes(eventType, responder) + ) { + const onEvent = responder.onEvent; + visitedResponders.add(responder); + if (onEvent !== null) { + currentInstance = responderInstance; + responderEvent.responderTarget = ((target: any): ReactNativeEventTarget); + onEvent(responderEvent, eventResponderContext, props, state); } } } @@ -479,9 +406,6 @@ function traverseAndHandleEventResponderInstances( for (let i = 0; i < responderInstances.length; i++) { const responderInstance = responderInstances[i]; - if (!validateOwnership(responderInstance)) { - continue; - } const {props, responder, state, target} = responderInstance; const onRootEvent = responder.onRootEvent; if (onRootEvent !== null) { @@ -500,57 +424,24 @@ export function dispatchEventForResponderEventSystem( targetFiber: null | Fiber, nativeEvent: ReactFaricEvent, ): void { - const previousEventQueue = currentEventQueue; const previousInstance = currentInstance; const previousTimers = currentTimers; const previousTimeStamp = currentTimeStamp; - const previousEventQueuePriority = currentEventQueuePriority; currentTimers = null; - currentEventQueue = []; - currentEventQueuePriority = ContinuousEvent; // We might want to control timeStamp another way here currentTimeStamp = Date.now(); try { - traverseAndHandleEventResponderInstances( - topLevelType, - targetFiber, - nativeEvent, - ); - processEventQueue(); + batchedEventUpdates(() => { + traverseAndHandleEventResponderInstances( + topLevelType, + targetFiber, + nativeEvent, + ); + }); } finally { currentTimers = previousTimers; currentInstance = previousInstance; - currentEventQueue = previousEventQueue; currentTimeStamp = previousTimeStamp; - currentEventQueuePriority = previousEventQueuePriority; - } -} - -// TODO this function is almost an exact copy of the DOM version, we should -// somehow share the logic -function triggerOwnershipListeners(): void { - const listeningInstances = Array.from(ownershipChangeListeners); - const previousInstance = currentInstance; - const previousEventQueuePriority = currentEventQueuePriority; - const previousEventQueue = currentEventQueue; - try { - for (let i = 0; i < listeningInstances.length; i++) { - const instance = listeningInstances[i]; - const {props, responder, state} = instance; - currentInstance = instance; - currentEventQueuePriority = ContinuousEvent; - currentEventQueue = []; - const onOwnershipChange = ((responder: any): ReactNativeEventResponder) - .onOwnershipChange; - if (onOwnershipChange !== null) { - onOwnershipChange(eventResponderContext, props, state); - } - } - processEventQueue(); - } finally { - currentInstance = previousInstance; - currentEventQueue = previousEventQueue; - currentEventQueuePriority = previousEventQueuePriority; } } @@ -562,19 +453,14 @@ export function mountEventResponder( props: Object, state: Object, ) { - if (responder.onOwnershipChange !== null) { - ownershipChangeListeners.add(responderInstance); - } const onMount = responder.onMount; if (onMount !== null) { - currentEventQueuePriority = ContinuousEvent; currentInstance = responderInstance; - currentEventQueue = []; try { - onMount(eventResponderContext, props, state); - processEventQueue(); + batchedEventUpdates(() => { + onMount(eventResponderContext, props, state); + }); } finally { - currentEventQueue = null; currentInstance = null; currentTimers = null; } @@ -590,22 +476,16 @@ export function unmountEventResponder( const onUnmount = responder.onUnmount; if (onUnmount !== null) { let {props, state} = responderInstance; - currentEventQueue = []; - currentEventQueuePriority = ContinuousEvent; currentInstance = responderInstance; try { - onUnmount(eventResponderContext, props, state); - processEventQueue(); + batchedEventUpdates(() => { + onUnmount(eventResponderContext, props, state); + }); } finally { - currentEventQueue = null; currentInstance = null; currentTimers = null; } } - releaseOwnershipForEventResponderInstance(responderInstance); - if (responder.onOwnershipChange !== null) { - ownershipChangeListeners.delete(responderInstance); - } const rootEventTypesSet = responderInstance.rootEventTypes; if (rootEventTypesSet !== null) { const rootEventTypes = Array.from(rootEventTypesSet); diff --git a/packages/react-native-renderer/src/ReactNativeRenderer.js b/packages/react-native-renderer/src/ReactNativeRenderer.js index 853c5f54fc7ae..21ec4e239e2cd 100644 --- a/packages/react-native-renderer/src/ReactNativeRenderer.js +++ b/packages/react-native-renderer/src/ReactNativeRenderer.js @@ -141,7 +141,7 @@ const ReactNativeRenderer: ReactNativeType = { if (!root) { // TODO (bvaughn): If we decide to keep the wrapper component, // We could create a wrapper for containerTag as well to reduce special casing. - root = createContainer(containerTag, LegacyRoot, false); + root = createContainer(containerTag, LegacyRoot, false, null); roots.set(containerTag, root); } updateContainer(element, root, null, callback); diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 7369552eb34e6..1ae8275449cc5 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -908,7 +908,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { if (!root) { const container = {rootID: rootID, pendingChildren: [], children: []}; rootContainers.set(rootID, container); - root = NoopRenderer.createContainer(container, tag, false); + root = NoopRenderer.createContainer(container, tag, false, null); roots.set(rootID, root); } return root.current.stateNode.containerInfo; @@ -925,6 +925,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { container, ConcurrentRoot, false, + null, ); return { _Scheduler: Scheduler, @@ -950,6 +951,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { container, BatchedRoot, false, + null, ); return { _Scheduler: Scheduler, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 3a6ff1f5aca05..b8996ad698ee1 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -607,7 +607,12 @@ function commitLifeCycles( } return; } - case SuspenseComponent: + case SuspenseComponent: { + if (enableSuspenseCallback) { + commitSuspenseHydrationCallbacks(finishedRoot, finishedWork); + } + return; + } case SuspenseListComponent: case IncompleteClassComponent: case FundamentalComponent: @@ -644,7 +649,8 @@ function hideOrUnhideAllChildren(finishedWork, isHidden) { } } else if ( node.tag === SuspenseComponent && - node.memoizedState !== null + node.memoizedState !== null && + node.memoizedState.dehydrated === null ) { // Found a nested Suspense component that timed out. Skip over the // primary child fragment, which should remain hidden. @@ -719,6 +725,7 @@ function commitDetachRef(current: Fiber) { // deletion, so don't let them throw. Host-originating errors should // interrupt deletion, so it's okay function commitUnmount( + finishedRoot: FiberRoot, current: Fiber, renderPriorityLevel: ReactPriorityLevel, ): void { @@ -801,7 +808,7 @@ function commitUnmount( // We are also not using this parent because // the portal will get pushed immediately. if (supportsMutation) { - unmountHostComponents(current, renderPriorityLevel); + unmountHostComponents(finishedRoot, current, renderPriorityLevel); } else if (supportsPersistence) { emptyPortalContainer(current); } @@ -815,11 +822,24 @@ function commitUnmount( current.stateNode = null; } } + return; + } + case DehydratedFragment: { + if (enableSuspenseCallback) { + const hydrationCallbacks = finishedRoot.hydrationCallbacks; + if (hydrationCallbacks !== null) { + const onDeleted = hydrationCallbacks.onDeleted; + if (onDeleted) { + onDeleted((current.stateNode: SuspenseInstance)); + } + } + } } } } function commitNestedUnmounts( + finishedRoot: FiberRoot, root: Fiber, renderPriorityLevel: ReactPriorityLevel, ): void { @@ -830,7 +850,7 @@ function commitNestedUnmounts( // we do an inner loop while we're still inside the host node. let node: Fiber = root; while (true) { - commitUnmount(node, renderPriorityLevel); + commitUnmount(finishedRoot, node, renderPriorityLevel); // Visit children because they may contain more composite or host nodes. // Skip portals because commitUnmount() currently visits them recursively. if ( @@ -1081,7 +1101,11 @@ function commitPlacement(finishedWork: Fiber): void { } } -function unmountHostComponents(current, renderPriorityLevel): void { +function unmountHostComponents( + finishedRoot, + current, + renderPriorityLevel, +): void { // We only have the top Fiber that was deleted but we need to recurse down its // children to find all the terminal nodes. let node: Fiber = current; @@ -1129,7 +1153,7 @@ function unmountHostComponents(current, renderPriorityLevel): void { } if (node.tag === HostComponent || node.tag === HostText) { - commitNestedUnmounts(node, renderPriorityLevel); + commitNestedUnmounts(finishedRoot, node, renderPriorityLevel); // After all the children have unmounted, it is now safe to remove the // node from the tree. if (currentParentIsContainer) { @@ -1146,7 +1170,7 @@ function unmountHostComponents(current, renderPriorityLevel): void { // Don't visit children because we already visited them. } else if (enableFundamentalAPI && node.tag === FundamentalComponent) { const fundamentalNode = node.stateNode.instance; - commitNestedUnmounts(node, renderPriorityLevel); + commitNestedUnmounts(finishedRoot, node, renderPriorityLevel); // After all the children have unmounted, it is now safe to remove the // node from the tree. if (currentParentIsContainer) { @@ -1164,6 +1188,16 @@ function unmountHostComponents(current, renderPriorityLevel): void { enableSuspenseServerRenderer && node.tag === DehydratedFragment ) { + if (enableSuspenseCallback) { + const hydrationCallbacks = finishedRoot.hydrationCallbacks; + if (hydrationCallbacks !== null) { + const onDeleted = hydrationCallbacks.onDeleted; + if (onDeleted) { + onDeleted((node.stateNode: SuspenseInstance)); + } + } + } + // Delete the dehydrated suspense boundary and all of its content. if (currentParentIsContainer) { clearSuspenseBoundaryFromContainer( @@ -1188,7 +1222,7 @@ function unmountHostComponents(current, renderPriorityLevel): void { continue; } } else { - commitUnmount(node, renderPriorityLevel); + commitUnmount(finishedRoot, node, renderPriorityLevel); // Visit children because we may find more host components below. if (node.child !== null) { node.child.return = node; @@ -1216,16 +1250,17 @@ function unmountHostComponents(current, renderPriorityLevel): void { } function commitDeletion( + finishedRoot: FiberRoot, current: Fiber, renderPriorityLevel: ReactPriorityLevel, ): void { if (supportsMutation) { // Recursively delete all host nodes from the parent. // Detach refs and call componentWillUnmount() on the whole subtree. - unmountHostComponents(current, renderPriorityLevel); + unmountHostComponents(finishedRoot, current, renderPriorityLevel); } else { // Detach refs and call componentWillUnmount() on the whole subtree. - commitNestedUnmounts(current, renderPriorityLevel); + commitNestedUnmounts(finishedRoot, current, renderPriorityLevel); } detachFiber(current); } @@ -1382,6 +1417,30 @@ function commitSuspenseComponent(finishedWork: Fiber) { } } +function commitSuspenseHydrationCallbacks( + finishedRoot: FiberRoot, + finishedWork: Fiber, +) { + if (enableSuspenseCallback) { + const hydrationCallbacks = finishedRoot.hydrationCallbacks; + if (hydrationCallbacks !== null) { + const onHydrated = hydrationCallbacks.onHydrated; + if (onHydrated) { + const newState: SuspenseState | null = finishedWork.memoizedState; + if (newState === null) { + const current = finishedWork.alternate; + if (current !== null) { + const prevState: SuspenseState | null = current.memoizedState; + if (prevState !== null && prevState.dehydrated !== null) { + onHydrated(prevState.dehydrated); + } + } + } + } + } + } +} + function attachSuspenseRetryListeners(finishedWork: Fiber) { // If this boundary just timed out, then it will have a set of thenables. // For each thenable, attach a listener so that when it resolves, React diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 6433a193ea69b..6d5fa4f3173fb 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -859,6 +859,10 @@ function completeWork( if ((workInProgress.effectTag & DidCapture) === NoEffect) { // This boundary did not suspend so it's now hydrated and unsuspended. workInProgress.memoizedState = null; + if (enableSuspenseCallback) { + // Notify the callback. + workInProgress.effectTag |= Update; + } } else { // Something suspended. Schedule an effect to attach retry listeners. workInProgress.effectTag |= Update; diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 81c8418263d65..03378ee54a234 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -20,6 +20,7 @@ import {FundamentalComponent} from 'shared/ReactWorkTags'; import type {ReactNodeList} from 'shared/ReactTypes'; import type {ExpirationTime} from './ReactFiberExpirationTime'; import type {SuspenseConfig} from './ReactFiberSuspenseConfig'; +import type {SuspenseHydrationCallbacks} from './ReactFiberSuspenseComponent'; import { findCurrentHostFiber, @@ -294,8 +295,9 @@ export function createContainer( containerInfo: Container, tag: RootTag, hydrate: boolean, + hydrationCallbacks: null | SuspenseHydrationCallbacks, ): OpaqueRoot { - return createFiberRoot(containerInfo, tag, hydrate); + return createFiberRoot(containerInfo, tag, hydrate, hydrationCallbacks); } export function updateContainer( diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 148ff8a8e9a69..7ef93739039e0 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -13,11 +13,15 @@ import type {RootTag} from 'shared/ReactRootTags'; import type {TimeoutHandle, NoTimeout} from './ReactFiberHostConfig'; import type {Thenable} from './ReactFiberWorkLoop'; import type {Interaction} from 'scheduler/src/Tracing'; +import type {SuspenseHydrationCallbacks} from './ReactFiberSuspenseComponent'; import {noTimeout} from './ReactFiberHostConfig'; import {createHostRootFiber} from './ReactFiber'; import {NoWork} from './ReactFiberExpirationTime'; -import {enableSchedulerTracing} from 'shared/ReactFeatureFlags'; +import { + enableSchedulerTracing, + enableSuspenseCallback, +} from 'shared/ReactFeatureFlags'; import {unstable_getThreadID} from 'scheduler/tracing'; // TODO: This should be lifted into the renderer. @@ -83,6 +87,11 @@ type ProfilingOnlyFiberRootProperties = {| pendingInteractionMap: PendingInteractionMap, |}; +// The follow fields are only used by enableSuspenseCallback for hydration. +type SuspenseCallbackOnlyFiberRootProperties = {| + hydrationCallbacks: null | SuspenseHydrationCallbacks, +|}; + // Exported FiberRoot type includes all properties, // To avoid requiring potentially error-prone :any casts throughout the project. // Profiling properties are only safe to access in profiling builds (when enableSchedulerTracing is true). @@ -91,6 +100,7 @@ type ProfilingOnlyFiberRootProperties = {| export type FiberRoot = { ...BaseFiberRootProperties, ...ProfilingOnlyFiberRootProperties, + ...SuspenseCallbackOnlyFiberRootProperties, }; function FiberRootNode(containerInfo, tag, hydrate) { @@ -117,14 +127,21 @@ function FiberRootNode(containerInfo, tag, hydrate) { this.memoizedInteractions = new Set(); this.pendingInteractionMap = new Map(); } + if (enableSuspenseCallback) { + this.hydrationCallbacks = null; + } } export function createFiberRoot( containerInfo: any, tag: RootTag, hydrate: boolean, + hydrationCallbacks: null | SuspenseHydrationCallbacks, ): FiberRoot { const root: FiberRoot = (new FiberRootNode(containerInfo, tag, hydrate): any); + if (enableSuspenseCallback) { + root.hydrationCallbacks = hydrationCallbacks; + } // Cyclic construction. This cheats the type system right now because // stateNode is any. diff --git a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js index 284c6f44291f5..dfb6964d64e28 100644 --- a/packages/react-reconciler/src/ReactFiberSuspenseComponent.js +++ b/packages/react-reconciler/src/ReactFiberSuspenseComponent.js @@ -17,6 +17,11 @@ import { isSuspenseInstanceFallback, } from './ReactFiberHostConfig'; +export type SuspenseHydrationCallbacks = { + onHydrated?: (suspenseInstance: SuspenseInstance) => void, + onDeleted?: (suspenseInstance: SuspenseInstance) => void, +}; + // A null SuspenseState represents an unsuspended normal Suspense boundary. // A non-null SuspenseState means that it is blocked for one reason or another. // - A non-null dehydrated field means it's blocked pending hydration. diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index efbef396d0315..21454b8feebac 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -1638,6 +1638,7 @@ function commitRootImpl(root, renderPriorityLevel) { null, commitMutationEffects, null, + root, renderPriorityLevel, ); if (hasCaughtError()) { @@ -1648,7 +1649,7 @@ function commitRootImpl(root, renderPriorityLevel) { } } else { try { - commitMutationEffects(renderPriorityLevel); + commitMutationEffects(root, renderPriorityLevel); } catch (error) { invariant(nextEffect !== null, 'Should be working on an effect.'); captureCommitPhaseError(nextEffect, error); @@ -1837,7 +1838,7 @@ function commitBeforeMutationEffects() { } } -function commitMutationEffects(renderPriorityLevel) { +function commitMutationEffects(root: FiberRoot, renderPriorityLevel) { // TODO: Should probably move the bulk of this function to commitWork. while (nextEffect !== null) { setCurrentDebugFiberInDEV(nextEffect); @@ -1888,7 +1889,7 @@ function commitMutationEffects(renderPriorityLevel) { break; } case Deletion: { - commitDeletion(nextEffect, renderPriorityLevel); + commitDeletion(root, nextEffect, renderPriorityLevel); break; } } diff --git a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js index 1849eceaae98a..6a724025abdcb 100644 --- a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js @@ -58,6 +58,7 @@ describe('ReactFiberHostContext', () => { /* root: */ null, ConcurrentRoot, false, + null, ); Renderer.updateContainer( @@ -110,6 +111,7 @@ describe('ReactFiberHostContext', () => { rootContext, ConcurrentRoot, false, + null, ); Renderer.updateContainer( diff --git a/packages/react-test-renderer/src/ReactTestRenderer.js b/packages/react-test-renderer/src/ReactTestRenderer.js index 11b6fed109697..f5133c528464f 100644 --- a/packages/react-test-renderer/src/ReactTestRenderer.js +++ b/packages/react-test-renderer/src/ReactTestRenderer.js @@ -442,6 +442,7 @@ const ReactTestRendererFiber = { container, isConcurrent ? ConcurrentRoot : LegacyRoot, false, + null, ); invariant(root != null, 'something went wrong'); updateContainer(element, root, null, null); diff --git a/packages/scheduler/src/__tests__/SchedulerBrowser-test.internal.js b/packages/scheduler/src/__tests__/SchedulerBrowser-test.internal.js index dba2c3b5da567..657e597620632 100644 --- a/packages/scheduler/src/__tests__/SchedulerBrowser-test.internal.js +++ b/packages/scheduler/src/__tests__/SchedulerBrowser-test.internal.js @@ -15,6 +15,7 @@ let Scheduler; let runtime; let performance; +let cancelCallback; let scheduleCallback; let NormalPriority; @@ -52,6 +53,7 @@ describe('SchedulerBrowser', () => { performance = window.performance; require('scheduler/src/SchedulerFeatureFlags').enableMessageLoopImplementation = enableMessageLoopImplementation; Scheduler = require('scheduler'); + cancelCallback = Scheduler.unstable_cancelCallback; scheduleCallback = Scheduler.unstable_scheduleCallback; NormalPriority = Scheduler.unstable_NormalPriority; }); @@ -360,6 +362,15 @@ describe('SchedulerBrowser', () => { const enableMessageLoopImplementation = true; beforeAndAfterHooks(enableMessageLoopImplementation); + it('task that finishes before deadline', () => { + scheduleCallback(NormalPriority, () => { + runtime.log('Task'); + }); + runtime.assertLog(['Post Message']); + runtime.fireMessageEvent(); + runtime.assertLog(['Message Event', 'Task']); + }); + it('task with continutation', () => { scheduleCallback(NormalPriority, () => { runtime.log('Task'); @@ -385,7 +396,48 @@ describe('SchedulerBrowser', () => { runtime.assertLog(['Message Event', 'Continuation']); }); - it('task that throws', () => { + it('multiple tasks', () => { + scheduleCallback(NormalPriority, () => { + runtime.log('A'); + }); + scheduleCallback(NormalPriority, () => { + runtime.log('B'); + }); + runtime.assertLog(['Post Message']); + runtime.fireMessageEvent(); + runtime.assertLog(['Message Event', 'A', 'B']); + }); + + it('multiple tasks with a yield in between', () => { + scheduleCallback(NormalPriority, () => { + runtime.log('A'); + runtime.advanceTime(4999); + }); + scheduleCallback(NormalPriority, () => { + runtime.log('B'); + }); + runtime.assertLog(['Post Message']); + runtime.fireMessageEvent(); + runtime.assertLog([ + 'Message Event', + 'A', + // Ran out of time. Post a continuation event. + 'Post Message', + ]); + runtime.fireMessageEvent(); + runtime.assertLog(['Message Event', 'B']); + }); + + it('cancels tasks', () => { + const task = scheduleCallback(NormalPriority, () => { + runtime.log('Task'); + }); + runtime.assertLog(['Post Message']); + cancelCallback(task); + runtime.assertLog([]); + }); + + it('throws when a task errors then continues in a new event', () => { scheduleCallback(NormalPriority, () => { runtime.log('Oops!'); throw Error('Oops!'); @@ -418,5 +470,24 @@ describe('SchedulerBrowser', () => { runtime.fireMessageEvent(); runtime.assertLog(['Message Event', 'B']); }); + + it('schedule new task after a cancellation', () => { + let handle = scheduleCallback(NormalPriority, () => { + runtime.log('A'); + }); + + runtime.assertLog(['Post Message']); + cancelCallback(handle); + + runtime.fireMessageEvent(); + runtime.assertLog(['Message Event']); + + scheduleCallback(NormalPriority, () => { + runtime.log('B'); + }); + runtime.assertLog(['Post Message']); + runtime.fireMessageEvent(); + runtime.assertLog(['Message Event', 'B']); + }); }); }); diff --git a/packages/scheduler/src/forks/SchedulerHostConfig.default.js b/packages/scheduler/src/forks/SchedulerHostConfig.default.js index 8e6afbcc8ad11..c11fca6cd029d 100644 --- a/packages/scheduler/src/forks/SchedulerHostConfig.default.js +++ b/packages/scheduler/src/forks/SchedulerHostConfig.default.js @@ -235,6 +235,8 @@ if ( port.postMessage(null); throw error; } + } else { + isMessageLoopRunning = false; } // Yielding to the browser will give it a chance to paint, so we can // reset this. diff --git a/packages/shared/ReactDOMTypes.js b/packages/shared/ReactDOMTypes.js index 746ea6fa5dd69..6f2946341bb7d 100644 --- a/packages/shared/ReactDOMTypes.js +++ b/packages/shared/ReactDOMTypes.js @@ -64,9 +64,6 @@ export type ReactDOMResponderContext = { isTargetWithinResponderScope: (Element | Document) => boolean, addRootEventTypes: (rootEventTypes: Array) => void, removeRootEventTypes: (rootEventTypes: Array) => void, - hasOwnership: () => boolean, - requestGlobalOwnership: () => boolean, - releaseOwnership: () => boolean, setTimeout: (func: () => void, timeout: number) => number, clearTimeout: (timerId: number) => void, getFocusableElementsInScope(deep: boolean): Array, diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 34d482cf2b46b..3b64033a6e2ff 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -84,6 +84,8 @@ export const enableUserBlockingEvents = false; // Add a callback property to suspense to notify which promises are currently // in the update queue. This allows reporting and tracing of what is causing // the user to see a loading state. +// Also allows hydration callbacks to fire when a dehydrated boundary gets +// hydrated or deleted. export const enableSuspenseCallback = false; // Part of the simplification of React.createElement so we can eventually move diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 7f72ce6782c09..939872036cb83 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -107,9 +107,6 @@ export type ReactEventResponder = { | ((event: E, context: C, props: Object, state: Object) => void), onMount: null | ((context: C, props: Object, state: Object) => void), onUnmount: null | ((context: C, props: Object, state: Object) => void), - onOwnershipChange: - | null - | ((context: C, props: Object, state: Object) => void), }; export type EventPriority = 0 | 1 | 2; diff --git a/packages/shared/createEventResponder.js b/packages/shared/createEventResponder.js index cd21bfc9b0829..02373b9574668 100644 --- a/packages/shared/createEventResponder.js +++ b/packages/shared/createEventResponder.js @@ -19,7 +19,6 @@ export default function createEventResponder( onEvent, onMount, onUnmount, - onOwnershipChange, onRootEvent, rootEventTypes, targetEventTypes, @@ -30,7 +29,6 @@ export default function createEventResponder( getInitialState: getInitialState || null, onEvent: onEvent || null, onMount: onMount || null, - onOwnershipChange: onOwnershipChange || null, onRootEvent: onRootEvent || null, onUnmount: onUnmount || null, rootEventTypes: rootEventTypes || null, diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 392d643e4ef52..2c22194fc219f 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -301,7 +301,7 @@ "300": "Rendered fewer hooks than expected. This may be caused by an accidental early return statement.", "301": "Too many re-renders. React limits the number of renders to prevent an infinite loop.", "302": "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", - "303": "suspense fallback not found, something is broken", + "303": "ReactDOMServer did not find an internal fallback frame for Suspense. This is a bug in React. Please file an issue.", "304": "Maximum number of concurrent React renderers exceeded. This can happen if you are not properly destroying the Readable provided by React. Ensure that you call .destroy() on it if you no longer want to read from it, and did not read to the end. If you use .pipe() this should be automatic.", "305": "The current renderer does not support hydration. This error is likely caused by a bug in React. Please file an issue.", "306": "Element type is invalid. Received a promise that resolves to: %s. Lazy element type must resolve to a class or function.%s", @@ -339,5 +339,6 @@ "338": "ReactDOMServer does not yet support the fundamental API.", "339": "An invalid value was used as an event listener. Expect one or many event listeners created via React.unstable_useResponder().", "340": "Threw in newly mounted dehydrated component. This is likely a bug in React. Please file an issue.", - "341": "We just came from a parent so we must have had a parent. This is a bug in React." + "341": "We just came from a parent so we must have had a parent. This is a bug in React.", + "342": "A React component suspended while rendering, but no fallback UI was specified.\n\nAdd a component higher in the tree to provide a loading indicator or placeholder to display." }