diff --git a/CHANGELOG.md b/CHANGELOG.md index cf391ed9f135..4a13a0386347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ +## 16.10.1 (September 28, 2019) + +### React DOM + +* Fix regression in Next.js apps by allowing Suspense mismatch during hydration to silently proceed ([@sebmarkbage](https://github.com/sebmarkbage) in [#16943](https://github.com/facebook/react/pull/16943)) + +## 16.10.0 (September 27, 2019) + +### React DOM + +* Fix edge case where a hook update wasn't being memoized. ([@sebmarkbage](http://github.com/sebmarkbage) in [#16359](https://github.com/facebook/react/pull/16359)) +* Fix heuristic for determining when to hydrate, so we don't incorrectly hydrate during an update. ([@sebmarkbage](http://github.com/sebmarkbage) in [#16739](https://github.com/facebook/react/pull/16739)) +* Clear additional fiber fields during unmount to save memory. ([@trueadm](http://github.com/trueadm) in [#16807](https://github.com/facebook/react/pull/16807)) +* Fix bug with required text fields in Firefox. ([@halvves](http://github.com/halvves) in [#16578](https://github.com/facebook/react/pull/16578)) +* Prefer `Object.is` instead of inline polyfill, when available. ([@ku8ar](http://github.com/ku8ar) in [#16212](https://github.com/facebook/react/pull/16212)) +* Fix bug when mixing Suspense and error handling. ([@acdlite](http://github.com/acdlite) in [#16801](https://github.com/facebook/react/pull/16801)) + + +### Scheduler (Experimental) + +* Improve queue performance by switching its internal data structure to a min binary heap. ([@acdlite](http://github.com/acdlite) in [#16245](https://github.com/facebook/react/pull/16245)) +* Use `postMessage` loop with short intervals instead of attempting to align to frame boundaries with `requestAnimationFrame`. ([@acdlite](http://github.com/acdlite) in [#16214](https://github.com/facebook/react/pull/16214)) + +### useSubscription + +* Avoid tearing issue when a mutation happens and the previous update is still in progress. ([@bvaughn](http://github.com/bvaughn) in [#16623](https://github.com/facebook/react/pull/16623)) + ## 16.9.0 (August 8, 2019) ### React diff --git a/fixtures/dom/src/components/fixtures/mouse-events/index.js b/fixtures/dom/src/components/fixtures/mouse-events/index.js index 4c121bf09479..3624d4e837b2 100644 --- a/fixtures/dom/src/components/fixtures/mouse-events/index.js +++ b/fixtures/dom/src/components/fixtures/mouse-events/index.js @@ -1,5 +1,6 @@ import FixtureSet from '../../FixtureSet'; import MouseMovement from './mouse-movement'; +import MouseEnter from './mouse-enter'; const React = window.React; @@ -8,6 +9,7 @@ class MouseEvents extends React.Component { return ( + ); } diff --git a/fixtures/dom/src/components/fixtures/mouse-events/mouse-enter.js b/fixtures/dom/src/components/fixtures/mouse-events/mouse-enter.js new file mode 100644 index 000000000000..c0fbcbda6a43 --- /dev/null +++ b/fixtures/dom/src/components/fixtures/mouse-events/mouse-enter.js @@ -0,0 +1,73 @@ +import TestCase from '../../TestCase'; + +const React = window.React; +const ReactDOM = window.ReactDOM; + +const MouseEnter = () => { + const containerRef = React.useRef(); + + React.useEffect(function() { + const hostEl = containerRef.current; + ReactDOM.render(, hostEl, () => { + ReactDOM.render(, hostEl.childNodes[1]); + }); + }, []); + + return ( + + +
  • Mouse enter the boxes below, from different borders
  • +
    + + Mouse enter call count should equal to 1;
    + Issue{' '} + + #16763 + {' '} + should not happen. +
    +
    +
    + + ); +}; + +const MouseEnterDetect = () => { + const [log, setLog] = React.useState({}); + const firstEl = React.useRef(); + const siblingEl = React.useRef(); + + const onMouseEnter = e => { + const timeStamp = e.timeStamp; + setLog(log => { + const callCount = 1 + (log.timeStamp === timeStamp ? log.callCount : 0); + return { + timeStamp, + callCount, + }; + }); + }; + + return ( + +
    + Mouse enter call count: {log.callCount || ''} +
    +
    + + ); +}; + +export default MouseEnter; diff --git a/packages/create-subscription/package.json b/packages/create-subscription/package.json index adab23210e6a..e03ce56e283c 100644 --- a/packages/create-subscription/package.json +++ b/packages/create-subscription/package.json @@ -1,7 +1,7 @@ { "name": "create-subscription", "description": "utility for subscribing to external data sources inside React components", - "version": "16.9.0", + "version": "16.10.1", "repository": { "type": "git", "url": "https://github.com/facebook/react.git", diff --git a/packages/eslint-plugin-react-hooks/package.json b/packages/eslint-plugin-react-hooks/package.json index 293a9c44ee1a..6e7d978e2502 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": "2.0.1", + "version": "2.1.1", "repository": { "type": "git", "url": "https://github.com/facebook/react.git", diff --git a/packages/jest-react/package.json b/packages/jest-react/package.json index 55acca9f4e7b..070e704c0fef 100644 --- a/packages/jest-react/package.json +++ b/packages/jest-react/package.json @@ -1,6 +1,6 @@ { "name": "jest-react", - "version": "0.7.0", + "version": "0.8.1", "description": "Jest matchers and utilities for testing React components.", "main": "index.js", "repository": { diff --git a/packages/legacy-events/EventPluginHub.js b/packages/legacy-events/EventPluginHub.js index 1ab4bc3fa456..f5068ff39b18 100644 --- a/packages/legacy-events/EventPluginHub.js +++ b/packages/legacy-events/EventPluginHub.js @@ -132,10 +132,10 @@ export function getListener(inst: Fiber, registrationName: string) { */ function extractPluginEvents( topLevelType: TopLevelType, - eventSystemFlags: EventSystemFlags, targetInst: null | Fiber, nativeEvent: AnyNativeEvent, nativeEventTarget: EventTarget, + eventSystemFlags: EventSystemFlags, ): Array | ReactSyntheticEvent | null { let events = null; for (let i = 0; i < plugins.length; i++) { @@ -144,10 +144,10 @@ function extractPluginEvents( if (possiblePlugin) { const extractedEvents = possiblePlugin.extractEvents( topLevelType, - eventSystemFlags, targetInst, nativeEvent, nativeEventTarget, + eventSystemFlags, ); if (extractedEvents) { events = accumulateInto(events, extractedEvents); @@ -159,17 +159,17 @@ function extractPluginEvents( export function runExtractedPluginEventsInBatch( topLevelType: TopLevelType, - eventSystemFlags: EventSystemFlags, targetInst: null | Fiber, nativeEvent: AnyNativeEvent, nativeEventTarget: EventTarget, + eventSystemFlags: EventSystemFlags, ) { const events = extractPluginEvents( topLevelType, - eventSystemFlags, targetInst, nativeEvent, nativeEventTarget, + eventSystemFlags, ); runEventsInBatch(events); } diff --git a/packages/legacy-events/PluginModuleType.js b/packages/legacy-events/PluginModuleType.js index c1cf5fc6783e..d6728427e874 100644 --- a/packages/legacy-events/PluginModuleType.js +++ b/packages/legacy-events/PluginModuleType.js @@ -25,10 +25,10 @@ export type PluginModule = { eventTypes: EventTypes, extractEvents: ( topLevelType: TopLevelType, - eventSystemFlags: EventSystemFlags, targetInst: null | Fiber, nativeTarget: NativeEvent, nativeEventTarget: EventTarget, + eventSystemFlags: EventSystemFlags, ) => ?ReactSyntheticEvent, tapMoveThreshold?: number, }; diff --git a/packages/legacy-events/ResponderEventPlugin.js b/packages/legacy-events/ResponderEventPlugin.js index aea49578a397..f58323aa9a8b 100644 --- a/packages/legacy-events/ResponderEventPlugin.js +++ b/packages/legacy-events/ResponderEventPlugin.js @@ -504,10 +504,10 @@ const ResponderEventPlugin = { */ extractEvents: function( topLevelType, - eventSystemFlags, targetInst, nativeEvent, nativeEventTarget, + eventSystemFlags, ) { if (isStartish(topLevelType)) { trackedTouchCount += 1; diff --git a/packages/legacy-events/__tests__/ResponderEventPlugin-test.internal.js b/packages/legacy-events/__tests__/ResponderEventPlugin-test.internal.js index c235578b2875..abe96a380805 100644 --- a/packages/legacy-events/__tests__/ResponderEventPlugin-test.internal.js +++ b/packages/legacy-events/__tests__/ResponderEventPlugin-test.internal.js @@ -314,10 +314,10 @@ const run = function(config, hierarchyConfig, nativeEventConfig) { // Trigger the event const extractedEvents = ResponderEventPlugin.extractEvents( nativeEventConfig.topLevelType, - PLUGIN_EVENT_SYSTEM, nativeEventConfig.targetInst, nativeEventConfig.nativeEvent, nativeEventConfig.target, + PLUGIN_EVENT_SYSTEM, ); // At this point the negotiation events have been dispatched as part of the diff --git a/packages/react-art/package.json b/packages/react-art/package.json index ae2ebcb1baf9..9b1d42876aa6 100644 --- a/packages/react-art/package.json +++ b/packages/react-art/package.json @@ -1,7 +1,7 @@ { "name": "react-art", "description": "React ART is a JavaScript library for drawing vector graphics using React. It provides declarative and reactive bindings to the ART library. Using the same declarative API you can render the output to either Canvas, SVG or VML (IE8).", - "version": "16.9.0", + "version": "16.10.1", "main": "index.js", "repository": { "type": "git", @@ -27,7 +27,7 @@ "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "scheduler": "^0.15.0" + "scheduler": "^0.16.1" }, "peerDependencies": { "react": "^16.0.0" diff --git a/packages/react-devtools-core/package.json b/packages/react-devtools-core/package.json index 4e887b3802e8..9513b6fbce09 100644 --- a/packages/react-devtools-core/package.json +++ b/packages/react-devtools-core/package.json @@ -1,6 +1,6 @@ { "name": "react-devtools-core", - "version": "4.1.0", + "version": "4.1.3", "description": "Use react-devtools outside of the browser", "license": "MIT", "main": "./dist/backend.js", diff --git a/packages/react-devtools-extensions/chrome/manifest.json b/packages/react-devtools-extensions/chrome/manifest.json index 4e2336c0fc5d..f28a5db3ce12 100644 --- a/packages/react-devtools-extensions/chrome/manifest.json +++ b/packages/react-devtools-extensions/chrome/manifest.json @@ -2,8 +2,8 @@ "manifest_version": 2, "name": "React Developer Tools", "description": "Adds React debugging tools to the Chrome Developer Tools.", - "version": "4.1.0", - "version_name": "4.1.0", + "version": "4.1.3", + "version_name": "4.1.3", "minimum_chrome_version": "49", diff --git a/packages/react-devtools-extensions/firefox/manifest.json b/packages/react-devtools-extensions/firefox/manifest.json index 24b722109e99..d1f0386e1394 100644 --- a/packages/react-devtools-extensions/firefox/manifest.json +++ b/packages/react-devtools-extensions/firefox/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "React Developer Tools", "description": "Adds React debugging tools to the Firefox Developer Tools.", - "version": "4.1.0", + "version": "4.1.3", "applications": { "gecko": { diff --git a/packages/react-devtools-extensions/src/inject.js b/packages/react-devtools-extensions/src/inject.js deleted file mode 100644 index 938e2cf7eb22..000000000000 --- a/packages/react-devtools-extensions/src/inject.js +++ /dev/null @@ -1,24 +0,0 @@ -/* global chrome */ - -export default function inject(scriptName: string, done: ?Function) { - const source = ` - // the prototype stuff is in case document.createElement has been modified - (function () { - var script = document.constructor.prototype.createElement.call(document, 'script'); - script.src = "${scriptName}"; - script.charset = "utf-8"; - document.documentElement.appendChild(script); - script.parentNode.removeChild(script); - })() - `; - - chrome.devtools.inspectedWindow.eval(source, function(response, error) { - if (error) { - console.log(error); - } - - if (typeof done === 'function') { - done(); - } - }); -} diff --git a/packages/react-devtools-extensions/src/injectGlobalHook.js b/packages/react-devtools-extensions/src/injectGlobalHook.js index b56f9a299b37..ea41a6c9688f 100644 --- a/packages/react-devtools-extensions/src/injectGlobalHook.js +++ b/packages/react-devtools-extensions/src/injectGlobalHook.js @@ -24,16 +24,20 @@ let lastDetectionResult; // So instead, the hook will use postMessage() to pass message to us here. // And when this happens, we'll send a message to the "background page". window.addEventListener('message', function(evt) { - if ( - evt.source === window && - evt.data && - evt.data.source === 'react-devtools-detector' - ) { + if (evt.source !== window || !evt.data) { + return; + } + if (evt.data.source === 'react-devtools-detector') { lastDetectionResult = { hasDetectedReact: true, reactBuildType: evt.data.reactBuildType, }; chrome.runtime.sendMessage(lastDetectionResult); + } else if (evt.data.source === 'react-devtools-inject-backend') { + const script = document.createElement('script'); + script.src = chrome.runtime.getURL('build/backend.js'); + document.documentElement.appendChild(script); + script.parentNode.removeChild(script); } }); diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js index 7d786e032191..d535ba7303b4 100644 --- a/packages/react-devtools-extensions/src/main.js +++ b/packages/react-devtools-extensions/src/main.js @@ -4,7 +4,6 @@ import {createElement} from 'react'; import {unstable_createRoot as createRoot, flushSync} from 'react-dom'; import Bridge from 'react-devtools-shared/src/bridge'; import Store from 'react-devtools-shared/src/devtools/store'; -import inject from './inject'; import { createViewElementSource, getBrowserName, @@ -135,7 +134,14 @@ function createPanelIfReactLoaded() { // Initialize the backend only once the Store has been initialized. // Otherwise the Store may miss important initial tree op codes. - inject(chrome.runtime.getURL('build/backend.js')); + chrome.devtools.inspectedWindow.eval( + `window.postMessage({ source: 'react-devtools-inject-backend' }, '*');`, + function(response, evalError) { + if (evalError) { + console.error(evalError); + } + }, + ); const viewElementSourceFunction = createViewElementSource( bridge, @@ -155,7 +161,7 @@ function createPanelIfReactLoaded() { overrideTab, profilerPortalContainer, showTabBar: false, - showWelcomeToTheNewDevToolsDialog: true, + warnIfUnsupportedVersionDetected: true, store, viewElementSourceFunction, }), diff --git a/packages/react-devtools-inline/package.json b/packages/react-devtools-inline/package.json index d254a7730c46..b3782d47607e 100644 --- a/packages/react-devtools-inline/package.json +++ b/packages/react-devtools-inline/package.json @@ -1,6 +1,6 @@ { "name": "react-devtools-inline", - "version": "4.1.0", + "version": "4.1.3", "description": "Embed react-devtools within a website", "license": "MIT", "main": "./dist/backend.js", diff --git a/packages/react-devtools-shared/src/__tests__/useEditableValue-test.js b/packages/react-devtools-shared/src/__tests__/useEditableValue-test.js new file mode 100644 index 000000000000..db22d2813e40 --- /dev/null +++ b/packages/react-devtools-shared/src/__tests__/useEditableValue-test.js @@ -0,0 +1,191 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +describe('useEditableValue', () => { + let act; + let React; + let ReactDOM; + let useEditableValue; + + beforeEach(() => { + const utils = require('./utils'); + act = utils.act; + + React = require('react'); + ReactDOM = require('react-dom'); + + useEditableValue = require('../devtools/views/hooks').useEditableValue; + }); + + it('should not cause a loop with values like NaN', () => { + let state; + + function Example({value = NaN}) { + const tuple = useEditableValue(value); + state = tuple[0]; + return null; + } + + const container = document.createElement('div'); + ReactDOM.render(, container); + expect(state.editableValue).toEqual('NaN'); + expect(state.externalValue).toEqual(NaN); + expect(state.parsedValue).toEqual(NaN); + expect(state.hasPendingChanges).toBe(false); + expect(state.isValid).toBe(true); + }); + + it('should override editable state when external props are updated', () => { + let state; + + function Example({value}) { + const tuple = useEditableValue(value); + state = tuple[0]; + return null; + } + + const container = document.createElement('div'); + ReactDOM.render(, container); + expect(state.editableValue).toEqual('1'); + expect(state.externalValue).toEqual(1); + expect(state.parsedValue).toEqual(1); + expect(state.hasPendingChanges).toBe(false); + expect(state.isValid).toBe(true); + + // If there are NO pending changes, + // an update to the external prop value should override the local/pending value. + ReactDOM.render(, container); + expect(state.editableValue).toEqual('2'); + expect(state.externalValue).toEqual(2); + expect(state.parsedValue).toEqual(2); + expect(state.hasPendingChanges).toBe(false); + expect(state.isValid).toBe(true); + }); + + it('should not override editable state when external props are updated if there are pending changes', () => { + let dispatch, state; + + function Example({value}) { + const tuple = useEditableValue(value); + state = tuple[0]; + dispatch = tuple[1]; + return null; + } + + const container = document.createElement('div'); + ReactDOM.render(, container); + expect(state.editableValue).toEqual('1'); + expect(state.externalValue).toEqual(1); + expect(state.parsedValue).toEqual(1); + expect(state.hasPendingChanges).toBe(false); + expect(state.isValid).toBe(true); + + // Update (local) editable state. + act(() => + dispatch({ + type: 'UPDATE', + editableValue: '2', + externalValue: 1, + }), + ); + expect(state.editableValue).toEqual('2'); + expect(state.externalValue).toEqual(1); + expect(state.parsedValue).toEqual(2); + expect(state.hasPendingChanges).toBe(true); + expect(state.isValid).toBe(true); + + // If there ARE pending changes, + // an update to the external prop value should NOT override the local/pending value. + ReactDOM.render(, container); + expect(state.editableValue).toEqual('2'); + expect(state.externalValue).toEqual(3); + expect(state.parsedValue).toEqual(2); + expect(state.hasPendingChanges).toBe(true); + expect(state.isValid).toBe(true); + }); + + it('should parse edits to ensure valid JSON', () => { + let dispatch, state; + + function Example({value}) { + const tuple = useEditableValue(value); + state = tuple[0]; + dispatch = tuple[1]; + return null; + } + + const container = document.createElement('div'); + ReactDOM.render(, container); + expect(state.editableValue).toEqual('1'); + expect(state.externalValue).toEqual(1); + expect(state.parsedValue).toEqual(1); + expect(state.hasPendingChanges).toBe(false); + expect(state.isValid).toBe(true); + + // Update (local) editable state. + act(() => + dispatch({ + type: 'UPDATE', + editableValue: '"a', + externalValue: 1, + }), + ); + expect(state.editableValue).toEqual('"a'); + expect(state.externalValue).toEqual(1); + expect(state.parsedValue).toEqual(1); + expect(state.hasPendingChanges).toBe(true); + expect(state.isValid).toBe(false); + }); + + it('should reset to external value upon request', () => { + let dispatch, state; + + function Example({value}) { + const tuple = useEditableValue(value); + state = tuple[0]; + dispatch = tuple[1]; + return null; + } + + const container = document.createElement('div'); + ReactDOM.render(, container); + expect(state.editableValue).toEqual('1'); + expect(state.externalValue).toEqual(1); + expect(state.parsedValue).toEqual(1); + expect(state.hasPendingChanges).toBe(false); + expect(state.isValid).toBe(true); + + // Update (local) editable state. + act(() => + dispatch({ + type: 'UPDATE', + editableValue: '2', + externalValue: 1, + }), + ); + expect(state.editableValue).toEqual('2'); + expect(state.externalValue).toEqual(1); + expect(state.parsedValue).toEqual(2); + expect(state.hasPendingChanges).toBe(true); + expect(state.isValid).toBe(true); + + // Reset editable state + act(() => + dispatch({ + type: 'RESET', + externalValue: 1, + }), + ); + expect(state.editableValue).toEqual('1'); + expect(state.externalValue).toEqual(1); + expect(state.parsedValue).toEqual(1); + expect(state.hasPendingChanges).toBe(false); + expect(state.isValid).toBe(true); + }); +}); diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 3bbec8416ab3..3e73c0f750e6 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -474,6 +474,10 @@ export default class Agent extends EventEmitter<{| } }; + onUnsupportedRenderer(rendererID: number) { + this._bridge.send('unsupportedRendererVersion', rendererID); + } + _throttledPersistSelection = throttle((rendererID: number, id: number) => { // This is throttled, so both renderer and selected ID // might not be available by the time we read them. diff --git a/packages/react-devtools-shared/src/backend/index.js b/packages/react-devtools-shared/src/backend/index.js index 2c57627f3111..73e6c9f8f251 100644 --- a/packages/react-devtools-shared/src/backend/index.js +++ b/packages/react-devtools-shared/src/backend/index.js @@ -39,6 +39,10 @@ export function initBackend( }, ), + hook.sub('unsupported-renderer-version', (id: number) => { + agent.onUnsupportedRenderer(id); + }), + hook.sub('operations', agent.onHookOperations), // TODO Add additional subscriptions required for profiling mode @@ -48,23 +52,33 @@ export function initBackend( let rendererInterface = hook.rendererInterfaces.get(id); // Inject any not-yet-injected renderers (if we didn't reload-and-profile) - if (!rendererInterface) { + if (rendererInterface == null) { if (typeof renderer.findFiberByHostInstance === 'function') { + // react-reconciler v16+ rendererInterface = attach(hook, id, renderer, global); - } else { + } else if (renderer.ComponentTree) { + // react-dom v15 rendererInterface = attachLegacy(hook, id, renderer, global); + } else { + // Older react-dom or other unsupported renderer version } - hook.rendererInterfaces.set(id, rendererInterface); + if (rendererInterface != null) { + hook.rendererInterfaces.set(id, rendererInterface); + } } // Notify the DevTools frontend about new renderers. // This includes any that were attached early (via __REACT_DEVTOOLS_ATTACH__). - hook.emit('renderer-attached', { - id, - renderer, - rendererInterface, - }); + if (rendererInterface != null) { + hook.emit('renderer-attached', { + id, + renderer, + rendererInterface, + }); + } else { + hook.emit('unsupported-renderer-version', id); + } }; // Connect renderers that have already injected themselves. diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 520f583013d9..7a5268f632bd 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -82,12 +82,6 @@ type ReactSymbolsType = { CONTEXT_CONSUMER_SYMBOL_STRING: string, CONTEXT_PROVIDER_NUMBER: number, CONTEXT_PROVIDER_SYMBOL_STRING: string, - EVENT_COMPONENT_NUMBER: number, - EVENT_COMPONENT_STRING: string, - EVENT_TARGET_NUMBER: number, - EVENT_TARGET_STRING: string, - EVENT_TARGET_TOUCH_HIT_NUMBER: number, - EVENT_TARGET_TOUCH_HIT_STRING: string, FORWARD_REF_NUMBER: number, FORWARD_REF_SYMBOL_STRING: string, MEMO_NUMBER: number, @@ -99,6 +93,8 @@ type ReactSymbolsType = { SUSPENSE_NUMBER: number, SUSPENSE_SYMBOL_STRING: string, DEPRECATED_PLACEHOLDER_SYMBOL_STRING: string, + SCOPE_NUMBER: number, + SCOPE_SYMBOL_STRING: string, }; type ReactPriorityLevelsType = {| @@ -165,12 +161,6 @@ export function getInternalReactConstants( CONTEXT_CONSUMER_SYMBOL_STRING: 'Symbol(react.context)', CONTEXT_PROVIDER_NUMBER: 0xeacd, CONTEXT_PROVIDER_SYMBOL_STRING: 'Symbol(react.provider)', - EVENT_COMPONENT_NUMBER: 0xead5, - EVENT_COMPONENT_STRING: 'Symbol(react.event_component)', - EVENT_TARGET_NUMBER: 0xead6, - EVENT_TARGET_STRING: 'Symbol(react.event_target)', - EVENT_TARGET_TOUCH_HIT_NUMBER: 0xead7, - EVENT_TARGET_TOUCH_HIT_STRING: 'Symbol(react.event_target.touch_hit)', FORWARD_REF_NUMBER: 0xead0, FORWARD_REF_SYMBOL_STRING: 'Symbol(react.forward_ref)', MEMO_NUMBER: 0xead3, @@ -182,6 +172,8 @@ export function getInternalReactConstants( SUSPENSE_NUMBER: 0xead1, SUSPENSE_SYMBOL_STRING: 'Symbol(react.suspense)', DEPRECATED_PLACEHOLDER_SYMBOL_STRING: 'Symbol(react.placeholder)', + SCOPE_NUMBER: 0xead7, + SCOPE_SYMBOL_STRING: 'Symbol(react.scope)', }; const ReactTypeOfSideEffect: ReactTypeOfSideEffectType = { @@ -331,6 +323,8 @@ export function getInternalReactConstants( DEPRECATED_PLACEHOLDER_SYMBOL_STRING, PROFILER_NUMBER, PROFILER_SYMBOL_STRING, + SCOPE_NUMBER, + SCOPE_SYMBOL_STRING, } = ReactSymbols; // NOTICE Keep in sync with shouldFilterFiber() and other get*ForFiber methods @@ -410,6 +404,9 @@ export function getInternalReactConstants( case PROFILER_NUMBER: case PROFILER_SYMBOL_STRING: return `Profiler(${fiber.memoizedProps.id})`; + case SCOPE_NUMBER: + case SCOPE_SYMBOL_STRING: + return 'Scope'; default: // Unknown element type. // This may mean a new element type that has not yet been added to DevTools. diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index 245817b5c660..bf6a4c728671 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -81,7 +81,10 @@ export type ReactRenderer = { // Enables DevTools to append owners-only component stack to error messages. getCurrentFiber?: () => Fiber | null, - // <= 15 + // Uniquely identifies React DOM v15. + ComponentTree?: any, + + // Present for React DOM v12 (possibly earlier) through v15. Mount?: any, }; diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index 4d88747992f6..695121d08c25 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -83,6 +83,7 @@ type BackendEvents = {| stopInspectingNative: [boolean], syncSelectionFromNativeElementsPanel: [], syncSelectionToNativeElementsPanel: [], + unsupportedRendererVersion: [RendererID], // React Native style editor plug-in. isNativeStyleEditorSupported: [ diff --git a/packages/react-devtools-shared/src/constants.js b/packages/react-devtools-shared/src/constants.js index 00956d28ff8f..d0f0a89807f9 100644 --- a/packages/react-devtools-shared/src/constants.js +++ b/packages/react-devtools-shared/src/constants.js @@ -35,6 +35,9 @@ export const PROFILER_EXPORT_VERSION = 4; export const CHANGE_LOG_URL = 'https://github.com/facebook/react/blob/master/packages/react-devtools/CHANGELOG.md'; +export const UNSUPPORTED_VERSION_URL = + 'https://reactjs.org/blog/2019/08/15/new-react-devtools.html#how-do-i-get-the-old-version-back'; + // HACK // // Extracting during build time avoids a temporarily invalid state for the inline target. diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 68264ddeb464..efbcf0219688 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -73,6 +73,7 @@ export default class Store extends EventEmitter<{| supportsNativeStyleEditor: [], supportsProfiling: [], supportsReloadAndProfile: [], + unsupportedRendererVersionDetected: [], |}> { _bridge: FrontendBridge; @@ -125,6 +126,8 @@ export default class Store extends EventEmitter<{| _supportsProfiling: boolean = false; _supportsReloadAndProfile: boolean = false; + _unsupportedRendererVersionDetected: boolean = false; + // Total number of visible elements (within all roots). // Used for windowing purposes. _weightAcrossRoots: number = 0; @@ -179,6 +182,10 @@ export default class Store extends EventEmitter<{| 'isNativeStyleEditorSupported', this.onBridgeNativeStyleEditorSupported, ); + bridge.addListener( + 'unsupportedRendererVersion', + this.onBridgeUnsupportedRendererVersion, + ); this._profilerStore = new ProfilerStore(bridge, this, isProfiling); } @@ -337,6 +344,10 @@ export default class Store extends EventEmitter<{| return this._supportsReloadAndProfile && this._isBackendStorageAPISupported; } + get unsupportedRendererVersionDetected(): boolean { + return this._unsupportedRendererVersionDetected; + } + containsElement(id: number): boolean { return this._idToElement.get(id) != null; } @@ -1009,4 +1020,10 @@ export default class Store extends EventEmitter<{| this.emit('supportsReloadAndProfile'); }; + + onBridgeUnsupportedRendererVersion = () => { + this._unsupportedRendererVersionDetected = true; + + this.emit('unsupportedRendererVersionDetected'); + }; } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js b/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js index a0429bf8d37d..992f2b2dff65 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js @@ -7,7 +7,7 @@ * @flow */ -import React, {Fragment, useCallback, useRef} from 'react'; +import React, {Fragment, useRef} from 'react'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; import styles from './EditableValue.css'; @@ -17,51 +17,51 @@ type OverrideValueFn = (path: Array, value: any) => void; type EditableValueProps = {| className?: string, - initialValue: any, overrideValueFn: OverrideValueFn, path: Array, + value: any, |}; export default function EditableValue({ className = '', - initialValue, overrideValueFn, path, + value, }: EditableValueProps) { const inputRef = useRef(null); - const { - editableValue, - hasPendingChanges, - isValid, - parsedValue, - reset, - update, - } = useEditableValue(initialValue); + const [state, dispatch] = useEditableValue(value); + const {editableValue, hasPendingChanges, isValid, parsedValue} = state; - const handleChange = useCallback(({target}) => update(target.value), [ - update, - ]); + const reset = () => + dispatch({ + type: 'RESET', + externalValue: value, + }); - const handleKeyDown = useCallback( - event => { - // Prevent keydown events from e.g. change selected element in the tree - event.stopPropagation(); + const handleChange = ({target}) => + dispatch({ + type: 'UPDATE', + editableValue: target.value, + externalValue: value, + }); - switch (event.key) { - case 'Enter': - if (isValid && hasPendingChanges) { - overrideValueFn(path, parsedValue); - } - break; - case 'Escape': - reset(); - break; - default: - break; - } - }, - [hasPendingChanges, isValid, overrideValueFn, parsedValue, reset], - ); + const handleKeyDown = event => { + // Prevent keydown events from e.g. change selected element in the tree + event.stopPropagation(); + + switch (event.key) { + case 'Enter': + if (isValid && hasPendingChanges) { + overrideValueFn(path, parsedValue); + } + break; + case 'Escape': + reset(); + break; + default: + break; + } + }; let placeholder = ''; if (editableValue === undefined) { diff --git a/packages/react-devtools-shared/src/devtools/views/Components/HooksTree.js b/packages/react-devtools-shared/src/devtools/views/Components/HooksTree.js index ce0be7c47a1e..b02e8faa95f3 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/HooksTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/HooksTree.js @@ -270,9 +270,9 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) { {typeof overrideValueFn === 'function' ? ( ) : ( // $FlowFixMe Cannot create span element because in property children diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js index 9c81c3f952fd..7a5dbae57155 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js @@ -105,9 +105,9 @@ export default function InspectedElementTree({ : 
    )} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js b/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js index 9e82eefa426f..76b1a2e57b89 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js @@ -102,9 +102,9 @@ export default function KeyValue({ {isEditable ? ( ) : ( {displayValue} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js index f3f3c1994807..54738bf3d4b7 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js @@ -619,6 +619,7 @@ type Props = {| children: React$Node, // Used for automated testing + defaultInspectedElementID?: ?number, defaultOwnerID?: ?number, defaultSelectedElementID?: ?number, defaultSelectedElementIndex?: ?number, @@ -627,6 +628,7 @@ type Props = {| // TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists. function TreeContextController({ children, + defaultInspectedElementID, defaultOwnerID, defaultSelectedElementID, defaultSelectedElementIndex, @@ -700,7 +702,8 @@ function TreeContextController({ ownerFlatTree: null, // Inspection element panel - inspectedElementID: null, + inspectedElementID: + defaultInspectedElementID == null ? null : defaultInspectedElementID, }); const dispatchWrapper = useCallback( diff --git a/packages/react-devtools-shared/src/devtools/views/DevTools.js b/packages/react-devtools-shared/src/devtools/views/DevTools.js index 89b275cffd83..229a750adb7b 100644 --- a/packages/react-devtools-shared/src/devtools/views/DevTools.js +++ b/packages/react-devtools-shared/src/devtools/views/DevTools.js @@ -24,6 +24,7 @@ import ViewElementSourceContext from './Components/ViewElementSourceContext'; import {ProfilerContextController} from './Profiler/ProfilerContext'; import {ModalDialogContextController} from './ModalDialog'; import ReactLogo from './ReactLogo'; +import UnsupportedVersionDialog from './UnsupportedVersionDialog'; import WarnIfLegacyBackendDetected from './WarnIfLegacyBackendDetected'; import styles from './DevTools.css'; @@ -51,6 +52,7 @@ export type Props = {| showTabBar?: boolean, store: Store, warnIfLegacyBackendDetected?: boolean, + warnIfUnsupportedVersionDetected?: boolean, viewElementSourceFunction?: ?ViewElementSource, // This property is used only by the web extension target. @@ -92,6 +94,7 @@ export default function DevTools({ showTabBar = false, store, warnIfLegacyBackendDetected = false, + warnIfUnsupportedVersionDetected = false, viewElementSourceFunction, }: Props) { const [tab, setTab] = useState(defaultTab); @@ -164,6 +167,7 @@ export default function DevTools({ {warnIfLegacyBackendDetected && } + {warnIfUnsupportedVersionDetected && } diff --git a/packages/react-devtools-shared/src/devtools/views/UnsupportedVersionDialog.css b/packages/react-devtools-shared/src/devtools/views/UnsupportedVersionDialog.css new file mode 100644 index 000000000000..5c183f1fe60f --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/UnsupportedVersionDialog.css @@ -0,0 +1,20 @@ +.Row { + display: flex; + flex-direction: row; + align-items: center; +} + +.Column { + display: flex; + flex-direction: column; + align-items: center; +} + +.Title { + font-size: var(--font-size-sans-large); + margin-bottom: 0.5rem; +} + +.ReleaseNotesLink { + color: var(--color-button-active); +} \ No newline at end of file diff --git a/packages/react-devtools-shared/src/devtools/views/UnsupportedVersionDialog.js b/packages/react-devtools-shared/src/devtools/views/UnsupportedVersionDialog.js new file mode 100644 index 000000000000..652c4904c224 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/UnsupportedVersionDialog.js @@ -0,0 +1,83 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import React, {Fragment, useContext, useEffect, useState} from 'react'; +import {unstable_batchedUpdates as batchedUpdates} from 'react-dom'; +import {ModalDialogContext} from './ModalDialog'; +import {StoreContext} from './context'; +import {UNSUPPORTED_VERSION_URL} from 'react-devtools-shared/src/constants'; + +import styles from './UnsupportedVersionDialog.css'; + +type DAILOG_STATE = 'dialog-not-shown' | 'show-dialog' | 'dialog-shown'; + +export default function UnsupportedVersionDialog(_: {||}) { + const {dispatch} = useContext(ModalDialogContext); + const store = useContext(StoreContext); + const [state, setState] = useState('dialog-not-shown'); + + useEffect( + () => { + if (state === 'dialog-not-shown') { + const showDialog = () => { + batchedUpdates(() => { + setState('show-dialog'); + dispatch({ + canBeDismissed: true, + type: 'SHOW', + content: , + }); + }); + }; + + if (store.unsupportedRendererVersionDetected) { + showDialog(); + } else { + store.addListener('unsupportedRendererVersionDetected', showDialog); + return () => { + store.removeListener( + 'unsupportedRendererVersionDetected', + showDialog, + ); + }; + } + } + }, + [state, store], + ); + + return null; +} + +function DialogContent(_: {||}) { + return ( + +
    +
    +
    Unsupported React version detected
    +

    + This version of React DevTools supports React DOM v15+ and React + Native v61+. +

    +

    + In order to use DevTools with an older version of React, you'll need + to{' '} + + install an older version of the extension + . +

    +
    +
    +
    + ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/hooks.js b/packages/react-devtools-shared/src/devtools/views/hooks.js index 57ebb69f6e01..693f72535bbe 100644 --- a/packages/react-devtools-shared/src/devtools/views/hooks.js +++ b/packages/react-devtools-shared/src/devtools/views/hooks.js @@ -8,68 +8,101 @@ */ import throttle from 'lodash.throttle'; -import {useCallback, useEffect, useLayoutEffect, useState} from 'react'; -import {unstable_batchedUpdates as batchedUpdates} from 'react-dom'; +import { + useCallback, + useEffect, + useLayoutEffect, + useReducer, + useState, +} from 'react'; import { localStorageGetItem, localStorageSetItem, } from 'react-devtools-shared/src/storage'; import {sanitizeForParse, smartParse, smartStringify} from '../utils'; -type EditableValue = {| +type ACTION_RESET = {| + type: 'RESET', + externalValue: any, +|}; +type ACTION_UPDATE = {| + type: 'UPDATE', + editableValue: any, + externalValue: any, +|}; + +type UseEditableValueAction = ACTION_RESET | ACTION_UPDATE; +type UseEditableValueDispatch = (action: UseEditableValueAction) => void; +type UseEditableValueState = {| editableValue: any, + externalValue: any, hasPendingChanges: boolean, isValid: boolean, parsedValue: any, - reset: () => void, - update: (newValue: any) => void, |}; +function useEditableValueReducer(state, action) { + switch (action.type) { + case 'RESET': + return { + ...state, + editableValue: smartStringify(action.externalValue), + externalValue: action.externalValue, + hasPendingChanges: false, + isValid: true, + parsedValue: action.externalValue, + }; + case 'UPDATE': + let isNewValueValid = false; + let newParsedValue; + try { + newParsedValue = smartParse(action.editableValue); + isNewValueValid = true; + } catch (error) {} + return { + ...state, + editableValue: sanitizeForParse(action.editableValue), + externalValue: action.externalValue, + hasPendingChanges: + smartStringify(action.externalValue) !== action.editableValue, + isValid: isNewValueValid, + parsedValue: isNewValueValid ? newParsedValue : state.parsedValue, + }; + default: + throw new Error(`Invalid action "${action.type}"`); + } +} + // Convenience hook for working with an editable value that is validated via JSON.parse. export function useEditableValue( - initialValue: any, - initialIsValid?: boolean = true, -): EditableValue { - const [editableValue, setEditableValue] = useState(() => - smartStringify(initialValue), - ); - const [parsedValue, setParsedValue] = useState(initialValue); - const [isValid, setIsValid] = useState(initialIsValid); - - const reset = useCallback( - () => { - setEditableValue(smartStringify(initialValue)); - setParsedValue(initialValue); - setIsValid(initialIsValid); - }, - [initialValue, initialIsValid], - ); + externalValue: any, +): [UseEditableValueState, UseEditableValueDispatch] { + const [state, dispatch] = useReducer< + UseEditableValueState, + UseEditableValueAction, + >(useEditableValueReducer, { + editableValue: smartStringify(externalValue), + externalValue, + hasPendingChanges: false, + isValid: true, + parsedValue: externalValue, + }); + if (!Object.is(state.externalValue, externalValue)) { + if (!state.hasPendingChanges) { + dispatch({ + type: 'RESET', + externalValue, + }); + } else { + dispatch({ + type: 'UPDATE', + editableValue: state.editableValue, + externalValue, + }); + } + } - const update = useCallback(newValue => { - let isNewValueValid = false; - let newParsedValue; - try { - newParsedValue = smartParse(newValue); - isNewValueValid = true; - } catch (error) {} - - batchedUpdates(() => { - setEditableValue(sanitizeForParse(newValue)); - if (isNewValueValid) { - setParsedValue(newParsedValue); - } - setIsValid(isNewValueValid); - }); - }, []); - - return { - editableValue, - hasPendingChanges: smartStringify(initialValue) !== editableValue, - isValid, - parsedValue, - reset, - update, - }; + return [state, dispatch]; } export function useIsOverflowing( diff --git a/packages/react-devtools-shell/src/app/ReactNativeWeb/index.js b/packages/react-devtools-shell/src/app/ReactNativeWeb/index.js index 9f8246458e7b..95961378f00d 100644 --- a/packages/react-devtools-shell/src/app/ReactNativeWeb/index.js +++ b/packages/react-devtools-shell/src/app/ReactNativeWeb/index.js @@ -11,7 +11,7 @@ import React, {Fragment, useState} from 'react'; import {Button, Text, View} from 'react-native-web'; export default function ReactNativeWeb() { - const [backgroundColor, setBackgroundColor] = useState('blue'); + const [backgroundColor, setBackgroundColor] = useState('purple'); const toggleColor = () => setBackgroundColor(backgroundColor === 'purple' ? 'green' : 'purple'); return ( @@ -29,8 +29,8 @@ export default function ReactNativeWeb() { left
    + ); + }; + ReactDOM.render(, container); + dispatchClickEvent(buttonRef.current); + document.body.removeChild(domNode); + expect(onEvent).not.toBeCalled(); + }); + + it('should propagate target events through portals when enabled', () => { + const buttonRef = React.createRef(); + const onEvent = jest.fn(); + const TestResponder = createEventResponder({ + targetPortalPropagation: true, + targetEventTypes: ['click'], + onEvent, + }); + const domNode = document.createElement('div'); + document.body.appendChild(domNode); + const Component = () => { + const listener = React.unstable_useResponder(TestResponder, {}); + return ( +
    + {ReactDOM.createPortal(
    + ); + }; + ReactDOM.render(, container); + dispatchClickEvent(buttonRef.current); + document.body.removeChild(domNode); + expect(onEvent).toBeCalled(); + }); }); diff --git a/packages/react-dom/src/events/__tests__/EnterLeaveEventPlugin-test.js b/packages/react-dom/src/events/__tests__/EnterLeaveEventPlugin-test.js index 33dd3e964ee4..b8bda1c67fe9 100644 --- a/packages/react-dom/src/events/__tests__/EnterLeaveEventPlugin-test.js +++ b/packages/react-dom/src/events/__tests__/EnterLeaveEventPlugin-test.js @@ -134,4 +134,55 @@ describe('EnterLeaveEventPlugin', () => { expect(childEnterCalls).toBe(1); expect(parentEnterCalls).toBe(0); }); + + // Test for https://github.com/facebook/react/issues/16763. + it('should call mouseEnter once from sibling rendered inside a rendered component', done => { + const mockFn = jest.fn(); + + class Parent extends React.Component { + constructor(props) { + super(props); + this.parentEl = React.createRef(); + } + + componentDidMount() { + ReactDOM.render(, this.parentEl.current); + } + + render() { + return
    ; + } + } + + class MouseEnterDetect extends React.Component { + constructor(props) { + super(props); + this.firstEl = React.createRef(); + this.siblingEl = React.createRef(); + } + + componentDidMount() { + this.siblingEl.current.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: this.firstEl.current, + }), + ); + expect(mockFn.mock.calls.length).toBe(1); + done(); + } + + render() { + return ( + +
    +
    + + ); + } + } + + ReactDOM.render(, container); + }); }); diff --git a/packages/react-dom/src/server/ReactPartialRenderer.js b/packages/react-dom/src/server/ReactPartialRenderer.js index 3b9655134c1b..d777a398ec15 100644 --- a/packages/react-dom/src/server/ReactPartialRenderer.js +++ b/packages/react-dom/src/server/ReactPartialRenderer.js @@ -15,7 +15,7 @@ import type {ReactProvider, ReactContext} from 'shared/ReactTypes'; import React from 'react'; import invariant from 'shared/invariant'; import getComponentName from 'shared/getComponentName'; -import lowPriorityWarning from 'shared/lowPriorityWarning'; +import lowPriorityWarningWithoutStack from 'shared/lowPriorityWarningWithoutStack'; import warning from 'shared/warning'; import warningWithoutStack from 'shared/warningWithoutStack'; import describeComponentFrame from 'shared/describeComponentFrame'; @@ -588,7 +588,7 @@ function resolve( const componentName = getComponentName(Component) || 'Unknown'; if (!didWarnAboutDeprecatedWillMount[componentName]) { - lowPriorityWarning( + lowPriorityWarningWithoutStack( false, // keep this warning in sync with ReactStrictModeWarning.js 'componentWillMount has been renamed, and is not recommended for use. ' + diff --git a/packages/react-dom/src/test-utils/ReactTestUtils.js b/packages/react-dom/src/test-utils/ReactTestUtils.js index 947a0d0bf5e4..58bfb04f8a57 100644 --- a/packages/react-dom/src/test-utils/ReactTestUtils.js +++ b/packages/react-dom/src/test-utils/ReactTestUtils.js @@ -17,7 +17,7 @@ import { } from 'shared/ReactWorkTags'; import SyntheticEvent from 'legacy-events/SyntheticEvent'; import invariant from 'shared/invariant'; -import lowPriorityWarning from 'shared/lowPriorityWarning'; +import lowPriorityWarningWithoutStack from 'shared/lowPriorityWarningWithoutStack'; import {ELEMENT_NODE} from '../shared/HTMLNodeType'; import * as DOMTopLevelEventTypes from '../events/DOMTopLevelEventTypes'; import {PLUGIN_EVENT_SYSTEM} from 'legacy-events/EventSystemFlags'; @@ -361,7 +361,7 @@ const ReactTestUtils = { mockComponent: function(module, mockTagName) { if (!hasWarnedAboutDeprecatedMockComponent) { hasWarnedAboutDeprecatedMockComponent = true; - lowPriorityWarning( + lowPriorityWarningWithoutStack( false, 'ReactTestUtils.mockComponent() is deprecated. ' + 'Use shallow rendering or jest.mock() instead.\n\n' + diff --git a/packages/react-interactions/accessibility/README.md b/packages/react-interactions/accessibility/README.md new file mode 100644 index 000000000000..9e910b18778f --- /dev/null +++ b/packages/react-interactions/accessibility/README.md @@ -0,0 +1,78 @@ +# `react-interactions/accessibility` + +*This package is experimental. It is intended for use with the experimental React +Scope API that is not available in open source builds.* + +Scopes allow for querying of the internal React sub-tree to collect handles to +host nodes. Scopes also have their own separate tree structure that allows +traversal of scopes of the same type. + +The core API is documented below. Documentation for individual Accessibility Components +can be found [here](./docs). + +## React Scopes + +Note: React Scopes require the internal React flag `enableScopeAPI`. + +When creating a scope, a query function is required. The query function is used +when collecting host nodes that match the criteria of the query function. + +```jsx +// This query function only matches host nodes that have the type of "div" +const queryFunction = (type: string, props: Object): boolean => { + if (type === 'div') { + return true; + } + return false; +}; + +// Create the scope with the queryFunction above +const DivOnlyScope = React.unstable_createScope(queryFunction); + +// We can now use this in our components. We need to attach +// a ref so we can get the matching host nodes. +function MyComponent(props) { + const divOnlyScope = useRef(null); + return ( + +
    DIV 1
    +
    DIV 2
    +
    DIV 3
    +
    + ); +} + +// Using the ref, we can get the host nodes via getScopedNodes() +const divs = divOnlyScope.current.getScopedNodes(); + +// [
    DIV 1
    ,
    DIV 2
    ,
    DIV 3
    ] +console.log(divs); +``` + +## React Scope Interface + +Scopes require a `ref` to access the internal interface of a particular scope. +The internal interface (`ReactScopeInterface`) exposes the following scope API: + +### getChildren: () => null | Array + +Returns an array of all child `ReactScopeInterface` nodes that are +of scopes of the same type. Returns `null` if there are no child scope nodes. + +### getChildrenFromRoot: () => null | Array + +Similar to `getChildren`, except this applies the same traversal from the root of the +React internal tree instead of from the scope node position. + +### getParent: () => null | ReactScopeInterface + +Returns the parent `ReactScopeInterface` of the scope node or `null` if none exists. + +### getProps: () => Object + +Returns the current `props` object of the scope node. + +### getScopedNodes: () => null | Array + +Returns an array of all child host nodes that successfully match when queried using the +query function passed to the scope. Returns `null` if there are no matching host nodes. \ No newline at end of file diff --git a/packages/react-interactions/accessibility/docs/FocusControl.md b/packages/react-interactions/accessibility/docs/FocusControl.md new file mode 100644 index 000000000000..1088e64ecb48 --- /dev/null +++ b/packages/react-interactions/accessibility/docs/FocusControl.md @@ -0,0 +1,60 @@ +# FocusControl + +`FocusControl` is a module that exports a selection of helpful utility functions to be used +in conjunction with the `ref` from a React Scope, such as `TabbableScope`. +A ref from `FocusManager` can also be used instead. + +## Example + +```jsx +const { + focusFirst, + focusNext, + focusPrevious, + getNextScope, + getPreviousScope, +} = FocusControl; + +function KeyboardFocusMover(props) { + const scopeRef = useRef(null); + + useEffect(() => { + const scope = scopeRef.current; + + if (scope) { + // Focus the first tabbable DOM node in my children + focusFirst(scope); + // Then focus the next chilkd + focusNext(scope); + } + }); + + return ( + + {props.children} + + ); +} +``` + +## FocusControl API + +### `focusFirst` + +Focus the first node that matches the given scope. + +### `focusNext` + +Focus the next sequential node that matchs the given scope. + +### `focusPrevious` + +Focus the previous sequential node that matchs the given scope. + +### `getNextScope` + +Focus the first node that matches the next sibling scope from the given scope. + +### `getPreviousScope` + +Focus the first node that matches the previous sibling scope from the given scope. \ No newline at end of file diff --git a/packages/react-interactions/accessibility/docs/FocusManager.md b/packages/react-interactions/accessibility/docs/FocusManager.md new file mode 100644 index 000000000000..9a1b48099567 --- /dev/null +++ b/packages/react-interactions/accessibility/docs/FocusManager.md @@ -0,0 +1,39 @@ +# FocusManager + +`FocusManager` is a component that is designed to provide basic focus management +control. These are the various props that `FocusManager` accepts: + +## Usage + +```jsx +function MyDialog(props) { + return ( + +
    +

    {props.title}

    +

    {props.text}

    + + +

    +
    + ) +} +``` + +### `scope` +`FocusManager` accepts a custom `ReactScope`. If a custom one is not supplied, `FocusManager` +will default to using `TabbableScope`. + +### `autoFocus` +When enabled, the first host node that matches the `FocusManager` scope will be focused +upon the `FocusManager` mounting. + +### `restoreFocus` +When enabled, the previous host node that was focused as `FocusManager` is mounted, +has its focus restored upon `FocusManager` unmounting. + +### `containFocus` +This contains the user focus to only that of `FocusManager`s sub-tree. Tabbing or +interacting with nodes outside the sub-tree will restore focus back into the `FocusManager`. +This is useful for modals, dialogs, dropdowns and other UI elements that require +a form of user-focus control that is similar to the `inert` property on the web. \ No newline at end of file diff --git a/packages/react-interactions/accessibility/docs/TabbableScope.md b/packages/react-interactions/accessibility/docs/TabbableScope.md new file mode 100644 index 000000000000..a975fdb2e7ac --- /dev/null +++ b/packages/react-interactions/accessibility/docs/TabbableScope.md @@ -0,0 +1,35 @@ +# TabbableScope + +`TabbableScope` is a custom scope implementation that can be used with +`FocusManager`, `FocusList`, `FocusTable` and `FocusControl` modules. + +## Usage + +```jsx +function FocusableNodeCollector(props) { + const scopeRef = useRef(null); + + useEffect(() => { + const scope = scopeRef.current; + + if (scope) { + const tabFocusableNodes = scope.getScopedNodes(); + if (tabFocusableNodes && props.onFocusableNodes) { + props.onFocusableNodes(tabFocusableNodes); + } + } + }); + + return ( + + {props.children} + + ); +} +``` + +## Implementation + +`TabbableScope` uses the experimental `React.unstable_createScope` API. The query +function used for the scope is designed to collect DOM nodes that are tab focusable +to the browser. See the [implementation](../src/TabbableScope.js#L12-L33) here. diff --git a/packages/react-interactions/accessibility/tab-focus.js b/packages/react-interactions/accessibility/focus-list.js similarity index 82% rename from packages/react-interactions/accessibility/tab-focus.js rename to packages/react-interactions/accessibility/focus-list.js index 493333ccad3f..3511cf818ed9 100644 --- a/packages/react-interactions/accessibility/tab-focus.js +++ b/packages/react-interactions/accessibility/focus-list.js @@ -9,4 +9,4 @@ 'use strict'; -module.exports = require('./src/TabFocus'); +module.exports = require('./src/FocusList'); diff --git a/packages/react-interactions/accessibility/focus-manager.js b/packages/react-interactions/accessibility/focus-manager.js new file mode 100644 index 000000000000..56d396157c22 --- /dev/null +++ b/packages/react-interactions/accessibility/focus-manager.js @@ -0,0 +1,12 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +'use strict'; + +module.exports = require('./src/FocusManager'); diff --git a/packages/react-interactions/accessibility/src/FocusControl.js b/packages/react-interactions/accessibility/src/FocusControl.js index f3fcabf7d027..27939d956d0c 100644 --- a/packages/react-interactions/accessibility/src/FocusControl.js +++ b/packages/react-interactions/accessibility/src/FocusControl.js @@ -110,7 +110,7 @@ export function focusPrevious( } } -export function getNextController( +export function getNextScope( scope: ReactScopeMethods, ): null | ReactScopeMethods { const allScopes = scope.getChildrenFromRoot(); @@ -124,7 +124,7 @@ export function getNextController( return allScopes[currentScopeIndex + 1]; } -export function getPreviousController( +export function getPreviousScope( scope: ReactScopeMethods, ): null | ReactScopeMethods { const allScopes = scope.getChildrenFromRoot(); @@ -137,3 +137,42 @@ export function getPreviousController( } return allScopes[currentScopeIndex - 1]; } + +const tabIndexDesc = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'tabIndex', +); +const tabIndexSetter = (tabIndexDesc: any).set; + +export function setElementCanTab(elem: HTMLElement, canTab: boolean): void { + let tabIndexState = (elem: any)._tabIndexState; + if (!tabIndexState) { + tabIndexState = { + value: elem.tabIndex, + canTab, + }; + (elem: any)._tabIndexState = tabIndexState; + if (!canTab) { + elem.tabIndex = -1; + } + // We track the tabIndex value so we can restore the correct + // tabIndex after we're done with it. + // $FlowFixMe: Flow comoplains that we are missing value? + Object.defineProperty(elem, 'tabIndex', { + enumerable: false, + configurable: true, + get() { + return tabIndexState.canTab ? tabIndexState.value : -1; + }, + set(val) { + if (tabIndexState.canTab) { + tabIndexSetter.call(elem, val); + } + tabIndexState.value = val; + }, + }); + } else if (tabIndexState.canTab !== canTab) { + tabIndexSetter.call(elem, canTab ? tabIndexState.value : -1); + tabIndexState.canTab = canTab; + } +} diff --git a/packages/react-interactions/accessibility/src/FocusList.js b/packages/react-interactions/accessibility/src/FocusList.js new file mode 100644 index 000000000000..f44db6330ed0 --- /dev/null +++ b/packages/react-interactions/accessibility/src/FocusList.js @@ -0,0 +1,223 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactScope, ReactScopeMethods} from 'shared/ReactTypes'; +import type {KeyboardEvent} from 'react-interactions/events/keyboard'; + +import React from 'react'; +import {useKeyboard} from 'react-interactions/events/keyboard'; +import {setElementCanTab} from 'react-interactions/accessibility/focus-control'; + +type FocusItemProps = { + children?: React.Node, + onKeyDown?: KeyboardEvent => void, +}; + +type FocusListProps = {| + children: React.Node, + portrait: boolean, + wrap?: boolean, + tabScope?: ReactScope, + allowModifiers?: boolean, +|}; + +const {useRef} = React; + +function focusListItem(cell: ReactScopeMethods, event: KeyboardEvent): void { + const tabbableNodes = cell.getScopedNodes(); + if (tabbableNodes !== null && tabbableNodes.length > 0) { + tabbableNodes[0].focus(); + event.preventDefault(); + } +} + +function getPreviousListItem( + list: ReactScopeMethods, + currentItem: ReactScopeMethods, +): null | ReactScopeMethods { + const items = list.getChildren(); + if (items !== null) { + const currentItemIndex = items.indexOf(currentItem); + const wrap = getListProps(currentItem).wrap; + if (currentItemIndex === 0 && wrap) { + return items[items.length - 1] || null; + } else if (currentItemIndex > 0) { + return items[currentItemIndex - 1] || null; + } + } + return null; +} + +function getNextListItem( + list: ReactScopeMethods, + currentItem: ReactScopeMethods, +): null | ReactScopeMethods { + const items = list.getChildren(); + if (items !== null) { + const currentItemIndex = items.indexOf(currentItem); + const wrap = getListProps(currentItem).wrap; + const end = currentItemIndex === items.length - 1; + if (end && wrap) { + return items[0] || null; + } else if (currentItemIndex !== -1 && !end) { + return items[currentItemIndex + 1] || null; + } + } + return null; +} + +function getListProps(currentCell: ReactScopeMethods): Object { + const list = currentCell.getParent(); + if (list !== null) { + const listProps = list.getProps(); + if (listProps && listProps.type === 'list') { + return listProps; + } + } + return {}; +} + +function hasModifierKey(event: KeyboardEvent): boolean { + const {altKey, ctrlKey, metaKey, shiftKey} = event; + return ( + altKey === true || ctrlKey === true || metaKey === true || shiftKey === true + ); +} + +export function createFocusList(scope: ReactScope): Array { + const TableScope = React.unstable_createScope(scope.fn); + + function List({ + children, + portrait, + wrap, + tabScope: TabScope, + allowModifiers, + }): FocusListProps { + const tabScopeRef = useRef(null); + return ( + + {TabScope ? ( + {children} + ) : ( + children + )} + + ); + } + + function Item({children, onKeyDown}): FocusItemProps { + const scopeRef = useRef(null); + const keyboard = useKeyboard({ + onKeyDown(event: KeyboardEvent): void { + const currentItem = scopeRef.current; + if (currentItem !== null) { + const list = currentItem.getParent(); + const listProps = list && list.getProps(); + if (list !== null && listProps.type === 'list') { + const portrait = listProps.portrait; + const key = event.key; + + if (key === 'Tab') { + const tabScope = getListProps(currentItem).tabScopeRef.current; + if (tabScope) { + const activeNode = document.activeElement; + const nodes = tabScope.getScopedNodes(); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node !== activeNode) { + setElementCanTab(node, false); + } else { + setElementCanTab(node, true); + } + } + return; + } + event.continuePropagation(); + return; + } + // Using modifier keys with keyboard arrow events should be no-ops + // unless an explicit allowModifiers flag is set on the FocusList. + if (hasModifierKey(event)) { + const allowModifiers = getListProps(currentItem).allowModifiers; + if (!allowModifiers) { + event.continuePropagation(); + return; + } + } + switch (key) { + case 'ArrowUp': { + if (portrait) { + const previousListItem = getPreviousListItem( + list, + currentItem, + ); + if (previousListItem) { + focusListItem(previousListItem, event); + return; + } + } + break; + } + case 'ArrowDown': { + if (portrait) { + const nextListItem = getNextListItem(list, currentItem); + if (nextListItem) { + focusListItem(nextListItem, event); + return; + } + } + break; + } + case 'ArrowLeft': { + if (!portrait) { + const previousListItem = getPreviousListItem( + list, + currentItem, + ); + if (previousListItem) { + focusListItem(previousListItem, event); + return; + } + } + break; + } + case 'ArrowRight': { + if (!portrait) { + const nextListItem = getNextListItem(list, currentItem); + if (nextListItem) { + focusListItem(nextListItem, event); + return; + } + } + break; + } + } + } + } + if (onKeyDown) { + onKeyDown(event); + } + event.continuePropagation(); + }, + }); + return ( + + {children} + + ); + } + + return [List, Item]; +} diff --git a/packages/react-interactions/accessibility/src/FocusManager.js b/packages/react-interactions/accessibility/src/FocusManager.js new file mode 100644 index 000000000000..12a37297b9a0 --- /dev/null +++ b/packages/react-interactions/accessibility/src/FocusManager.js @@ -0,0 +1,115 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactScope} from 'shared/ReactTypes'; +import type {KeyboardEvent} from 'react-interactions/events/keyboard'; + +import React from 'react'; +import {useKeyboard} from 'react-interactions/events/keyboard'; +import {useFocusWithin} from 'react-interactions/events/focus'; +import { + focusFirst, + focusPrevious, + focusNext, +} from 'react-interactions/accessibility/focus-control'; +import TabbableScope from 'react-interactions/accessibility/tabbable-scope'; + +type TabFocusProps = {| + autoFocus?: boolean, + children: React.Node, + containFocus?: boolean, + restoreFocus?: boolean, + scope: ReactScope, +|}; + +const {useLayoutEffect, useRef} = React; + +const FocusManager = React.forwardRef( + ( + { + autoFocus, + children, + containFocus, + restoreFocus, + scope: CustomScope, + }: TabFocusProps, + ref, + ): React.Node => { + const ScopeToUse = CustomScope || TabbableScope; + const scopeRef = useRef(null); + // This ensures tabbing works through the React tree (including Portals and Suspense nodes) + const keyboard = useKeyboard({ + onKeyDown(event: KeyboardEvent): void { + if (event.key !== 'Tab') { + event.continuePropagation(); + return; + } + const scope = scopeRef.current; + if (scope !== null) { + if (event.shiftKey) { + focusPrevious(scope, event, containFocus); + } else { + focusNext(scope, event, containFocus); + } + } + }, + }); + const focusWithin = useFocusWithin({ + onBlurWithin: function(event) { + if (!containFocus) { + event.continuePropagation(); + return; + } + const lastNode = event.target; + if (lastNode) { + requestAnimationFrame(() => { + (lastNode: any).focus(); + }); + } + }, + }); + useLayoutEffect( + () => { + const scope = scopeRef.current; + let restoreElem; + if (restoreFocus) { + restoreElem = document.activeElement; + } + if (autoFocus && scope !== null) { + focusFirst(scope); + } + if (restoreElem) { + return () => { + (restoreElem: any).focus(); + }; + } + }, + [scopeRef], + ); + + return ( + { + if (ref) { + if (typeof ref === 'function') { + ref(node); + } else { + ref.current = node; + } + } + scopeRef.current = node; + }} + listeners={[keyboard, focusWithin]}> + {children} + + ); + }, +); + +export default FocusManager; diff --git a/packages/react-interactions/accessibility/src/FocusTable.js b/packages/react-interactions/accessibility/src/FocusTable.js index d405c0f92938..0f7633c73dd4 100644 --- a/packages/react-interactions/accessibility/src/FocusTable.js +++ b/packages/react-interactions/accessibility/src/FocusTable.js @@ -7,14 +7,16 @@ * @flow */ -import type {ReactScopeMethods} from 'shared/ReactTypes'; +import type {ReactScope, ReactScopeMethods} from 'shared/ReactTypes'; import type {KeyboardEvent} from 'react-interactions/events/keyboard'; import React from 'react'; import {useKeyboard} from 'react-interactions/events/keyboard'; +import {setElementCanTab} from 'react-interactions/accessibility/focus-control'; type FocusCellProps = { children?: React.Node, + onKeyDown?: KeyboardEvent => void, }; type FocusRowProps = { @@ -28,6 +30,9 @@ type FocusTableProps = {| direction: 'left' | 'right' | 'up' | 'down', focusTableByID: (id: string) => void, ) => void, + wrap?: boolean, + tabScope?: ReactScope, + allowModifiers?: boolean, |}; const {useRef} = React; @@ -54,19 +59,26 @@ export function focusFirstCellOnTable(table: ReactScopeMethods): void { } } -function focusCell(cell: ReactScopeMethods): void { +function focusScope(cell: ReactScopeMethods, event?: KeyboardEvent): void { const tabbableNodes = cell.getScopedNodes(); if (tabbableNodes !== null && tabbableNodes.length > 0) { tabbableNodes[0].focus(); + if (event) { + event.preventDefault(); + } } } -function focusCellByIndex(row: ReactScopeMethods, cellIndex: number): void { +function focusCellByIndex( + row: ReactScopeMethods, + cellIndex: number, + event?: KeyboardEvent, +): void { const cells = row.getChildren(); if (cells !== null) { const cell = cells[cellIndex]; if (cell) { - focusCell(cell); + focusScope(cell, event); } } } @@ -101,6 +113,7 @@ function getRows(currentCell: ReactScopeMethods) { function triggerNavigateOut( currentCell: ReactScopeMethods, direction: 'left' | 'right' | 'up' | 'down', + event, ): void { const row = currentCell.getParent(); if (row !== null && row.getProps().type === 'row') { @@ -122,20 +135,56 @@ function triggerNavigateOut( } }; onKeyboardOut(direction, focusTableByID); + return; } } } + event.continuePropagation(); } -export function createFocusTable( - scopeImpl: (type: string, props: Object) => boolean, -): Array { - const TableScope = React.unstable_createScope(scopeImpl); +function getTableProps(currentCell: ReactScopeMethods): Object { + const row = currentCell.getParent(); + if (row !== null && row.getProps().type === 'row') { + const table = row.getParent(); + if (table !== null) { + return table.getProps(); + } + } + return {}; +} + +function hasModifierKey(event: KeyboardEvent): boolean { + const {altKey, ctrlKey, metaKey, shiftKey} = event; + return ( + altKey === true || ctrlKey === true || metaKey === true || shiftKey === true + ); +} + +export function createFocusTable(scope: ReactScope): Array { + const TableScope = React.unstable_createScope(scope.fn); - function Table({children, onKeyboardOut, id}): FocusTableProps { + function Table({ + children, + onKeyboardOut, + id, + wrap, + tabScope: TabScope, + allowModifiers, + }): FocusTableProps { + const tabScopeRef = useRef(null); return ( - - {children} + + {TabScope ? ( + {children} + ) : ( + children + )} ); } @@ -144,12 +193,44 @@ export function createFocusTable( return {children}; } - function Cell({children}): FocusCellProps { + function Cell({children, onKeyDown}): FocusCellProps { const scopeRef = useRef(null); const keyboard = useKeyboard({ onKeyDown(event: KeyboardEvent): void { const currentCell = scopeRef.current; - switch (event.key) { + if (currentCell === null) { + event.continuePropagation(); + return; + } + const key = event.key; + if (key === 'Tab') { + const tabScope = getTableProps(currentCell).tabScopeRef.current; + if (tabScope) { + const activeNode = document.activeElement; + const nodes = tabScope.getScopedNodes(); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node !== activeNode) { + setElementCanTab(node, false); + } else { + setElementCanTab(node, true); + } + } + return; + } + event.continuePropagation(); + return; + } + // Using modifier keys with keyboard arrow events should be no-ops + // unless an explicit allowModifiers flag is set on the FocusTable. + if (hasModifierKey(event)) { + const allowModifiers = getTableProps(currentCell).allowModifiers; + if (!allowModifiers) { + event.continuePropagation(); + return; + } + } + switch (key) { case 'ArrowUp': { const [cells, cellIndex] = getRowCells(currentCell); if (cells !== null) { @@ -157,10 +238,15 @@ export function createFocusTable( if (rows !== null) { if (rowIndex > 0) { const row = rows[rowIndex - 1]; - focusCellByIndex(row, cellIndex); - event.preventDefault(); + focusCellByIndex(row, cellIndex, event); } else if (rowIndex === 0) { - triggerNavigateOut(currentCell, 'up'); + const wrap = getTableProps(currentCell).wrap; + if (wrap) { + const row = rows[rows.length - 1]; + focusCellByIndex(row, cellIndex, event); + } else { + triggerNavigateOut(currentCell, 'up', event); + } } } } @@ -173,11 +259,16 @@ export function createFocusTable( if (rows !== null) { if (rowIndex !== -1) { if (rowIndex === rows.length - 1) { - triggerNavigateOut(currentCell, 'down'); + const wrap = getTableProps(currentCell).wrap; + if (wrap) { + const row = rows[0]; + focusCellByIndex(row, cellIndex, event); + } else { + triggerNavigateOut(currentCell, 'down', event); + } } else { const row = rows[rowIndex + 1]; - focusCellByIndex(row, cellIndex); - event.preventDefault(); + focusCellByIndex(row, cellIndex, event); } } } @@ -188,10 +279,15 @@ export function createFocusTable( const [cells, rowIndex] = getRowCells(currentCell); if (cells !== null) { if (rowIndex > 0) { - focusCell(cells[rowIndex - 1]); + focusScope(cells[rowIndex - 1]); event.preventDefault(); } else if (rowIndex === 0) { - triggerNavigateOut(currentCell, 'left'); + const wrap = getTableProps(currentCell).wrap; + if (wrap) { + focusScope(cells[cells.length - 1], event); + } else { + triggerNavigateOut(currentCell, 'left', event); + } } } return; @@ -201,17 +297,23 @@ export function createFocusTable( if (cells !== null) { if (rowIndex !== -1) { if (rowIndex === cells.length - 1) { - triggerNavigateOut(currentCell, 'right'); + const wrap = getTableProps(currentCell).wrap; + if (wrap) { + focusScope(cells[0], event); + } else { + triggerNavigateOut(currentCell, 'right', event); + } } else { - focusCell(cells[rowIndex + 1]); - event.preventDefault(); + focusScope(cells[rowIndex + 1], event); } } } return; } } - event.continuePropagation(); + if (onKeyDown) { + onKeyDown(event); + } }, }); return ( diff --git a/packages/react-interactions/accessibility/src/TabFocus.js b/packages/react-interactions/accessibility/src/TabFocus.js deleted file mode 100644 index 76e528116cd2..000000000000 --- a/packages/react-interactions/accessibility/src/TabFocus.js +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -import type {ReactScope} from 'shared/ReactTypes'; -import type {KeyboardEvent} from 'react-interactions/events/keyboard'; - -import React from 'react'; -import {useKeyboard} from 'react-interactions/events/keyboard'; -import { - focusPrevious, - focusNext, -} from 'react-interactions/accessibility/focus-control'; - -type TabFocusProps = { - children: React.Node, - contain?: boolean, - scope: ReactScope, -}; - -const {useRef} = React; - -const TabFocus = React.forwardRef( - ({children, contain, scope: Scope}: TabFocusProps, ref): React.Node => { - const scopeRef = useRef(null); - const keyboard = useKeyboard({ - onKeyDown(event: KeyboardEvent): void { - if (event.key !== 'Tab') { - event.continuePropagation(); - return; - } - const scope = scopeRef.current; - if (scope !== null) { - if (event.shiftKey) { - focusPrevious(scope, event, contain); - } else { - focusNext(scope, event, contain); - } - } - }, - }); - - return ( - { - if (ref) { - if (typeof ref === 'function') { - ref(node); - } else { - ref.current = node; - } - } - scopeRef.current = node; - }} - listeners={keyboard}> - {children} - - ); - }, -); - -export default TabFocus; diff --git a/packages/react-interactions/accessibility/src/TabbableScope.js b/packages/react-interactions/accessibility/src/TabbableScope.js index 7c025f63afeb..5cf27277add2 100644 --- a/packages/react-interactions/accessibility/src/TabbableScope.js +++ b/packages/react-interactions/accessibility/src/TabbableScope.js @@ -9,7 +9,7 @@ import React from 'react'; -export const tabFocusableImpl = (type: string, props: Object): boolean => { +const tabFocusableImpl = (type: string, props: Object): boolean => { if (props.tabIndex === -1 || props.disabled) { return false; } diff --git a/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js b/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js new file mode 100644 index 000000000000..68a8cc83058b --- /dev/null +++ b/packages/react-interactions/accessibility/src/__tests__/FocusList-test.internal.js @@ -0,0 +1,256 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import {createEventTarget} from 'react-interactions/events/src/dom/testing-library'; +import {emulateBrowserTab} from '../emulateBrowserTab'; + +let React; +let ReactFeatureFlags; +let createFocusList; +let TabbableScope; + +describe('FocusList', () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableScopeAPI = true; + ReactFeatureFlags.enableFlareAPI = true; + createFocusList = require('../FocusList').createFocusList; + TabbableScope = require('../TabbableScope').default; + React = require('react'); + }); + + describe('ReactDOM', () => { + let ReactDOM; + let container; + + beforeEach(() => { + ReactDOM = require('react-dom'); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + container = null; + }); + + function createFocusListComponent() { + const [FocusList, FocusItem] = createFocusList(TabbableScope); + + return ({portrait, wrap, allowModifiers}) => ( + +
      + +
    • Item 1
    • +
      + +
    • Item 2
    • +
      + +
    • Item 3
    • +
      +
    +
    + ); + } + + it('handles keyboard arrow operations (portrait)', () => { + const Test = createFocusListComponent(); + + ReactDOM.render(, container); + const listItems = document.querySelectorAll('li'); + const firstListItem = createEventTarget(listItems[0]); + firstListItem.focus(); + firstListItem.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('Item 2'); + + const secondListItem = createEventTarget(document.activeElement); + secondListItem.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + + const thirdListItem = createEventTarget(document.activeElement); + thirdListItem.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + thirdListItem.keydown({ + key: 'ArrowRight', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + thirdListItem.keydown({ + key: 'ArrowLeft', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + // Should be a no-op due to modifier + thirdListItem.keydown({ + key: 'ArrowUp', + altKey: true, + }); + expect(document.activeElement.textContent).toBe('Item 3'); + }); + + it('handles keyboard arrow operations (landscape)', () => { + const Test = createFocusListComponent(); + + ReactDOM.render(, container); + const listItems = document.querySelectorAll('li'); + const firstListItem = createEventTarget(listItems[0]); + firstListItem.focus(); + firstListItem.keydown({ + key: 'ArrowRight', + }); + expect(document.activeElement.textContent).toBe('Item 2'); + + const secondListItem = createEventTarget(document.activeElement); + secondListItem.keydown({ + key: 'ArrowRight', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + + const thirdListItem = createEventTarget(document.activeElement); + thirdListItem.keydown({ + key: 'ArrowRight', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + thirdListItem.keydown({ + key: 'ArrowUp', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + thirdListItem.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + }); + + it('handles keyboard arrow operations (portrait) with wrapping enabled', () => { + const Test = createFocusListComponent(); + + ReactDOM.render(, container); + const listItems = document.querySelectorAll('li'); + let firstListItem = createEventTarget(listItems[0]); + firstListItem.focus(); + firstListItem.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('Item 2'); + + const secondListItem = createEventTarget(document.activeElement); + secondListItem.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + + const thirdListItem = createEventTarget(document.activeElement); + thirdListItem.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.textContent).toBe('Item 1'); + + firstListItem = createEventTarget(document.activeElement); + firstListItem.keydown({ + key: 'ArrowUp', + }); + expect(document.activeElement.textContent).toBe('Item 3'); + }); + + it('handles keyboard arrow operations (portrait) with allowModifiers', () => { + const Test = createFocusListComponent(); + + ReactDOM.render( + , + container, + ); + const listItems = document.querySelectorAll('li'); + let firstListItem = createEventTarget(listItems[0]); + firstListItem.focus(); + firstListItem.keydown({ + key: 'ArrowDown', + altKey: true, + }); + expect(document.activeElement.textContent).toBe('Item 2'); + }); + + it('handles keyboard arrow operations mixed with tabbing', () => { + const [FocusList, FocusItem] = createFocusList(TabbableScope); + const beforeRef = React.createRef(); + const afterRef = React.createRef(); + + function Test() { + return ( + <> + + +
      + +
    • + +
    • +
      + +
    • + +
    • +
      + +
    • + +
    • +
      + +
    • + +
    • +
      + +
    • + +
    • +
      + +
    • + +
    • +
      +
    +
    + + + ); + } + + ReactDOM.render(, container); + beforeRef.current.focus(); + + expect(document.activeElement.placeholder).toBe('Before'); + emulateBrowserTab(); + expect(document.activeElement.placeholder).toBe('A'); + emulateBrowserTab(); + expect(document.activeElement.placeholder).toBe('After'); + emulateBrowserTab(true); + expect(document.activeElement.placeholder).toBe('A'); + const a = createEventTarget(document.activeElement); + a.keydown({ + key: 'ArrowDown', + }); + expect(document.activeElement.placeholder).toBe('B'); + emulateBrowserTab(); + expect(document.activeElement.placeholder).toBe('After'); + emulateBrowserTab(true); + expect(document.activeElement.placeholder).toBe('B'); + }); + }); +}); diff --git a/packages/react-interactions/accessibility/src/__tests__/TabFocus-test.internal.js b/packages/react-interactions/accessibility/src/__tests__/FocusManager-test.internal.js similarity index 73% rename from packages/react-interactions/accessibility/src/__tests__/TabFocus-test.internal.js rename to packages/react-interactions/accessibility/src/__tests__/FocusManager-test.internal.js index 92a2833b9c43..0d33ec035fd4 100644 --- a/packages/react-interactions/accessibility/src/__tests__/TabFocus-test.internal.js +++ b/packages/react-interactions/accessibility/src/__tests__/FocusManager-test.internal.js @@ -11,18 +11,16 @@ import {createEventTarget} from 'react-interactions/events/src/dom/testing-libra let React; let ReactFeatureFlags; -let TabFocus; -let TabbableScope; +let FocusManager; let FocusControl; -describe('TabFocusController', () => { +describe('FocusManager', () => { beforeEach(() => { jest.resetModules(); ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.enableScopeAPI = true; ReactFeatureFlags.enableFlareAPI = true; - TabFocus = require('../TabFocus').default; - TabbableScope = require('../TabbableScope').default; + FocusManager = require('../FocusManager').default; FocusControl = require('../FocusControl'); React = require('react'); }); @@ -42,21 +40,21 @@ describe('TabFocusController', () => { container = null; }); - it('handles tab operations', () => { + it('handles tab operations by default', () => { const inputRef = React.createRef(); const input2Ref = React.createRef(); const buttonRef = React.createRef(); - const butto2nRef = React.createRef(); + const button2Ref = React.createRef(); const divRef = React.createRef(); const Test = () => ( - +
    + ); + }; + + ReactDOM.render(, container); + difRef.current.focus(); + expect(document.activeElement).toBe(difRef.current); + ReactDOM.render(, container); + expect(document.activeElement).toBe(buttonRef.current); + ReactDOM.render(, container); + expect(document.activeElement).toBe(difRef.current); + }); + + it('handles containFocus', () => { const inputRef = React.createRef(); const input2Ref = React.createRef(); + const input3Ref = React.createRef(); const buttonRef = React.createRef(); const button2Ref = React.createRef(); const Test = () => ( - - -
    ); ReactDOM.render(, container); @@ -98,9 +139,16 @@ describe('TabFocusController', () => { expect(document.activeElement).toBe(buttonRef.current); createEventTarget(document.activeElement).tabPrevious(); expect(document.activeElement).toBe(button2Ref.current); + // Focus should be restored to the contained area + const rAF = window.requestAnimationFrame; + window.requestAnimationFrame = x => setTimeout(x); + input3Ref.current.focus(); + jest.advanceTimersByTime(1); + window.requestAnimationFrame = rAF; + expect(document.activeElement).toBe(button2Ref.current); }); - it('handles tab operations when controllers are nested', () => { + it('works with nested FocusManagers', () => { const inputRef = React.createRef(); const input2Ref = React.createRef(); const buttonRef = React.createRef(); @@ -109,16 +157,16 @@ describe('TabFocusController', () => { const button4Ref = React.createRef(); const Test = () => ( - +