diff --git a/.eslintrc.js b/.eslintrc.js index 30432c21a5..c91ba7e6e7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -142,6 +142,8 @@ module.exports = { ], globals: { + SharedArrayBuffer: true, + spyOnDev: true, spyOnDevAndProd: true, spyOnProd: true, diff --git a/CHANGELOG.md b/CHANGELOG.md index 80180a8e4e..cf391ed9f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,10 @@ * Warn in Strict Mode if effects are scheduled outside an `act()` call. ([@threepointone](https://github.com/threepointone) in [#15763](https://github.com/facebook/react/pull/15763) and [#16041](https://github.com/facebook/react/pull/16041)) * Warn when using `act` from the wrong renderer. ([@threepointone](https://github.com/threepointone) in [#15756](https://github.com/facebook/react/pull/15756)) +### ESLint Plugin: React Hooks + +* Report Hook calls at the top level as a violation. ([gaearon](https://github.com/gaearon) in [#16455](https://github.com/facebook/react/pull/16455)) + ## 16.8.6 (March 27, 2019) ### React DOM diff --git a/packages/eslint-plugin-react-hooks/package.json b/packages/eslint-plugin-react-hooks/package.json index dd970825f9..293a9c44ee 100644 --- a/packages/eslint-plugin-react-hooks/package.json +++ b/packages/eslint-plugin-react-hooks/package.json @@ -1,7 +1,7 @@ { "name": "eslint-plugin-react-hooks", "description": "ESLint rules for React Hooks", - "version": "1.7.0", + "version": "2.0.1", "repository": { "type": "git", "url": "https://github.com/facebook/react.git", diff --git a/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js b/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js index fff4566cfb..a4a4063a8b 100644 --- a/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js +++ b/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js @@ -432,8 +432,7 @@ export default { 'React Hook function.'; context.report({node: hook, message}); } else if (codePathNode.type === 'Program') { - // We could warn here but there are false positives related - // configuring libraries like `history`. + // These are dangerous if you have inline requires enabled. const message = `React Hook "${context.getSource(hook)}" cannot be called ` + 'at the top level. React Hooks must be called in a ' + diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index 231ee89205..247086c64b 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -432,7 +432,6 @@ export function mountResponderInstance( props: Object, state: Object, instance: Object, - rootContainerInstance: Object, ) { throw new Error('Not yet implemented.'); } diff --git a/packages/react-devtools/CHANGELOG.md b/packages/react-devtools/CHANGELOG.md index bc10a248ba..894bc3c75a 100644 --- a/packages/react-devtools/CHANGELOG.md +++ b/packages/react-devtools/CHANGELOG.md @@ -9,6 +9,11 @@ +## 4.0.6 (August 26, 2019) +#### Bug fixes +* Remove ⚛️ emoji prefix from Firefox extension tab labels +* Standalone polyfills `Symbol` usage + ## 4.0.5 (August 19, 2019) #### Bug fixes * Props, state, and context values are alpha sorted. diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 11c6ad904b..d31bf6cfc9 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -25,6 +25,7 @@ describe('ReactDOMServerPartialHydration', () => { ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.enableSuspenseServerRenderer = true; ReactFeatureFlags.enableSuspenseCallback = true; + ReactFeatureFlags.enableFlareAPI = true; React = require('react'); ReactDOM = require('react-dom'); @@ -1729,4 +1730,169 @@ describe('ReactDOMServerPartialHydration', () => { // patched up the tree, which might mean we haven't patched the className. expect(newSpan.className).toBe('hi'); }); + + it('does not invoke an event on a hydrated node until it commits', async () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Sibling({text}) { + if (suspend) { + throw promise; + } else { + return 'Hello'; + } + } + + let clicks = 0; + + function Button() { + let [clicked, setClicked] = React.useState(false); + if (clicked) { + return null; + } + return ( + { + setClicked(true); + clicks++; + }}> + Click me + + ); + } + + function App() { + return ( +
+ +
+ ); + } + + suspend = false; + let finalHTML = ReactDOMServer.renderToString(); + let container = document.createElement('div'); + container.innerHTML = finalHTML; + + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(container); + + let a = container.getElementsByTagName('a')[0]; + + // 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}); + root.render(); + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + expect(container.textContent).toBe('Click meHello'); + + // We're now partially hydrated. + a.click(); + expect(clicks).toBe(0); + + // Resolving the promise so that rendering can complete. + suspend = false; + resolve(); + await promise; + + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + // TODO: With selective hydration the event should've been replayed + // but for now we'll have to issue it again. + act(() => { + a.click(); + }); + + expect(clicks).toBe(1); + + expect(container.textContent).toBe('Hello'); + + document.body.removeChild(container); + }); + + it('does not invoke an event on a hydrated EventResponder until it commits', async () => { + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Sibling({text}) { + if (suspend) { + throw promise; + } else { + return 'Hello'; + } + } + + const onEvent = jest.fn(); + const TestResponder = React.unstable_createResponder('TestEventResponder', { + targetEventTypes: ['click'], + onEvent, + }); + + function Button() { + let listener = React.unstable_useResponder(TestResponder, {}); + return Click me; + } + + function App() { + return ( +
+ +
+ ); + } + + suspend = false; + let finalHTML = ReactDOMServer.renderToString(); + let container = document.createElement('div'); + container.innerHTML = finalHTML; + + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(container); + + let a = container.getElementsByTagName('a')[0]; + + // 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}); + root.render(); + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + // We're now partially hydrated. + a.click(); + // We should not have invoked the event yet because we're not + // yet hydrated. + expect(onEvent).toHaveBeenCalledTimes(0); + + // Resolving the promise so that rendering can complete. + suspend = false; + resolve(); + await promise; + + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + // TODO: With selective hydration the event should've been replayed + // but for now we'll have to issue it again. + act(() => { + a.click(); + }); + + expect(onEvent).toHaveBeenCalledTimes(1); + + document.body.removeChild(container); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js index d093a11bf8..a533923d5b 100644 --- a/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js @@ -13,6 +13,7 @@ let React; let ReactDOM; let ReactDOMServer; let Scheduler; +let act; // These tests rely both on ReactDOMServer and ReactDOM. // If a test only needs ReactDOMServer, put it in ReactServerRendering-test instead. @@ -23,6 +24,7 @@ describe('ReactDOMServerHydration', () => { ReactDOM = require('react-dom'); ReactDOMServer = require('react-dom/server'); Scheduler = require('scheduler'); + act = require('react-dom/test-utils').act; }); it('should have the correct mounting behavior (old hydrate API)', () => { @@ -499,4 +501,89 @@ describe('ReactDOMServerHydration', () => { Scheduler.unstable_flushAll(); expect(element.textContent).toBe('Hello world'); }); + + it('does not invoke an event on a concurrent hydrating node until it commits', () => { + function Sibling({text}) { + Scheduler.unstable_yieldValue('Sibling'); + return Sibling; + } + + function Sibling2({text}) { + Scheduler.unstable_yieldValue('Sibling2'); + return null; + } + + let clicks = 0; + + function Button() { + Scheduler.unstable_yieldValue('Button'); + let [clicked, setClicked] = React.useState(false); + if (clicked) { + return null; + } + return ( + { + setClicked(true); + clicks++; + }}> + Click me + + ); + } + + function App() { + return ( +
+
+ ); + } + + let finalHTML = ReactDOMServer.renderToString(); + let container = document.createElement('div'); + container.innerHTML = finalHTML; + expect(Scheduler).toHaveYielded(['Button', 'Sibling', 'Sibling2']); + + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(container); + + let a = container.getElementsByTagName('a')[0]; + + // Hydrate asynchronously. + let root = ReactDOM.unstable_createRoot(container, {hydrate: true}); + root.render(); + // Flush part way through the render. + if (__DEV__) { + // In DEV effects gets double invoked. + expect(Scheduler).toFlushAndYieldThrough(['Button', 'Button', 'Sibling']); + } else { + expect(Scheduler).toFlushAndYieldThrough(['Button', 'Sibling']); + } + + expect(container.textContent).toBe('Click meSibling'); + + // We're now partially hydrated. + a.click(); + // Clicking should not invoke the event yet because we haven't committed + // the hydration yet. + expect(clicks).toBe(0); + + // Finish the rest of the hydration. + expect(Scheduler).toFlushAndYield(['Sibling2']); + + // TODO: With selective hydration the event should've been replayed + // but for now we'll have to issue it again. + act(() => { + a.click(); + }); + + expect(clicks).toBe(1); + + expect(container.textContent).toBe('Sibling'); + + document.body.removeChild(container); + }); }); diff --git a/packages/react-dom/src/client/ReactDOMComponentTree.js b/packages/react-dom/src/client/ReactDOMComponentTree.js index a3b61d6e49..8d3105ae44 100644 --- a/packages/react-dom/src/client/ReactDOMComponentTree.js +++ b/packages/react-dom/src/client/ReactDOMComponentTree.js @@ -23,26 +23,29 @@ export function precacheFiberNode(hostInst, node) { * ReactDOMTextComponent instance ancestor. */ export function getClosestInstanceFromNode(node) { - if (node[internalInstanceKey]) { - return node[internalInstanceKey]; + let inst = node[internalInstanceKey]; + if (inst) { + return inst; } - while (!node[internalInstanceKey]) { - if (node.parentNode) { - node = node.parentNode; + do { + node = node.parentNode; + if (node) { + inst = node[internalInstanceKey]; } else { // Top of the tree. This node must not be part of a React tree (or is // unmounted, potentially). return null; } - } + } while (!inst); - let inst = node[internalInstanceKey]; - if (inst.tag === HostComponent || inst.tag === HostText) { - // In Fiber, this will always be the deepest root. - return inst; + let tag = inst.tag; + switch (tag) { + case HostComponent: + case HostText: + // In Fiber, this will always be the deepest root. + return inst; } - return null; } diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index bc2e94f802..6b95e63ac9 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -824,10 +824,9 @@ export function mountResponderInstance( responderProps: Object, responderState: Object, instance: Instance, - rootContainerInstance: Container, ): ReactDOMEventResponderInstance { // Listen to events - const doc = rootContainerInstance.ownerDocument; + const doc = instance.ownerDocument; const documentBody = doc.body || doc; const { rootEventTypes, diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index cc82d658b6..267b9ce63b 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -12,7 +12,7 @@ import { PASSIVE_NOT_SUPPORTED, } from 'legacy-events/EventSystemFlags'; import type {AnyNativeEvent} from 'legacy-events/PluginModuleType'; -import {HostComponent} from 'shared/ReactWorkTags'; +import {HostComponent, SuspenseComponent} from 'shared/ReactWorkTags'; import type {EventPriority} from 'shared/ReactTypes'; import type { ReactDOMEventResponder, @@ -32,10 +32,6 @@ import type {Fiber} from 'react-reconciler/src/ReactFiber'; import warning from 'shared/warning'; import {enableFlareAPI} from 'shared/ReactFeatureFlags'; import invariant from 'shared/invariant'; -import { - isFiberSuspenseAndTimedOut, - getSuspenseFallbackChild, -} from 'react-reconciler/src/ReactFiberEvents'; import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; import { @@ -445,7 +441,7 @@ function createDOMResponderEvent( passive: boolean, passiveSupported: boolean, ): ReactDOMResponderEvent { - const {pointerType} = (nativeEvent: any); + const {buttons, pointerType} = (nativeEvent: any); let eventPointerType = ''; let pointerId = null; @@ -454,7 +450,7 @@ function createDOMResponderEvent( pointerId = (nativeEvent: any).pointerId; } else if (nativeEvent.key !== undefined) { eventPointerType = 'keyboard'; - } else if (nativeEvent.button !== undefined) { + } else if (buttons !== undefined) { eventPointerType = 'mouse'; } else if ((nativeEvent: any).changedTouches !== undefined) { eventPointerType = 'touch'; @@ -630,6 +626,14 @@ function validateResponderContext(): void { ); } +function isFiberSuspenseAndTimedOut(fiber: Fiber): boolean { + return fiber.tag === SuspenseComponent && fiber.memoizedState !== null; +} + +function getSuspenseFallbackChild(fiber: Fiber): Fiber | null { + return ((((fiber.child: any): Fiber).sibling: any): Fiber).child; +} + export function dispatchEventForResponderEventSystem( topLevelType: string, targetFiber: null | Fiber, 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 80f3a0a492..6eaaec4505 100644 --- a/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js @@ -14,6 +14,7 @@ let ReactFeatureFlags; let ReactDOM; let ReactDOMServer; let ReactTestRenderer; +let Scheduler; // FIXME: What should the public API be for setting an event's priority? Right // now it's an enum but is that what we want? Hard coding this for now. @@ -72,6 +73,7 @@ describe('DOMEventResponderSystem', () => { React = require('react'); ReactDOM = require('react-dom'); ReactDOMServer = require('react-dom/server'); + Scheduler = require('scheduler'); container = document.createElement('div'); document.body.appendChild(container); }); @@ -811,8 +813,8 @@ describe('DOMEventResponderSystem', () => { it('the event responder system should warn on accessing invalid properties', () => { const TestResponder = createEventResponder({ - rootEventTypes: ['click'], - onRootEvent: (event, context, props) => { + targetEventTypes: ['click'], + onEvent: (event, context, props) => { const syntheticEvent = { target: event.target, type: 'click', @@ -823,19 +825,24 @@ describe('DOMEventResponderSystem', () => { }); let handler; + let buttonRef = React.createRef(); const Test = () => { const listener = React.unstable_useResponder(TestResponder, { onClick: handler, }); - return ; + return ( + + ); }; expect(() => { handler = event => { event.preventDefault(); }; ReactDOM.render(, container); - dispatchClickEvent(document.body); + dispatchClickEvent(buttonRef.current); }).toWarnDev( 'Warning: preventDefault() is not available on event objects created from event responder modules ' + '(React Flare).' + @@ -847,7 +854,7 @@ describe('DOMEventResponderSystem', () => { event.stopPropagation(); }; ReactDOM.render(, container); - dispatchClickEvent(document.body); + dispatchClickEvent(buttonRef.current); }).toWarnDev( 'Warning: stopPropagation() is not available on event objects created from event responder modules ' + '(React Flare).' + @@ -859,7 +866,7 @@ describe('DOMEventResponderSystem', () => { event.isDefaultPrevented(); }; ReactDOM.render(, container); - dispatchClickEvent(document.body); + dispatchClickEvent(buttonRef.current); }).toWarnDev( 'Warning: isDefaultPrevented() is not available on event objects created from event responder modules ' + '(React Flare).' + @@ -871,7 +878,7 @@ describe('DOMEventResponderSystem', () => { event.isPropagationStopped(); }; ReactDOM.render(, container); - dispatchClickEvent(document.body); + dispatchClickEvent(buttonRef.current); }).toWarnDev( 'Warning: isPropagationStopped() is not available on event objects created from event responder modules ' + '(React Flare).' + @@ -883,7 +890,7 @@ describe('DOMEventResponderSystem', () => { return event.nativeEvent; }; ReactDOM.render(, container); - dispatchClickEvent(document.body); + dispatchClickEvent(buttonRef.current); }).toWarnDev( 'Warning: nativeEvent is not available on event objects created from event responder modules ' + '(React Flare).' + @@ -934,4 +941,57 @@ describe('DOMEventResponderSystem', () => { ReactDOM.render(, container); buttonRef.current.dispatchEvent(createEvent('foobar')); }); + + it('should work with concurrent mode updates', async () => { + const log = []; + const TestResponder = createEventResponder({ + targetEventTypes: ['click'], + onEvent(event, context, props) { + log.push(props); + }, + }); + const ref = React.createRef(); + + function Test({counter}) { + const listener = React.unstable_useResponder(TestResponder, {counter}); + + return ( + + ); + } + + let root = ReactDOM.unstable_createRoot(container); + let batch = root.createBatch(); + batch.render(); + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + batch.commit(); + + // Click the button + dispatchClickEvent(ref.current); + expect(log).toEqual([{counter: 0}]); + + // Clear log + log.length = 0; + + // Increase counter + batch = root.createBatch(); + batch.render(); + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + // Click the button again + dispatchClickEvent(ref.current); + expect(log).toEqual([{counter: 0}]); + + // Clear log + log.length = 0; + + // Commit + batch.commit(); + dispatchClickEvent(ref.current); + expect(log).toEqual([{counter: 1}]); + }); }); diff --git a/packages/react-events/docs/ContextMenu.md b/packages/react-events/docs/ContextMenu.md new file mode 100644 index 0000000000..33b95c452b --- /dev/null +++ b/packages/react-events/docs/ContextMenu.md @@ -0,0 +1,54 @@ +# ContextMenu + +The `useContextMenu` hooks responds to context-menu events. + +```js +// Example +const Button = (props) => { + const contextmenu = useContextMenu({ + disabled, + onContextMenu, + preventDefault + }); + + return ( +
+ {props.children} +
+ ); +}; +``` + +## Types + +```js +type ContextMenuEvent = { + altKey: boolean, + buttons: 0 | 1 | 2, + ctrlKey: boolean, + metaKey: boolean, + pageX: number, + pageY: number, + pointerType: PointerType, + shiftKey: boolean, + target: Element, + timeStamp: number, + type: 'contextmenu', + x: number, + y: number, +} +``` + +## Props + +### disabled: boolean = false + +Disables the responder. + +### onContextMenu: (e: ContextMenuEvent) => void + +Called when the user performs a gesture to display a context menu. + +### preventDefault: boolean = true + +Prevents the native behavior (i.e., context menu). diff --git a/packages/react-events/docs/Focus.md b/packages/react-events/docs/Focus.md index 1fef78e370..47afc87240 100644 --- a/packages/react-events/docs/Focus.md +++ b/packages/react-events/docs/Focus.md @@ -1,29 +1,29 @@ # Focus -The `Focus` module responds to focus and blur events on its child. Focus events +The `useFocus` hook responds to focus and blur events on its child. Focus events are dispatched for all input types, with the exception of `onFocusVisibleChange` which is only dispatched when focusing with a keyboard. -Focus events do not propagate between `Focus` event responders. +Focus events do not propagate between `useFocus` event responders. ```js // Example const Button = (props) => { - const [ focusVisible, setFocusVisible ] = useState(false); + const [ isFocusVisible, setFocusVisible ] = useState(false); + const focus = useFocus({ + onBlur={props.onBlur} + onFocus={props.onFocus} + onFocusVisibleChange={setFocusVisible} + }); return ( - -