diff --git a/compiler/packages/babel-plugin-react-compiler/src/Optimization/ConstantPropagation.ts b/compiler/packages/babel-plugin-react-compiler/src/Optimization/ConstantPropagation.ts
index 07cd419230ac4..05f4ef1ae7ffd 100644
--- a/compiler/packages/babel-plugin-react-compiler/src/Optimization/ConstantPropagation.ts
+++ b/compiler/packages/babel-plugin-react-compiler/src/Optimization/ConstantPropagation.ts
@@ -327,6 +327,23 @@ function evaluateInstruction(
}
return null;
}
+ case '-': {
+ const operand = read(constants, value.value);
+ if (
+ operand !== null &&
+ operand.kind === 'Primitive' &&
+ typeof operand.value === 'number'
+ ) {
+ const result: Primitive = {
+ kind: 'Primitive',
+ value: operand.value * -1,
+ loc: value.loc,
+ };
+ instr.value = result;
+ return result;
+ }
+ return null;
+ }
default:
return null;
}
diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-unary-number.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-unary-number.expect.md
new file mode 100644
index 0000000000000..074c3214810bd
--- /dev/null
+++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-unary-number.expect.md
@@ -0,0 +1,74 @@
+
+## Input
+
+```javascript
+import {Stringify} from 'shared-runtime';
+
+function foo() {
+ const a = -1;
+ return (
+
+ );
+}
+
+export const FIXTURE_ENTRYPOINT = {
+ fn: foo,
+ params: [],
+ isComponent: false,
+};
+
+```
+
+## Code
+
+```javascript
+import { c as _c } from "react/compiler-runtime";
+import { Stringify } from "shared-runtime";
+
+function foo() {
+ const $ = _c(1);
+ let t0;
+ if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
+ t0 = (
+
+ );
+ $[0] = t0;
+ } else {
+ t0 = $[0];
+ }
+ return t0;
+}
+
+export const FIXTURE_ENTRYPOINT = {
+ fn: foo,
+ params: [],
+ isComponent: false,
+};
+
+```
+
+### Eval output
+(kind: ok)
{"value":[-2,0,true,null,null,null,null,null]}
\ No newline at end of file
diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-unary-number.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-unary-number.js
new file mode 100644
index 0000000000000..8ef8ec0e6c79a
--- /dev/null
+++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-unary-number.js
@@ -0,0 +1,25 @@
+import {Stringify} from 'shared-runtime';
+
+function foo() {
+ const a = -1;
+ return (
+
+ );
+}
+
+export const FIXTURE_ENTRYPOINT = {
+ fn: foo,
+ params: [],
+ isComponent: false,
+};
diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-unary.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-unary.expect.md
index 3ba5ea6bb9553..aaea767643b06 100644
--- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-unary.expect.md
+++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/constant-propagation-unary.expect.md
@@ -58,7 +58,7 @@ function foo() {
n0: true,
n1: false,
n2: false,
- n3: !-1,
+ n3: false,
s0: true,
s1: false,
s2: false,
diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/EventDispatchCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/EventDispatchCase.js
new file mode 100644
index 0000000000000..ef5a959021332
--- /dev/null
+++ b/fixtures/dom/src/components/fixtures/fragment-refs/EventDispatchCase.js
@@ -0,0 +1,157 @@
+import TestCase from '../../TestCase';
+import Fixture from '../../Fixture';
+
+const React = window.React;
+const {Fragment, useRef, useState} = React;
+
+function WrapperComponent(props) {
+ return props.children;
+}
+
+const initialState = {
+ child: false,
+ parent: false,
+ grandparent: false,
+};
+
+export default function EventListenerCase() {
+ const fragmentRef = useRef(null);
+ const [clickedState, setClickedState] = useState({...initialState});
+ const [fragmentEventFired, setFragmentEventFired] = useState(false);
+ const [bubblesState, setBubblesState] = useState(true);
+
+ function setClick(id) {
+ setClickedState(prev => ({...prev, [id]: true}));
+ }
+
+ function fragmentClickHandler(e) {
+ setFragmentEventFired(true);
+ }
+
+ return (
+
+
+
+ Each box has regular click handlers, you can click each one to observe
+ the status changing through standard bubbling.
+
+ Clear the clicked state
+
+ Click the "Dispatch click event" button to dispatch a click event on
+ the Fragment. The event will be dispatched on the Fragment's parent,
+ so the child will not change state.
+
+
+ Click the "Add event listener" button to add a click event listener on
+ the Fragment. This registers a handler that will turn the child blue
+ on click.
+
+
+ Now click the "Dispatch click event" button again. You can see that it
+ will fire the Fragment's event handler in addition to bubbling the
+ click from the parent.
+
+
+ If you turn off bubbling, only the Fragment's event handler will be
+ called.
+
+
+
+
+
+ Dispatching an event on a Fragment will forward the dispatch to its
+ parent for the standard case. You can observe when dispatching that
+ the parent handler is called in additional to bubbling from there. A
+ delay is added to make the bubbling more clear.{' '}
+
+
+ When there have been event handlers added to the Fragment, the
+ Fragment's event handler will be called in addition to bubbling from
+ the parent. Without bubbling, only the Fragment's event handler will
+ be called.
+
+
+
+
+
+ {
+ setBubblesState(e.target.value === 'true');
+ }}>
+ Bubbles: true
+ Bubbles: false
+
+ {
+ fragmentRef.current.dispatchEvent(
+ new MouseEvent('click', {bubbles: bubblesState})
+ );
+ }}>
+ Dispatch click event
+
+ {
+ setClickedState({...initialState});
+ setFragmentEventFired(false);
+ }}>
+ Reset clicked state
+
+ {
+ fragmentRef.current.addEventListener(
+ 'click',
+ fragmentClickHandler
+ );
+ }}>
+ Add event listener
+
+ {
+ fragmentRef.current.removeEventListener(
+ 'click',
+ fragmentClickHandler
+ );
+ }}>
+ Remove event listener
+
+
+ {
+ setTimeout(() => {
+ setClick('grandparent');
+ }, 200);
+ }}
+ className="card">
+ Fragment grandparent - clicked:{' '}
+ {clickedState.grandparent ? 'true' : 'false'}
+
{
+ setTimeout(() => {
+ setClick('parent');
+ }, 100);
+ }}
+ className="card">
+ Fragment parent - clicked: {clickedState.parent ? 'true' : 'false'}
+
+ {
+ setClick('child');
+ }}>
+ Fragment child - clicked:{' '}
+ {clickedState.child ? 'true' : 'false'}
+
+
+
+
+
+
+ );
+}
diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js
index 888107c9e07c7..baff30895c0e0 100644
--- a/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js
+++ b/fixtures/dom/src/components/fixtures/fragment-refs/FocusCase.js
@@ -43,11 +43,18 @@ export default function FocusCase() {
- Unfocusable div
- Button 1
+
+
+
Unfocusable div with nested focusable button
+
Button 1
+
Button 2
- Unfocusable div
+
diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/index.js b/fixtures/dom/src/components/fixtures/fragment-refs/index.js
index b84f273177d3a..23b440938cf7a 100644
--- a/fixtures/dom/src/components/fixtures/fragment-refs/index.js
+++ b/fixtures/dom/src/components/fixtures/fragment-refs/index.js
@@ -1,5 +1,6 @@
import FixtureSet from '../../FixtureSet';
import EventListenerCase from './EventListenerCase';
+import EventDispatchCase from './EventDispatchCase';
import IntersectionObserverCase from './IntersectionObserverCase';
import ResizeObserverCase from './ResizeObserverCase';
import FocusCase from './FocusCase';
@@ -11,6 +12,7 @@ export default function FragmentRefsPage() {
return (
+
diff --git a/fixtures/dom/src/style.css b/fixtures/dom/src/style.css
index 66fda7afe0cac..e507014d6829d 100644
--- a/fixtures/dom/src/style.css
+++ b/fixtures/dom/src/style.css
@@ -365,6 +365,10 @@ tbody tr:nth-child(even) {
background-color: green;
}
+.highlight-focused-children * {
+ margin-left: 10px;
+}
+
.highlight-focused-children *:focus {
outline: 2px solid green;
}
diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js
index 3234814952fad..7d6bbd5c1fbb0 100644
--- a/packages/react-client/src/ReactFlightClient.js
+++ b/packages/react-client/src/ReactFlightClient.js
@@ -15,7 +15,7 @@ import type {
ReactAsyncInfo,
ReactTimeInfo,
ReactStackTrace,
- ReactCallSite,
+ ReactFunctionLocation,
ReactErrorInfoDev,
} from 'shared/ReactTypes';
import type {LazyComponent} from 'react/src/ReactLazy';
@@ -1074,7 +1074,7 @@ function loadServerReference, T>(
bound: null | Thenable>,
name?: string, // DEV-only
env?: string, // DEV-only
- location?: ReactCallSite, // DEV-only
+ location?: ReactFunctionLocation, // DEV-only
},
parentObject: Object,
key: string,
@@ -2226,6 +2226,8 @@ function createFakeFunction(
sourceMap: null | string,
line: number,
col: number,
+ enclosingLine: number,
+ enclosingCol: number,
environmentName: string,
): FakeFunction {
// This creates a fake copy of a Server Module. It represents a module that has already
@@ -2243,26 +2245,104 @@ function createFakeFunction(
// This allows us to use the original source map as the source map of this fake file to
// point to the original source.
let code;
- if (line <= 1) {
- const minSize = encodedName.length + 7;
+ // Normalize line/col to zero based.
+ if (enclosingLine < 1) {
+ enclosingLine = 0;
+ } else {
+ enclosingLine--;
+ }
+ if (enclosingCol < 1) {
+ enclosingCol = 0;
+ } else {
+ enclosingCol--;
+ }
+ if (line < 1) {
+ line = 0;
+ } else {
+ line--;
+ }
+ if (col < 1) {
+ col = 0;
+ } else {
+ col--;
+ }
+ if (line < enclosingLine || (line === enclosingLine && col < enclosingCol)) {
+ // Protection against invalid enclosing information. Should not happen.
+ enclosingLine = 0;
+ enclosingCol = 0;
+ }
+ if (line < 1) {
+ // Fit everything on the first line.
+ const minCol = encodedName.length + 3;
+ let enclosingColDistance = enclosingCol - minCol;
+ if (enclosingColDistance < 0) {
+ enclosingColDistance = 0;
+ }
+ let colDistance = col - enclosingColDistance - minCol - 3;
+ if (colDistance < 0) {
+ colDistance = 0;
+ }
code =
'({' +
encodedName +
- ':_=>' +
- ' '.repeat(col < minSize ? 0 : col - minSize) +
- '_()})\n' +
- comment;
+ ':' +
+ ' '.repeat(enclosingColDistance) +
+ '_=>' +
+ ' '.repeat(colDistance) +
+ '_()})';
+ } else if (enclosingLine < 1) {
+ // Fit just the enclosing function on the first line.
+ const minCol = encodedName.length + 3;
+ let enclosingColDistance = enclosingCol - minCol;
+ if (enclosingColDistance < 0) {
+ enclosingColDistance = 0;
+ }
+ code =
+ '({' +
+ encodedName +
+ ':' +
+ ' '.repeat(enclosingColDistance) +
+ '_=>' +
+ '\n'.repeat(line - enclosingLine) +
+ ' '.repeat(col) +
+ '_()})';
+ } else if (enclosingLine === line) {
+ // Fit the enclosing function and callsite on same line.
+ let colDistance = col - enclosingCol - 3;
+ if (colDistance < 0) {
+ colDistance = 0;
+ }
+ code =
+ '\n'.repeat(enclosingLine - 1) +
+ '({' +
+ encodedName +
+ ':\n' +
+ ' '.repeat(enclosingCol) +
+ '_=>' +
+ ' '.repeat(colDistance) +
+ '_()})';
} else {
+ // This is the ideal because we can always encode any position.
code =
- comment +
- '\n'.repeat(line - 2) +
+ '\n'.repeat(enclosingLine - 1) +
'({' +
encodedName +
- ':_=>\n' +
- ' '.repeat(col < 1 ? 0 : col - 1) +
+ ':\n' +
+ ' '.repeat(enclosingCol) +
+ '_=>' +
+ '\n'.repeat(line - enclosingLine) +
+ ' '.repeat(col) +
'_()})';
}
+ if (enclosingLine < 1) {
+ // If the function starts at the first line, we append the comment after.
+ code = code + '\n' + comment;
+ } else {
+ // Otherwise we prepend the comment on the first line.
+ code = comment + code;
+ }
+
if (filename.startsWith('/')) {
// If the filename starts with `/` we assume that it is a file system file
// rather than relative to the current host. Since on the server fully qualified
@@ -2320,7 +2400,7 @@ function buildFakeCallStack(
const frameKey = frame.join('-') + '-' + environmentName;
let fn = fakeFunctionCache.get(frameKey);
if (fn === undefined) {
- const [name, filename, line, col] = frame;
+ const [name, filename, line, col, enclosingLine, enclosingCol] = frame;
const findSourceMapURL = response._debugFindSourceMapURL;
const sourceMap = findSourceMapURL
? findSourceMapURL(filename, environmentName)
@@ -2331,6 +2411,8 @@ function buildFakeCallStack(
sourceMap,
line,
col,
+ enclosingLine,
+ enclosingCol,
environmentName,
);
// TODO: This cache should technically live on the response since the _debugFindSourceMapURL
diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js
index e3c4ec20ba1ab..6a0a37b787d34 100644
--- a/packages/react-client/src/ReactFlightReplyClient.js
+++ b/packages/react-client/src/ReactFlightReplyClient.js
@@ -13,7 +13,7 @@ import type {
FulfilledThenable,
RejectedThenable,
ReactCustomFormAction,
- ReactCallSite,
+ ReactFunctionLocation,
} from 'shared/ReactTypes';
import type {LazyComponent} from 'react/src/ReactLazy';
import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences';
@@ -1248,7 +1248,7 @@ export function createBoundServerReference, T>(
bound: null | Thenable>,
name?: string, // DEV-only
env?: string, // DEV-only
- location?: ReactCallSite, // DEV-only
+ location?: ReactFunctionLocation, // DEV-only
},
callServer: CallServerCallback,
encodeFormAction?: EncodeFormActionCallback,
@@ -1309,7 +1309,7 @@ const v8FrameRegExp =
// filename:0:0
const jscSpiderMonkeyFrameRegExp = /(?:(.*)@)?(.*):(\d+):(\d+)/;
-function parseStackLocation(error: Error): null | ReactCallSite {
+function parseStackLocation(error: Error): null | ReactFunctionLocation {
// This parsing is special in that we know that the calling function will always
// be a module that initializes the server action. We also need this part to work
// cross-browser so not worth a Config. It's DEV only so not super code size
@@ -1350,6 +1350,7 @@ function parseStackLocation(error: Error): null | ReactCallSite {
if (filename === '') {
filename = '';
}
+ // This is really the enclosingLine/Column.
const line = +(parsed[3] || parsed[6]);
const col = +(parsed[4] || parsed[7]);
diff --git a/packages/react-devtools-extensions/chrome/manifest.json b/packages/react-devtools-extensions/chrome/manifest.json
index 5cf6397bc75ef..a773bdcaa25f9 100644
--- a/packages/react-devtools-extensions/chrome/manifest.json
+++ b/packages/react-devtools-extensions/chrome/manifest.json
@@ -4,7 +4,7 @@
"description": "Adds React debugging tools to the Chrome Developer Tools.",
"version": "6.1.1",
"version_name": "6.1.1",
- "minimum_chrome_version": "102",
+ "minimum_chrome_version": "114",
"icons": {
"16": "icons/16-production.png",
"32": "icons/32-production.png",
diff --git a/packages/react-devtools-extensions/edge/manifest.json b/packages/react-devtools-extensions/edge/manifest.json
index 512dd888f7d94..333f788e90e61 100644
--- a/packages/react-devtools-extensions/edge/manifest.json
+++ b/packages/react-devtools-extensions/edge/manifest.json
@@ -4,7 +4,7 @@
"description": "Adds React debugging tools to the Microsoft Edge Developer Tools.",
"version": "6.1.1",
"version_name": "6.1.1",
- "minimum_chrome_version": "102",
+ "minimum_chrome_version": "114",
"icons": {
"16": "icons/16-production.png",
"32": "icons/32-production.png",
diff --git a/packages/react-devtools-shared/src/backend/views/TraceUpdates/canvas.js b/packages/react-devtools-shared/src/backend/views/TraceUpdates/canvas.js
index 3e01397546721..b9e2cd9068135 100644
--- a/packages/react-devtools-shared/src/backend/views/TraceUpdates/canvas.js
+++ b/packages/react-devtools-shared/src/backend/views/TraceUpdates/canvas.js
@@ -65,6 +65,24 @@ function drawWeb(nodeToData: Map) {
drawGroupBorders(context, group);
drawGroupLabel(context, group);
});
+
+ if (canvas !== null) {
+ if (nodeToData.size === 0 && canvas.matches(':popover-open')) {
+ // $FlowFixMe[prop-missing]: Flow doesn't recognize Popover API
+ // $FlowFixMe[incompatible-use]: Flow doesn't recognize Popover API
+ canvas.hidePopover();
+ return;
+ }
+ // $FlowFixMe[incompatible-use]: Flow doesn't recognize Popover API
+ if (canvas.matches(':popover-open')) {
+ // $FlowFixMe[prop-missing]: Flow doesn't recognize Popover API
+ // $FlowFixMe[incompatible-use]: Flow doesn't recognize Popover API
+ canvas.hidePopover();
+ }
+ // $FlowFixMe[prop-missing]: Flow doesn't recognize Popover API
+ // $FlowFixMe[incompatible-use]: Flow doesn't recognize Popover API
+ canvas.showPopover();
+ }
}
type GroupItem = {
@@ -191,7 +209,15 @@ function destroyNative(agent: Agent) {
function destroyWeb() {
if (canvas !== null) {
+ if (canvas.matches(':popover-open')) {
+ // $FlowFixMe[prop-missing]: Flow doesn't recognize Popover API
+ // $FlowFixMe[incompatible-use]: Flow doesn't recognize Popover API
+ canvas.hidePopover();
+ }
+
+ // $FlowFixMe[incompatible-use]: Flow doesn't recognize Popover API and loses canvas nullability tracking
if (canvas.parentNode != null) {
+ // $FlowFixMe[incompatible-call]: Flow doesn't track that canvas is non-null here
canvas.parentNode.removeChild(canvas);
}
canvas = null;
@@ -204,6 +230,9 @@ export function destroy(agent: Agent): void {
function initialize(): void {
canvas = window.document.createElement('canvas');
+ canvas.setAttribute('popover', 'manual');
+
+ // $FlowFixMe[incompatible-use]: Flow doesn't recognize Popover API
canvas.style.cssText = `
xx-background-color: red;
xx-opacity: 0.5;
@@ -213,7 +242,10 @@ function initialize(): void {
position: fixed;
right: 0;
top: 0;
- z-index: 1000000000;
+ background-color: transparent;
+ outline: none;
+ box-shadow: none;
+ border: none;
`;
const root = window.document.documentElement;
diff --git a/packages/react-devtools-shell/src/app/TraceUpdatesTest/index.js b/packages/react-devtools-shell/src/app/TraceUpdatesTest/index.js
new file mode 100644
index 0000000000000..dd847ad7aef45
--- /dev/null
+++ b/packages/react-devtools-shell/src/app/TraceUpdatesTest/index.js
@@ -0,0 +1,87 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and 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 * as React from 'react';
+import {useRef, useState} from 'react';
+
+const Counter = () => {
+ const [count, setCount] = useState(0);
+
+ return (
+
+
Count: {count}
+ setCount(c => c + 1)}>Increment
+
+ );
+};
+
+function DialogComponent() {
+ const dialogRef = useRef(null);
+
+ const openDialog = () => {
+ if (dialogRef.current) {
+ dialogRef.current.showModal();
+ }
+ };
+
+ const closeDialog = () => {
+ if (dialogRef.current) {
+ dialogRef.current.close();
+ }
+ };
+
+ return (
+
+ Open Dialog
+
+ Dialog Content
+
+ Close
+
+
+ );
+}
+
+function RegularComponent() {
+ return (
+
+
Regular Component
+
+
+ );
+}
+
+export default function TraceUpdatesTest(): React.Node {
+ return (
+
+
TraceUpdates Test
+
+
+
Standard Component
+
+
+
+
+
Dialog Component (top-layer element)
+
+
+
+
+
How to Test:
+
+ Open DevTools Components panel
+ Enable "Highlight updates when components render" in settings
+ Click increment buttons and observe highlights
+ Open the dialog and test increments there as well
+
+
+
+ );
+}
diff --git a/packages/react-devtools-shell/src/app/index.js b/packages/react-devtools-shell/src/app/index.js
index 11f13bec47b22..8de2f949bb744 100644
--- a/packages/react-devtools-shell/src/app/index.js
+++ b/packages/react-devtools-shell/src/app/index.js
@@ -19,6 +19,7 @@ import Toggle from './Toggle';
import ErrorBoundaries from './ErrorBoundaries';
import PartiallyStrictApp from './PartiallyStrictApp';
import SuspenseTree from './SuspenseTree';
+import TraceUpdatesTest from './TraceUpdatesTest';
import {ignoreErrors, ignoreLogs, ignoreWarnings} from './console';
import './styles.css';
@@ -112,6 +113,7 @@ function mountTestApp() {
mountApp(SuspenseTree);
mountApp(DeeplyNestedComponents);
mountApp(Iframe);
+ mountApp(TraceUpdatesTest);
if (shouldRenderLegacy) {
mountLegacyApp(PartiallyStrictApp);
diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
index 8584b644eff9d..373f33ba03021 100644
--- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
@@ -68,6 +68,7 @@ import {
getFragmentParentHostFiber,
getNextSiblingHostFiber,
getInstanceFromHostFiber,
+ traverseFragmentInstanceDeeply,
} from 'react-reconciler/src/ReactFiberTreeReflection';
export {detachDeletedInstance};
@@ -2597,6 +2598,7 @@ export type FragmentInstanceType = {
listener: EventListener,
optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
): void,
+ dispatchEvent(event: Event): boolean,
focus(focusOptions?: FocusOptions): void,
focusLast(focusOptions?: FocusOptions): void,
blur(): void,
@@ -2694,11 +2696,48 @@ function removeEventListenerFromChild(
return false;
}
// $FlowFixMe[prop-missing]
+FragmentInstance.prototype.dispatchEvent = function (
+ this: FragmentInstanceType,
+ event: Event,
+): boolean {
+ const parentHostFiber = getFragmentParentHostFiber(this._fragmentFiber);
+ if (parentHostFiber === null) {
+ return true;
+ }
+ const parentHostInstance =
+ getInstanceFromHostFiber(parentHostFiber);
+ const eventListeners = this._eventListeners;
+ if (
+ (eventListeners !== null && eventListeners.length > 0) ||
+ !event.bubbles
+ ) {
+ const temp = document.createTextNode('');
+ if (eventListeners) {
+ for (let i = 0; i < eventListeners.length; i++) {
+ const {type, listener, optionsOrUseCapture} = eventListeners[i];
+ temp.addEventListener(type, listener, optionsOrUseCapture);
+ }
+ }
+ parentHostInstance.appendChild(temp);
+ const cancelable = temp.dispatchEvent(event);
+ if (eventListeners) {
+ for (let i = 0; i < eventListeners.length; i++) {
+ const {type, listener, optionsOrUseCapture} = eventListeners[i];
+ temp.removeEventListener(type, listener, optionsOrUseCapture);
+ }
+ }
+ parentHostInstance.removeChild(temp);
+ return cancelable;
+ } else {
+ return parentHostInstance.dispatchEvent(event);
+ }
+};
+// $FlowFixMe[prop-missing]
FragmentInstance.prototype.focus = function (
this: FragmentInstanceType,
focusOptions?: FocusOptions,
): void {
- traverseFragmentInstance(
+ traverseFragmentInstanceDeeply(
this._fragmentFiber,
setFocusOnFiberIfFocusable,
focusOptions,
@@ -2717,7 +2756,11 @@ FragmentInstance.prototype.focusLast = function (
focusOptions?: FocusOptions,
): void {
const children: Array = [];
- traverseFragmentInstance(this._fragmentFiber, collectChildren, children);
+ traverseFragmentInstanceDeeply(
+ this._fragmentFiber,
+ collectChildren,
+ children,
+ );
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
if (setFocusOnFiberIfFocusable(child, focusOptions)) {
diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
index 50447e1eac677..e7e1d053a7a3e 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
@@ -145,6 +145,32 @@ describe('FragmentRefs', () => {
document.activeElement.blur();
});
+ // @gate enableFragmentRefs
+ it('focuses deeply nested focusable children, depth first', async () => {
+ const fragmentRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
+
+ function Test() {
+ return (
+
+
+
+
+ );
+ }
+ await act(() => {
+ root.render( );
+ });
+ await act(() => {
+ fragmentRef.current.focus();
+ });
+ expect(document.activeElement.id).toEqual('grandchild-a');
+ });
+
// @gate enableFragmentRefs
it('preserves document order when adding and removing children', async () => {
const fragmentRef = React.createRef();
@@ -228,6 +254,34 @@ describe('FragmentRefs', () => {
expect(document.activeElement.id).toEqual('child-c');
document.activeElement.blur();
});
+
+ // @gate enableFragmentRefs
+ it('focuses deeply nested focusable children, depth first', async () => {
+ const fragmentRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
+
+ function Test() {
+ return (
+
+
+
+
+ );
+ }
+ await act(() => {
+ root.render( );
+ });
+ await act(() => {
+ fragmentRef.current.focusLast();
+ });
+ expect(document.activeElement.id).toEqual('grandchild-b');
+ });
});
describe('blur()', () => {
@@ -299,410 +353,346 @@ describe('FragmentRefs', () => {
});
});
- describe('event listeners', () => {
- // @gate enableFragmentRefs
- it('adds and removes event listeners from children', async () => {
- const parentRef = React.createRef();
- const fragmentRef = React.createRef();
- const childARef = React.createRef();
- const childBRef = React.createRef();
- const root = ReactDOMClient.createRoot(container);
-
- let logs = [];
+ describe('events', () => {
+ describe('add/remove event listeners', () => {
+ // @gate enableFragmentRefs
+ it('adds and removes event listeners from children', async () => {
+ const parentRef = React.createRef();
+ const fragmentRef = React.createRef();
+ const childARef = React.createRef();
+ const childBRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
- function handleFragmentRefClicks() {
- logs.push('fragmentRef');
- }
+ let logs = [];
- function Test() {
- React.useEffect(() => {
- fragmentRef.current.addEventListener(
- 'click',
- handleFragmentRefClicks,
- );
+ function handleFragmentRefClicks() {
+ logs.push('fragmentRef');
+ }
- return () => {
- fragmentRef.current.removeEventListener(
+ function Test() {
+ React.useEffect(() => {
+ fragmentRef.current.addEventListener(
'click',
handleFragmentRefClicks,
);
- };
- }, []);
- return (
-
-
- <>Text>
- A
- <>
- B
- >
-
-
- );
- }
- await act(() => {
- root.render( );
- });
+ return () => {
+ fragmentRef.current.removeEventListener(
+ 'click',
+ handleFragmentRefClicks,
+ );
+ };
+ }, []);
+ return (
+
+
+ <>Text>
+ A
+ <>
+ B
+ >
+
+
+ );
+ }
- childARef.current.addEventListener('click', () => {
- logs.push('A');
- });
+ await act(() => {
+ root.render( );
+ });
- childBRef.current.addEventListener('click', () => {
- logs.push('B');
- });
+ childARef.current.addEventListener('click', () => {
+ logs.push('A');
+ });
- // Clicking on the parent should not trigger any listeners
- parentRef.current.click();
- expect(logs).toEqual([]);
+ childBRef.current.addEventListener('click', () => {
+ logs.push('B');
+ });
- // Clicking a child triggers its own listeners and the Fragment's
- childARef.current.click();
- expect(logs).toEqual(['fragmentRef', 'A']);
+ // Clicking on the parent should not trigger any listeners
+ parentRef.current.click();
+ expect(logs).toEqual([]);
- logs = [];
+ // Clicking a child triggers its own listeners and the Fragment's
+ childARef.current.click();
+ expect(logs).toEqual(['fragmentRef', 'A']);
- childBRef.current.click();
- expect(logs).toEqual(['fragmentRef', 'B']);
+ logs = [];
- logs = [];
+ childBRef.current.click();
+ expect(logs).toEqual(['fragmentRef', 'B']);
- fragmentRef.current.removeEventListener('click', handleFragmentRefClicks);
+ logs = [];
- childARef.current.click();
- expect(logs).toEqual(['A']);
+ fragmentRef.current.removeEventListener(
+ 'click',
+ handleFragmentRefClicks,
+ );
- logs = [];
+ childARef.current.click();
+ expect(logs).toEqual(['A']);
- childBRef.current.click();
- expect(logs).toEqual(['B']);
- });
+ logs = [];
- // @gate enableFragmentRefs
- it('adds and removes event listeners from children with multiple fragments', async () => {
- const fragmentRef = React.createRef();
- const nestedFragmentRef = React.createRef();
- const nestedFragmentRef2 = React.createRef();
- const childARef = React.createRef();
- const childBRef = React.createRef();
- const childCRef = React.createRef();
- const root = ReactDOMClient.createRoot(container);
+ childBRef.current.click();
+ expect(logs).toEqual(['B']);
+ });
- await act(() => {
- root.render(
-
-
- A
-
-
- B
+ // @gate enableFragmentRefs
+ it('adds and removes event listeners from children with multiple fragments', async () => {
+ const fragmentRef = React.createRef();
+ const nestedFragmentRef = React.createRef();
+ const nestedFragmentRef2 = React.createRef();
+ const childARef = React.createRef();
+ const childBRef = React.createRef();
+ const childCRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
+
+ await act(() => {
+ root.render(
+
-
- C
-
-
,
- );
- });
+ ,
+ );
+ });
- let logs = [];
+ let logs = [];
- function handleFragmentRefClicks() {
- logs.push('fragmentRef');
- }
+ function handleFragmentRefClicks() {
+ logs.push('fragmentRef');
+ }
- function handleNestedFragmentRefClicks() {
- logs.push('nestedFragmentRef');
- }
+ function handleNestedFragmentRefClicks() {
+ logs.push('nestedFragmentRef');
+ }
- function handleNestedFragmentRef2Clicks() {
- logs.push('nestedFragmentRef2');
- }
+ function handleNestedFragmentRef2Clicks() {
+ logs.push('nestedFragmentRef2');
+ }
- fragmentRef.current.addEventListener('click', handleFragmentRefClicks);
- nestedFragmentRef.current.addEventListener(
- 'click',
- handleNestedFragmentRefClicks,
- );
- nestedFragmentRef2.current.addEventListener(
- 'click',
- handleNestedFragmentRef2Clicks,
- );
+ fragmentRef.current.addEventListener('click', handleFragmentRefClicks);
+ nestedFragmentRef.current.addEventListener(
+ 'click',
+ handleNestedFragmentRefClicks,
+ );
+ nestedFragmentRef2.current.addEventListener(
+ 'click',
+ handleNestedFragmentRef2Clicks,
+ );
- childBRef.current.click();
- // Event bubbles to the parent fragment
- expect(logs).toEqual(['nestedFragmentRef', 'fragmentRef']);
+ childBRef.current.click();
+ // Event bubbles to the parent fragment
+ expect(logs).toEqual(['nestedFragmentRef', 'fragmentRef']);
- logs = [];
+ logs = [];
- childARef.current.click();
- expect(logs).toEqual(['fragmentRef']);
+ childARef.current.click();
+ expect(logs).toEqual(['fragmentRef']);
- logs = [];
- childCRef.current.click();
- expect(logs).toEqual(['fragmentRef', 'nestedFragmentRef2']);
+ logs = [];
+ childCRef.current.click();
+ expect(logs).toEqual(['fragmentRef', 'nestedFragmentRef2']);
- logs = [];
+ logs = [];
- fragmentRef.current.removeEventListener('click', handleFragmentRefClicks);
- nestedFragmentRef.current.removeEventListener(
- 'click',
- handleNestedFragmentRefClicks,
- );
- childCRef.current.click();
- expect(logs).toEqual(['nestedFragmentRef2']);
- });
+ fragmentRef.current.removeEventListener(
+ 'click',
+ handleFragmentRefClicks,
+ );
+ nestedFragmentRef.current.removeEventListener(
+ 'click',
+ handleNestedFragmentRefClicks,
+ );
+ childCRef.current.click();
+ expect(logs).toEqual(['nestedFragmentRef2']);
+ });
- // @gate enableFragmentRefs
- it('adds an event listener to a newly added child', async () => {
- const fragmentRef = React.createRef();
- const childRef = React.createRef();
- const root = ReactDOMClient.createRoot(container);
- let showChild;
+ // @gate enableFragmentRefs
+ it('adds an event listener to a newly added child', async () => {
+ const fragmentRef = React.createRef();
+ const childRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
+ let showChild;
- function Component() {
- const [shouldShowChild, setShouldShowChild] = React.useState(false);
- showChild = () => {
- setShouldShowChild(true);
- };
+ function Component() {
+ const [shouldShowChild, setShouldShowChild] = React.useState(false);
+ showChild = () => {
+ setShouldShowChild(true);
+ };
- return (
-
-
- A
- {shouldShowChild && (
-
- B
-
- )}
-
-
- );
- }
+ return (
+
+
+ A
+ {shouldShowChild && (
+
+ B
+
+ )}
+
+
+ );
+ }
- await act(() => {
- root.render( );
- });
+ await act(() => {
+ root.render( );
+ });
- expect(fragmentRef.current).not.toBe(null);
- expect(childRef.current).toBe(null);
+ expect(fragmentRef.current).not.toBe(null);
+ expect(childRef.current).toBe(null);
- let hasClicked = false;
- fragmentRef.current.addEventListener('click', () => {
- hasClicked = true;
- });
+ let hasClicked = false;
+ fragmentRef.current.addEventListener('click', () => {
+ hasClicked = true;
+ });
- await act(() => {
- showChild();
- });
- expect(childRef.current).not.toBe(null);
+ await act(() => {
+ showChild();
+ });
+ expect(childRef.current).not.toBe(null);
- childRef.current.click();
- expect(hasClicked).toBe(true);
- });
+ childRef.current.click();
+ expect(hasClicked).toBe(true);
+ });
- // @gate enableFragmentRefs
- it('applies event listeners to host children nested within non-host children', async () => {
- const fragmentRef = React.createRef();
- const childRef = React.createRef();
- const nestedChildRef = React.createRef();
- const root = ReactDOMClient.createRoot(container);
+ // @gate enableFragmentRefs
+ it('applies event listeners to host children nested within non-host children', async () => {
+ const fragmentRef = React.createRef();
+ const childRef = React.createRef();
+ const nestedChildRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
- await act(() => {
- root.render(
-
-
- Host A
-
+ await act(() => {
+ root.render(
+
+
+ Host A
- Host B
+
+ Host B
+
-
-
-
,
- );
- });
- const logs = [];
- fragmentRef.current.addEventListener('click', e => {
- logs.push(e.target.textContent);
- });
-
- expect(logs).toEqual([]);
- childRef.current.click();
- expect(logs).toEqual(['Host A']);
- nestedChildRef.current.click();
- expect(logs).toEqual(['Host A', 'Host B']);
- });
-
- // @gate enableFragmentRefs
- it('allows adding and cleaning up listeners in effects', async () => {
- const root = ReactDOMClient.createRoot(container);
-
- let logs = [];
- function logClick(e) {
- logs.push(e.currentTarget.id);
- }
-
- let rerender;
- let removeEventListeners;
-
- function Test() {
- const fragmentRef = React.useRef(null);
- // eslint-disable-next-line no-unused-vars
- const [_, setState] = React.useState(0);
- rerender = () => {
- setState(p => p + 1);
- };
- removeEventListeners = () => {
- fragmentRef.current.removeEventListener('click', logClick);
- };
- React.useEffect(() => {
- fragmentRef.current.addEventListener('click', logClick);
-
- return removeEventListeners;
+
+
,
+ );
+ });
+ const logs = [];
+ fragmentRef.current.addEventListener('click', e => {
+ logs.push(e.target.textContent);
});
- return (
-
-
-
- );
- }
-
- // The event listener was applied
- await act(() => root.render( ));
- expect(logs).toEqual([]);
- document.querySelector('#child-a').click();
- expect(logs).toEqual(['child-a']);
+ expect(logs).toEqual([]);
+ childRef.current.click();
+ expect(logs).toEqual(['Host A']);
+ nestedChildRef.current.click();
+ expect(logs).toEqual(['Host A', 'Host B']);
+ });
- // The event listener can be removed and re-added
- logs = [];
- await act(rerender);
- document.querySelector('#child-a').click();
- expect(logs).toEqual(['child-a']);
- });
+ // @gate enableFragmentRefs
+ it('allows adding and cleaning up listeners in effects', async () => {
+ const root = ReactDOMClient.createRoot(container);
- // @gate enableFragmentRefs
- it('does not apply removed event listeners to new children', async () => {
- const root = ReactDOMClient.createRoot(container);
- const fragmentRef = React.createRef(null);
- function Test() {
- return (
-
-
-
- );
- }
+ let logs = [];
+ function logClick(e) {
+ logs.push(e.currentTarget.id);
+ }
- let logs = [];
- function logClick(e) {
- logs.push(e.currentTarget.id);
- }
- await act(() => {
- root.render( );
- });
- fragmentRef.current.addEventListener('click', logClick);
- const childA = document.querySelector('#child-a');
- childA.click();
- expect(logs).toEqual(['child-a']);
+ let rerender;
+ let removeEventListeners;
- logs = [];
- fragmentRef.current.removeEventListener('click', logClick);
- childA.click();
- expect(logs).toEqual([]);
- });
+ function Test() {
+ const fragmentRef = React.useRef(null);
+ // eslint-disable-next-line no-unused-vars
+ const [_, setState] = React.useState(0);
+ rerender = () => {
+ setState(p => p + 1);
+ };
+ removeEventListeners = () => {
+ fragmentRef.current.removeEventListener('click', logClick);
+ };
+ React.useEffect(() => {
+ fragmentRef.current.addEventListener('click', logClick);
- // @gate enableFragmentRefs
- it('applies event listeners to portaled children', async () => {
- const fragmentRef = React.createRef();
- const childARef = React.createRef();
- const childBRef = React.createRef();
- const root = ReactDOMClient.createRoot(container);
+ return removeEventListeners;
+ });
- function Test() {
- return (
-
-
- {createPortal(
, document.body)}
-
- );
- }
+ return (
+
+
+
+ );
+ }
- await act(() => {
- root.render( );
- });
+ // The event listener was applied
+ await act(() => root.render( ));
+ expect(logs).toEqual([]);
+ document.querySelector('#child-a').click();
+ expect(logs).toEqual(['child-a']);
- const logs = [];
- fragmentRef.current.addEventListener('click', e => {
- logs.push(e.target.id);
+ // The event listener can be removed and re-added
+ logs = [];
+ await act(rerender);
+ document.querySelector('#child-a').click();
+ expect(logs).toEqual(['child-a']);
});
- childARef.current.click();
- expect(logs).toEqual(['child-a']);
-
- logs.length = 0;
- childBRef.current.click();
- expect(logs).toEqual(['child-b']);
- });
-
- describe('with activity', () => {
- // @gate enableFragmentRefs && enableActivity
- it('does not apply event listeners to hidden trees', async () => {
- const parentRef = React.createRef();
- const fragmentRef = React.createRef();
+ // @gate enableFragmentRefs
+ it('does not apply removed event listeners to new children', async () => {
const root = ReactDOMClient.createRoot(container);
-
+ const fragmentRef = React.createRef(null);
function Test() {
return (
-
-
- Child 1
-
- Child 2
-
- Child 3
-
-
+
+
+
);
}
+ let logs = [];
+ function logClick(e) {
+ logs.push(e.currentTarget.id);
+ }
await act(() => {
root.render( );
});
+ fragmentRef.current.addEventListener('click', logClick);
+ const childA = document.querySelector('#child-a');
+ childA.click();
+ expect(logs).toEqual(['child-a']);
- const logs = [];
- fragmentRef.current.addEventListener('click', e => {
- logs.push(e.target.textContent);
- });
-
- const [child1, child2, child3] = parentRef.current.children;
- child1.click();
- child2.click();
- child3.click();
- expect(logs).toEqual(['Child 1', 'Child 3']);
+ logs = [];
+ fragmentRef.current.removeEventListener('click', logClick);
+ childA.click();
+ expect(logs).toEqual([]);
});
- // @gate enableFragmentRefs && enableActivity
- it('applies event listeners to visible trees', async () => {
- const parentRef = React.createRef();
+ // @gate enableFragmentRefs
+ it('applies event listeners to portaled children', async () => {
const fragmentRef = React.createRef();
+ const childARef = React.createRef();
+ const childBRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
function Test() {
return (
-
-
- Child 1
-
- Child 2
-
- Child 3
-
-
+
+
+ {createPortal(
+
,
+ document.body,
+ )}
+
);
}
@@ -712,67 +702,242 @@ describe('FragmentRefs', () => {
const logs = [];
fragmentRef.current.addEventListener('click', e => {
- logs.push(e.target.textContent);
+ logs.push(e.target.id);
+ });
+
+ childARef.current.click();
+ expect(logs).toEqual(['child-a']);
+
+ logs.length = 0;
+ childBRef.current.click();
+ expect(logs).toEqual(['child-b']);
+ });
+
+ describe('with activity', () => {
+ // @gate enableFragmentRefs && enableActivity
+ it('does not apply event listeners to hidden trees', async () => {
+ const parentRef = React.createRef();
+ const fragmentRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
+
+ function Test() {
+ return (
+
+
+ Child 1
+
+ Child 2
+
+ Child 3
+
+
+ );
+ }
+
+ await act(() => {
+ root.render( );
+ });
+
+ const logs = [];
+ fragmentRef.current.addEventListener('click', e => {
+ logs.push(e.target.textContent);
+ });
+
+ const [child1, child2, child3] = parentRef.current.children;
+ child1.click();
+ child2.click();
+ child3.click();
+ expect(logs).toEqual(['Child 1', 'Child 3']);
});
- const [child1, child2, child3] = parentRef.current.children;
- child1.click();
- child2.click();
- child3.click();
- expect(logs).toEqual(['Child 1', 'Child 2', 'Child 3']);
+ // @gate enableFragmentRefs && enableActivity
+ it('applies event listeners to visible trees', async () => {
+ const parentRef = React.createRef();
+ const fragmentRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
+
+ function Test() {
+ return (
+
+
+ Child 1
+
+ Child 2
+
+ Child 3
+
+
+ );
+ }
+
+ await act(() => {
+ root.render( );
+ });
+
+ const logs = [];
+ fragmentRef.current.addEventListener('click', e => {
+ logs.push(e.target.textContent);
+ });
+
+ const [child1, child2, child3] = parentRef.current.children;
+ child1.click();
+ child2.click();
+ child3.click();
+ expect(logs).toEqual(['Child 1', 'Child 2', 'Child 3']);
+ });
+
+ // @gate enableFragmentRefs && enableActivity
+ it('handles Activity modes switching', async () => {
+ const fragmentRef = React.createRef();
+ const fragmentRef2 = React.createRef();
+ const parentRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
+
+ function Test({mode}) {
+ return (
+
+
+
+ Child
+
+ Child 2
+
+
+
+
+ );
+ }
+
+ await act(() => {
+ root.render( );
+ });
+
+ let logs = [];
+ fragmentRef.current.addEventListener('click', () => {
+ logs.push('clicked 1');
+ });
+ fragmentRef2.current.addEventListener('click', () => {
+ logs.push('clicked 2');
+ });
+ parentRef.current.lastChild.click();
+ expect(logs).toEqual(['clicked 1', 'clicked 2']);
+
+ logs = [];
+ await act(() => {
+ root.render( );
+ });
+ parentRef.current.firstChild.click();
+ parentRef.current.lastChild.click();
+ expect(logs).toEqual([]);
+
+ logs = [];
+ await act(() => {
+ root.render( );
+ });
+ parentRef.current.lastChild.click();
+ // Event order is flipped here because the nested child re-registers first
+ expect(logs).toEqual(['clicked 2', 'clicked 1']);
+ });
});
+ });
- // @gate enableFragmentRefs && enableActivity
- it('handles Activity modes switching', async () => {
+ describe('dispatchEvent()', () => {
+ // @gate enableFragmentRefs
+ it('fires events on the host parent if bubbles=true', async () => {
const fragmentRef = React.createRef();
- const fragmentRef2 = React.createRef();
- const parentRef = React.createRef();
const root = ReactDOMClient.createRoot(container);
+ let logs = [];
- function Test({mode}) {
+ function handleClick(e) {
+ logs.push([e.type, e.target.id, e.currentTarget.id]);
+ }
+
+ function Test({isMounted}) {
return (
-
-
-
- Child
-
- Child 2
+
+
+ {isMounted && (
+
+
+ Hi
+
-
-
+ )}
+
);
}
await act(() => {
- root.render( );
+ root.render( );
});
- let logs = [];
- fragmentRef.current.addEventListener('click', () => {
- logs.push('clicked 1');
- });
- fragmentRef2.current.addEventListener('click', () => {
- logs.push('clicked 2');
- });
- parentRef.current.lastChild.click();
- expect(logs).toEqual(['clicked 1', 'clicked 2']);
+ let isCancelable = !fragmentRef.current.dispatchEvent(
+ new MouseEvent('click', {bubbles: true}),
+ );
+ expect(logs).toEqual([
+ ['click', 'parent', 'parent'],
+ ['click', 'parent', 'grandparent'],
+ ]);
+ expect(isCancelable).toBe(false);
- logs = [];
+ const fragmentInstanceHandle = fragmentRef.current;
await act(() => {
- root.render( );
+ root.render( );
});
- parentRef.current.firstChild.click();
- parentRef.current.lastChild.click();
+ logs = [];
+ isCancelable = !fragmentInstanceHandle.dispatchEvent(
+ new MouseEvent('click', {bubbles: true}),
+ );
expect(logs).toEqual([]);
+ expect(isCancelable).toBe(false);
logs = [];
+ isCancelable = !fragmentInstanceHandle.dispatchEvent(
+ new MouseEvent('click', {bubbles: false}),
+ );
+ expect(logs).toEqual([]);
+ expect(isCancelable).toBe(false);
+ });
+
+ // @gate enableFragmentRefs
+ it('fires events on self, and only self if bubbles=false', async () => {
+ const fragmentRef = React.createRef();
+ const root = ReactDOMClient.createRoot(container);
+ let logs = [];
+
+ function handleClick(e) {
+ logs.push([e.type, e.target.id, e.currentTarget.id]);
+ }
+
+ function Test() {
+ return (
+
+
+
+ );
+ }
+
await act(() => {
- root.render( );
+ root.render( );
});
- parentRef.current.lastChild.click();
- // Event order is flipped here because the nested child re-registers first
- expect(logs).toEqual(['clicked 2', 'clicked 1']);
+
+ fragmentRef.current.addEventListener('click', handleClick);
+
+ fragmentRef.current.dispatchEvent(
+ new MouseEvent('click', {bubbles: true}),
+ );
+ expect(logs).toEqual([
+ ['click', undefined, undefined],
+ ['click', 'parent', 'parent'],
+ ]);
+
+ logs = [];
+
+ fragmentRef.current.dispatchEvent(
+ new MouseEvent('click', {bubbles: false}),
+ );
+ expect(logs).toEqual([['click', undefined, undefined]]);
});
});
});
diff --git a/packages/react-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js
index d032d3247e475..45da707dfea23 100644
--- a/packages/react-reconciler/src/ReactFiberTreeReflection.js
+++ b/packages/react-reconciler/src/ReactFiberTreeReflection.js
@@ -354,6 +354,16 @@ export function traverseFragmentInstance(
traverseVisibleHostChildren(fragmentFiber.child, false, fn, a, b, c);
}
+export function traverseFragmentInstanceDeeply (
+ fragmentFiber: Fiber,
+ fn: (Fiber, A, B, C) => boolean,
+ a: A,
+ b: B,
+ c: C,
+): void {
+ traverseVisibleHostChildren(fragmentFiber.child, true, fn, a, b, c);
+}
+
function traverseVisibleHostChildren (
child: Fiber | null,
searchWithinHosts: boolean,
@@ -363,24 +373,8 @@ function traverseVisibleHostChildren (
c: C,
): boolean {
while (child !== null) {
- if (child.tag === HostComponent) {
- if (fn(child, a, b, c)) {
- return true;
- }
- if (searchWithinHosts) {
- if (
- traverseVisibleHostChildren(
- child.child,
- searchWithinHosts,
- fn,
- a,
- b,
- c,
- )
- ) {
- return true;
- }
- }
+ if (child.tag === HostComponent && fn(child, a, b, c)) {
+ return true;
} else if (
child.tag === OffscreenComponent &&
child.memoizedState !== null
@@ -388,6 +382,7 @@ function traverseVisibleHostChildren (
// Skip hidden subtrees
} else {
if (
+ (searchWithinHosts || child.tag !== HostComponent) &&
traverseVisibleHostChildren(child.child, searchWithinHosts, fn, a, b, c)
) {
return true;
diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js
index 98b17cc92088e..ed6d9c443b7bc 100644
--- a/packages/react-server/src/ReactFlightServer.js
+++ b/packages/react-server/src/ReactFlightServer.js
@@ -62,7 +62,7 @@ import type {
ReactAsyncInfo,
ReactTimeInfo,
ReactStackTrace,
- ReactCallSite,
+ ReactFunctionLocation,
ReactErrorInfo,
ReactErrorInfoDev,
} from 'shared/ReactTypes';
@@ -152,11 +152,27 @@ function defaultFilterStackFrame(
);
}
+// DEV-only cache of parsed and filtered stack frames.
+const stackTraceCache: WeakMap = __DEV__
+ ? new WeakMap()
+ : (null: any);
+
function filterStackTrace(
request: Request,
error: Error,
skipFrames: number,
): ReactStackTrace {
+ const existing = stackTraceCache.get(error);
+ if (existing !== undefined) {
+ // Return a clone because the Flight protocol isn't yet resilient to deduping
+ // objects in the debug info. TODO: Support deduping stacks.
+ const clone = existing.slice(0);
+ for (let i = 0; i < clone.length; i++) {
+ // $FlowFixMe[invalid-tuple-arity]
+ clone[i] = clone[i].slice(0);
+ }
+ return clone;
+ }
// Since stacks can be quite large and we pass a lot of them, we filter them out eagerly
// to save bandwidth even in DEV. We'll also replay these stacks on the client so by
// stripping them early we avoid that overhead. Otherwise we'd normally just rely on
@@ -183,6 +199,7 @@ function filterStackTrace(
i--;
}
}
+ stackTraceCache.set(error, stack);
return stack;
}
@@ -2072,7 +2089,7 @@ function serializeServerReference(
const bound = boundArgs === null ? null : Promise.resolve(boundArgs);
const id = getServerReferenceId(request.bundlerConfig, serverReference);
- let location: null | ReactCallSite = null;
+ let location: null | ReactFunctionLocation = null;
if (__DEV__) {
const error = getServerReferenceLocation(
request.bundlerConfig,
@@ -2081,7 +2098,13 @@ function serializeServerReference(
if (error) {
const frames = parseStackTrace(error, 1);
if (frames.length > 0) {
- location = frames[0];
+ const firstFrame = frames[0];
+ location = [
+ firstFrame[0],
+ firstFrame[1],
+ firstFrame[2], // The line and col of the callsite represents the
+ firstFrame[3], // enclosing line and col of the function.
+ ];
}
}
}
@@ -2091,7 +2114,7 @@ function serializeServerReference(
bound: null | Promise>,
name?: string, // DEV-only
env?: string, // DEV-only
- location?: ReactCallSite, // DEV-only
+ location?: ReactFunctionLocation, // DEV-only
} =
__DEV__ && location !== null
? {
diff --git a/packages/react-server/src/ReactFlightStackConfigV8.js b/packages/react-server/src/ReactFlightStackConfigV8.js
index 981e62dbdbb5f..25bc2aba9de8b 100644
--- a/packages/react-server/src/ReactFlightStackConfigV8.js
+++ b/packages/react-server/src/ReactFlightStackConfigV8.js
@@ -9,23 +9,114 @@
import type {ReactStackTrace} from 'shared/ReactTypes';
-import DefaultPrepareStackTrace from 'shared/DefaultPrepareStackTrace';
+let framesToSkip: number = 0;
+let collectedStackTrace: null | ReactStackTrace = null;
-function getStack(error: Error): string {
- // We override Error.prepareStackTrace with our own version that normalizes
- // the stack to V8 formatting even if the server uses other formatting.
- // It also ensures that source maps are NOT applied to this since that can
- // be slow we're better off doing that lazily from the client instead of
- // eagerly on the server. If the stack has already been read, then we might
- // not get a normalized stack and it might still have been source mapped.
- const previousPrepare = Error.prepareStackTrace;
- Error.prepareStackTrace = DefaultPrepareStackTrace;
- try {
- // eslint-disable-next-line react-internal/safe-string-coercion
- return String(error.stack);
- } finally {
- Error.prepareStackTrace = previousPrepare;
+const identifierRegExp = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/;
+
+function getMethodCallName(callSite: CallSite): string {
+ const typeName = callSite.getTypeName();
+ const methodName = callSite.getMethodName();
+ const functionName = callSite.getFunctionName();
+ let result = '';
+ if (functionName) {
+ if (
+ typeName &&
+ identifierRegExp.test(functionName) &&
+ functionName !== typeName
+ ) {
+ result += typeName + '.';
+ }
+ result += functionName;
+ if (
+ methodName &&
+ functionName !== methodName &&
+ !functionName.endsWith('.' + methodName) &&
+ !functionName.endsWith(' ' + methodName)
+ ) {
+ result += ' [as ' + methodName + ']';
+ }
+ } else {
+ if (typeName) {
+ result += typeName + '.';
+ }
+ if (methodName) {
+ result += methodName;
+ } else {
+ result += '';
+ }
+ }
+ return result;
+}
+
+function collectStackTrace(
+ error: Error,
+ structuredStackTrace: CallSite[],
+): string {
+ const result: ReactStackTrace = [];
+ // Collect structured stack traces from the callsites.
+ // We mirror how V8 serializes stack frames and how we later parse them.
+ for (let i = framesToSkip; i < structuredStackTrace.length; i++) {
+ const callSite = structuredStackTrace[i];
+ let name = callSite.getFunctionName() || '';
+ if (name === 'react-stack-bottom-frame') {
+ // Skip everything after the bottom frame since it'll be internals.
+ break;
+ } else if (callSite.isNative()) {
+ result.push([name, '', 0, 0, 0, 0]);
+ } else {
+ // We encode complex function calls as if they're part of the function
+ // name since we cannot simulate the complex ones and they look the same
+ // as function names in UIs on the client as well as stacks.
+ if (callSite.isConstructor()) {
+ name = 'new ' + name;
+ } else if (!callSite.isToplevel()) {
+ name = getMethodCallName(callSite);
+ }
+ if (name === '') {
+ name = '';
+ }
+ let filename = callSite.getScriptNameOrSourceURL() || '';
+ if (filename === '') {
+ filename = '';
+ }
+ if (callSite.isEval() && !filename) {
+ const origin = callSite.getEvalOrigin();
+ if (origin) {
+ filename = origin.toString() + ', ';
+ }
+ }
+ const line = callSite.getLineNumber() || 0;
+ const col = callSite.getColumnNumber() || 0;
+ const enclosingLine: number =
+ // $FlowFixMe[prop-missing]
+ typeof callSite.getEnclosingLineNumber === 'function'
+ ? (callSite: any).getEnclosingLineNumber() || 0
+ : 0;
+ const enclosingCol: number =
+ // $FlowFixMe[prop-missing]
+ typeof callSite.getEnclosingColumnNumber === 'function'
+ ? (callSite: any).getEnclosingColumnNumber() || 0
+ : 0;
+ result.push([name, filename, line, col, enclosingLine, enclosingCol]);
+ }
}
+ // At the same time we generate a string stack trace just in case someone
+ // else reads it. Ideally, we'd call the previous prepareStackTrace to
+ // ensure it's in the expected format but it's common for that to be
+ // source mapped and since we do a lot of eager parsing of errors, it
+ // would be slow in those environments. We could maybe just rely on those
+ // environments having to disable source mapping globally to speed things up.
+ // For now, we just generate a default V8 formatted stack trace without
+ // source mapping as a fallback.
+ const name = error.name || 'Error';
+ const message = error.message || '';
+ let stack = name + ': ' + message;
+ for (let i = 0; i < structuredStackTrace.length; i++) {
+ stack += '\n at ' + structuredStackTrace[i].toString();
+ }
+ collectedStackTrace = result;
+ return stack;
}
// This matches either of these V8 formats.
@@ -39,7 +130,32 @@ export function parseStackTrace(
error: Error,
skipFrames: number,
): ReactStackTrace {
- let stack = getStack(error);
+ // We override Error.prepareStackTrace with our own version that collects
+ // the structured data. We need more information than the raw stack gives us
+ // and we need to ensure that we don't get the source mapped version.
+ collectedStackTrace = null;
+ framesToSkip = skipFrames;
+ const previousPrepare = Error.prepareStackTrace;
+ Error.prepareStackTrace = collectStackTrace;
+ let stack;
+ try {
+ // eslint-disable-next-line react-internal/safe-string-coercion
+ stack = String(error.stack);
+ } finally {
+ Error.prepareStackTrace = previousPrepare;
+ }
+
+ if (collectedStackTrace !== null) {
+ const result = collectedStackTrace;
+ collectedStackTrace = null;
+ return result;
+ }
+
+ // If the stack has already been read, or this is not actually a V8 compatible
+ // engine then we might not get a normalized stack and it might still have been
+ // source mapped. Regardless we try our best to parse it. This works best if the
+ // environment just uses default V8 formatting and no source mapping.
+
if (stack.startsWith('Error: react-stack-top-frame\n')) {
// V8's default formatting prefixes with the error message which we
// don't want/need.
@@ -73,7 +189,7 @@ export function parseStackTrace(
}
const line = +(parsed[3] || parsed[6]);
const col = +(parsed[4] || parsed[7]);
- parsedFrames.push([name, filename, line, col]);
+ parsedFrames.push([name, filename, line, col, 0, 0]);
}
return parsedFrames;
}
diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js
index e199fa9e7b0b7..4c8ca77bb160b 100644
--- a/packages/shared/ReactTypes.js
+++ b/packages/shared/ReactTypes.js
@@ -186,10 +186,19 @@ export type ReactCallSite = [
string, // file name TODO: model nested eval locations as nested arrays
number, // line number
number, // column number
+ number, // enclosing line number
+ number, // enclosing column number
];
export type ReactStackTrace = Array;
+export type ReactFunctionLocation = [
+ string, // function name
+ string, // file name TODO: model nested eval locations as nested arrays
+ number, // enclosing line number
+ number, // enclosing column number
+];
+
export type ReactComponentInfo = {
+name: string,
+env?: string,