diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/CompareDocumentPositionCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/CompareDocumentPositionCase.js new file mode 100644 index 00000000000..aada4e33fa4 --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/CompareDocumentPositionCase.js @@ -0,0 +1,53 @@ +import TestCase from '../../TestCase'; +import Fixture from '../../Fixture'; +import CompareDocumentPositionFragmentContainer from './CompareDocumentPositionFragmentContainer'; + +const React = window.React; + +export default function CompareDocumentPositionCase() { + return ( + + +
  • Click the "Compare All Positions" button
  • +
    + + The compareDocumentPosition method compares the position of the fragment + relative to other elements in the DOM. The "Before Element" should be + PRECEDING the fragment, and the "After Element" should be FOLLOWING. + Elements inside the fragment should be CONTAINED_BY. + + + + +
    + First child element +
    +
    + Second child element +
    +
    + Third child element +
    +
    +
    +
    +
    + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/CompareDocumentPositionFragmentContainer.js b/fixtures/dom/src/components/fixtures/fragment-refs/CompareDocumentPositionFragmentContainer.js new file mode 100644 index 00000000000..380bed49cb4 --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/CompareDocumentPositionFragmentContainer.js @@ -0,0 +1,246 @@ +const React = window.React; +const {Fragment, useRef, useState} = React; + +const POSITION_FLAGS = { + DISCONNECTED: 0x01, + PRECEDING: 0x02, + FOLLOWING: 0x04, + CONTAINS: 0x08, + CONTAINED_BY: 0x10, + IMPLEMENTATION_SPECIFIC: 0x20, +}; + +function getPositionDescription(bitmask) { + const flags = []; + if (bitmask & POSITION_FLAGS.DISCONNECTED) flags.push('DISCONNECTED'); + if (bitmask & POSITION_FLAGS.PRECEDING) flags.push('PRECEDING'); + if (bitmask & POSITION_FLAGS.FOLLOWING) flags.push('FOLLOWING'); + if (bitmask & POSITION_FLAGS.CONTAINS) flags.push('CONTAINS'); + if (bitmask & POSITION_FLAGS.CONTAINED_BY) flags.push('CONTAINED_BY'); + if (bitmask & POSITION_FLAGS.IMPLEMENTATION_SPECIFIC) + flags.push('IMPLEMENTATION_SPECIFIC'); + return flags.length > 0 ? flags.join(' | ') : 'SAME'; +} + +function ResultRow({label, result, color}) { + if (!result) return null; + + return ( +
    +
    + {label} +
    +
    + Raw value: + {result.raw} + Flags: + + {getPositionDescription(result.raw)} + +
    +
    + ); +} + +export default function CompareDocumentPositionFragmentContainer({children}) { + const fragmentRef = useRef(null); + const beforeRef = useRef(null); + const afterRef = useRef(null); + const insideRef = useRef(null); + const [results, setResults] = useState(null); + + const compareAll = () => { + const fragment = fragmentRef.current; + const beforePos = fragment.compareDocumentPosition(beforeRef.current); + const afterPos = fragment.compareDocumentPosition(afterRef.current); + const insidePos = insideRef.current + ? fragment.compareDocumentPosition(insideRef.current) + : null; + + setResults({ + before: {raw: beforePos}, + after: {raw: afterPos}, + inside: insidePos !== null ? {raw: insidePos} : null, + }); + }; + + return ( + +
    + + {results && ( + + Comparison complete + + )} +
    + +
    +
    +
    +
    + Before Element +
    + +
    +
    + FRAGMENT +
    +
    + {children} +
    +
    + +
    + After Element +
    +
    +
    + +
    +
    + Comparison Results +
    + + {!results && ( +
    + Click "Compare All Positions" to see results +
    + )} + + {results && ( + + + + {results.inside && ( + + )} + +
    + Flag Reference: +
    + 0x01 + DISCONNECTED + 0x02 + PRECEDING (other is before fragment) + 0x04 + FOLLOWING (other is after fragment) + 0x08 + CONTAINS (other contains fragment) + 0x10 + CONTAINED_BY (other is inside fragment) +
    +
    +
    + )} +
    +
    +
    + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/EventFragmentContainer.js b/fixtures/dom/src/components/fixtures/fragment-refs/EventFragmentContainer.js new file mode 100644 index 00000000000..33c99390a4d --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/EventFragmentContainer.js @@ -0,0 +1,112 @@ +const React = window.React; +const {Fragment, useRef, useState} = React; + +export default function EventFragmentContainer({children}) { + const fragmentRef = useRef(null); + const [eventLog, setEventLog] = useState([]); + const [listenerAdded, setListenerAdded] = useState(false); + const [bubblesState, setBubblesState] = useState(true); + + const logEvent = message => { + setEventLog(prev => [...prev, message]); + }; + + const fragmentClickHandler = () => { + logEvent('Fragment event listener fired'); + }; + + const addListener = () => { + fragmentRef.current.addEventListener('click', fragmentClickHandler); + setListenerAdded(true); + logEvent('Added click listener to fragment'); + }; + + const removeListener = () => { + fragmentRef.current.removeEventListener('click', fragmentClickHandler); + setListenerAdded(false); + logEvent('Removed click listener from fragment'); + }; + + const dispatchClick = () => { + fragmentRef.current.dispatchEvent( + new MouseEvent('click', {bubbles: bubblesState}) + ); + logEvent(`Dispatched click event (bubbles: ${bubblesState})`); + }; + + const clearLog = () => { + setEventLog([]); + }; + + return ( + +
    + + + + + +
    + +
    logEvent('Parent div clicked')} + style={{ + padding: '12px', + border: '1px dashed #ccc', + borderRadius: '4px', + backgroundColor: '#fff', + }}> + {children} +
    + + {eventLog.length > 0 && ( +
    + Event Log: + +
    + )} +
    + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/EventListenerCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/EventListenerCase.js index 125b67cf39a..a6e32422bc2 100644 --- a/fixtures/dom/src/components/fixtures/fragment-refs/EventListenerCase.js +++ b/fixtures/dom/src/components/fixtures/fragment-refs/EventListenerCase.js @@ -1,46 +1,35 @@ import TestCase from '../../TestCase'; import Fixture from '../../Fixture'; +import EventFragmentContainer from './EventFragmentContainer'; const React = window.React; -const {Fragment, useEffect, useRef, useState} = React; +const {useState} = React; function WrapperComponent(props) { return props.children; } -function handler(e) { - const text = e.currentTarget.innerText; - alert('You clicked: ' + text); -} - export default function EventListenerCase() { - const fragmentRef = useRef(null); const [extraChildCount, setExtraChildCount] = useState(0); - useEffect(() => { - fragmentRef.current.addEventListener('click', handler); - - const lastFragmentRefValue = fragmentRef.current; - return () => { - lastFragmentRefValue.removeEventListener('click', handler); - }; - }); - return ( -
  • Click one of the children, observe the alert
  • -
  • Add a new child, click it, observe the alert
  • -
  • Remove the event listeners, click a child, observe no alert
  • -
  • Add the event listeners back, click a child, observe the alert
  • +
  • + Click "Add event listener" to attach a click handler to the fragment +
  • +
  • Click "Dispatch click event" to dispatch a click event
  • +
  • Observe the event log showing the event fired
  • +
  • Add a new child, dispatch again to see it still works
  • +
  • + Click "Remove event listener" and dispatch again to see no event fires +
  • Fragment refs can manage event listeners on the first level of host - children. This page loads with an effect that sets up click event - hanndlers on each child card. Clicking on a card will show an alert - with the card's text. + children. The event log shows when events are dispatched and handled.

    New child nodes will also have event listeners applied. Removed nodes @@ -50,28 +39,17 @@ export default function EventListenerCase() { -

    Target count: {extraChildCount + 3}
    - - - - -
    - +
    + Target count: {extraChildCount + 3} + +
    +
    Child A
    @@ -88,8 +66,8 @@ export default function EventListenerCase() {
    ))} - - + +
    ); diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/GetClientRectsCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/GetClientRectsCase.js index 563f2ad0542..4d9b2803dde 100644 --- a/fixtures/dom/src/components/fixtures/fragment-refs/GetClientRectsCase.js +++ b/fixtures/dom/src/components/fixtures/fragment-refs/GetClientRectsCase.js @@ -1,17 +1,10 @@ import TestCase from '../../TestCase'; import Fixture from '../../Fixture'; +import PrintRectsFragmentContainer from './PrintRectsFragmentContainer'; const React = window.React; -const {Fragment, useRef, useState} = React; export default function GetClientRectsCase() { - const fragmentRef = useRef(null); - const [rects, setRects] = useState([]); - const getRects = () => { - const rects = fragmentRef.current.getClientRects(); - setRects(rects); - }; - return ( @@ -26,74 +19,35 @@ export default function GetClientRectsCase() { - -
    -
    + - {rects.map(({x, y, width, height}, index) => { - const scale = 0.3; - - return ( -
    - ); - })} -
    -
    - {rects.map(({x, y, width, height}, index) => { - return ( -
    - {index} :: {`{`}x: {x}, y: {y}, width: {width}, height:{' '} - {height} - {`}`} -
    - ); - })} -
    -
    + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. + +
    +
    +
    - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do - eiusmod tempor incididunt ut labore et dolore magna aliqua. - -
    -
    -
    ); diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/GetRootNodeFragmentContainer.js b/fixtures/dom/src/components/fixtures/fragment-refs/GetRootNodeFragmentContainer.js new file mode 100644 index 00000000000..01244b3c87a --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/GetRootNodeFragmentContainer.js @@ -0,0 +1,79 @@ +const React = window.React; +const {Fragment, useRef, useState} = React; + +export default function GetRootNodeFragmentContainer({children}) { + const fragmentRef = useRef(null); + const [rootNodeInfo, setRootNodeInfo] = useState(null); + + const getRootNodeInfo = () => { + const rootNode = fragmentRef.current.getRootNode(); + setRootNodeInfo({ + nodeName: rootNode.nodeName, + nodeType: rootNode.nodeType, + nodeTypeLabel: getNodeTypeLabel(rootNode.nodeType), + isDocument: rootNode === document, + }); + }; + + const getNodeTypeLabel = nodeType => { + const types = { + 1: 'ELEMENT_NODE', + 3: 'TEXT_NODE', + 9: 'DOCUMENT_NODE', + 11: 'DOCUMENT_FRAGMENT_NODE', + }; + return types[nodeType] || `UNKNOWN (${nodeType})`; + }; + + return ( + +
    + +
    + + {rootNodeInfo && ( +
    +
    + Node Name: {rootNodeInfo.nodeName} +
    +
    + Node Type: {rootNodeInfo.nodeType} ( + {rootNodeInfo.nodeTypeLabel}) +
    +
    + Is Document:{' '} + {rootNodeInfo.isDocument ? 'Yes' : 'No'} +
    +
    + )} + +
    + {children} +
    +
    + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/PrintRectsFragmentContainer.js b/fixtures/dom/src/components/fixtures/fragment-refs/PrintRectsFragmentContainer.js new file mode 100644 index 00000000000..a084932a512 --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/PrintRectsFragmentContainer.js @@ -0,0 +1,126 @@ +const React = window.React; +const {Fragment, useRef, useState} = React; + +const colors = [ + '#e74c3c', + '#3498db', + '#2ecc71', + '#9b59b6', + '#f39c12', + '#1abc9c', +]; + +export default function PrintRectsFragmentContainer({children}) { + const fragmentRef = useRef(null); + const [rects, setRects] = useState([]); + + const getRects = () => { + const rectsResult = fragmentRef.current.getClientRects(); + setRects(Array.from(rectsResult)); + }; + + const getColor = index => colors[index % colors.length]; + + return ( + +
    + + {rects.length > 0 && ( + + Found {rects.length} rect{rects.length !== 1 ? 's' : ''} + + )} +
    + +
    +
    + {rects.length === 0 && ( +
    + Click button to visualize rects +
    + )} + {rects.map(({x, y, width, height}, index) => { + const scale = 0.3; + const color = getColor(index); + + return ( +
    + ); + })} +
    + +
    + {rects.map(({x, y, width, height}, index) => { + const color = getColor(index); + return ( +
    + #{index}{' '} + + x: {Math.round(x)}, y: {Math.round(y)}, w: {Math.round(width)} + , h: {Math.round(height)} + +
    + ); + })} +
    +
    + +
    + {children} +
    + + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/TextNodesCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/TextNodesCase.js new file mode 100644 index 00000000000..c6a359ac887 --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/TextNodesCase.js @@ -0,0 +1,470 @@ +import TestCase from '../../TestCase'; +import Fixture from '../../Fixture'; +import PrintRectsFragmentContainer from './PrintRectsFragmentContainer'; +import CompareDocumentPositionFragmentContainer from './CompareDocumentPositionFragmentContainer'; +import EventFragmentContainer from './EventFragmentContainer'; +import GetRootNodeFragmentContainer from './GetRootNodeFragmentContainer'; + +const React = window.React; +const {Fragment, useRef, useState} = React; + +function GetClientRectsTextOnly() { + return ( + + +
  • Click the "Print Rects" button
  • +
    + + The fragment contains only text nodes. getClientRects should return + bounding rectangles for the text content using the Range API. + + + + + This is text content inside a fragment with no element children. + + + +
    + ); +} + +function GetClientRectsMixed() { + return ( + + +
  • Click the "Print Rects" button
  • +
    + + The fragment contains both text nodes and elements. getClientRects + should return bounding rectangles for both text content (via Range API) + and elements. + + + + + Text before the span. + + Element + + Text after the span. +
    + More text at the end. +
    +
    +
    +
    + ); +} + +function FocusTextOnlyNoop() { + const fragmentRef = useRef(null); + const [message, setMessage] = useState(''); + + const tryFocus = () => { + fragmentRef.current.focus(); + setMessage('Called focus() - no-op for text-only fragments'); + }; + + const tryFocusLast = () => { + fragmentRef.current.focusLast(); + setMessage('Called focusLast() - no-op for text-only fragments'); + }; + + return ( + + +
  • Click either focus button
  • +
    + + Calling focus() or focusLast() on a fragment with only text children is + a no-op. Nothing happens and no warning is logged. This is because text + nodes cannot receive focus. + + + + + + {message && ( +
    {message}
    + )} +
    +
    + + This fragment contains only text. Text nodes are not focusable. + +
    +
    +
    + ); +} + +function ScrollIntoViewTextOnly() { + const fragmentRef = useRef(null); + const [message, setMessage] = useState(''); + + const tryScrollIntoView = alignToTop => { + fragmentRef.current.scrollIntoView(alignToTop); + setMessage( + `Called scrollIntoView(${alignToTop}) - page should scroll to text` + ); + }; + + return ( + + +
  • Scroll down the page so the text fragment is not visible
  • +
  • Click one of the scrollIntoView buttons
  • +
    + + The page should scroll to bring the text content into view. With + alignToTop=true, the text should appear at the top of the viewport. With + alignToTop=false, it should appear at the bottom. This uses the Range + API to calculate text node positions. + + + + + + {message && ( +
    {message}
    + )} +
    +
    + + This fragment contains only text. The scrollIntoView method uses the + Range API to calculate the text position and scroll to it. + +
    +
    +
    + ); +} + +function ScrollIntoViewMixed() { + const fragmentRef = useRef(null); + const [message, setMessage] = useState(''); + + const tryScrollIntoView = alignToTop => { + fragmentRef.current.scrollIntoView(alignToTop); + setMessage( + `Called scrollIntoView(${alignToTop}) - page should scroll to fragment` + ); + }; + + const targetStyle = { + height: 300, + marginBottom: 50, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '24px', + fontWeight: 'bold', + }; + + return ( + + +
  • Scroll down the page so the fragment is not visible
  • +
  • Click one of the scrollIntoView buttons
  • +
    + + The fragment contains raw text nodes (not wrapped in elements) and + elements in alternating order. With alignToTop=true, scroll starts from + the last child and works backwards, ending with the first text node at + the top. With alignToTop=false, scroll starts from the first child and + works forward, ending with the last text node at the bottom. Text nodes + use the Range API for scrolling. + + + + + + {message && ( +
    {message}
    + )} +
    +
    + + TEXT NODE 1 - This is a raw text node at the start of the fragment +
    + ELEMENT 1 +
    + TEXT NODE 2 - This is a raw text node between elements +
    + ELEMENT 2 +
    + TEXT NODE 3 - This is a raw text node between elements +
    + ELEMENT 3 +
    + TEXT NODE 4 - This is a raw text node at the end of the fragment +
    +
    +
    +
    + ); +} + +function CompareDocumentPositionTextNodes() { + return ( + + +
  • Click the "Compare All Positions" button
  • +
    + + compareDocumentPosition should work correctly even when the fragment + contains only text nodes. The "Before" element should be PRECEDING the + fragment, and the "After" element should be FOLLOWING. + + + + + This is text-only content inside the fragment. + + + +
    + ); +} + +function ObserveTextOnlyWarning() { + const fragmentRef = useRef(null); + const [message, setMessage] = useState(''); + + const tryObserve = () => { + setMessage('Called observeUsing() - check console for warning'); + const observer = new IntersectionObserver(() => {}); + fragmentRef.current.observeUsing(observer); + }; + + return ( + + +
  • Open the browser console
  • +
  • Click the observeUsing button
  • +
    + + A warning should appear in the console because IntersectionObserver + cannot observe text nodes. The warning message should indicate that + observeUsing() was called on a FragmentInstance with only text children. + + + + + {message && ( +
    {message}
    + )} +
    +
    + + This fragment contains only text. Text nodes cannot be observed. + +
    +
    +
    + ); +} + +function EventTextOnly() { + return ( + + +
  • + Click "Add event listener" to attach a click handler to the fragment +
  • +
  • Click "Dispatch click event" to dispatch a click event
  • +
  • Observe that the fragment's event listener fires
  • +
  • Click "Remove event listener" and dispatch again
  • +
    + + Event operations (addEventListener, removeEventListener, dispatchEvent) + work on fragments with text-only content. The event is dispatched on the + fragment's parent element since text nodes cannot be event targets. + + + + + This fragment contains only text. Events are handled via the parent. + + + +
    + ); +} + +function EventMixed() { + return ( + + +
  • + Click "Add event listener" to attach a click handler to the fragment +
  • +
  • Click "Dispatch click event" to dispatch a click event
  • +
  • Observe that the fragment's event listener fires
  • +
  • Click directly on the element or text content to see bubbling
  • +
    + + Event operations work on fragments with mixed text and element content. + dispatchEvent forwards to the parent element. Clicks on child elements + or text bubble up through the DOM as normal. + + + + + Text node before element. + + Element + + Text node after element. + + + +
    + ); +} + +function GetRootNodeTextOnly() { + return ( + + +
  • Click the "Get Root Node" button
  • +
    + + getRootNode should return the root of the DOM tree containing the + fragment's text content. For a fragment in the main document, this + should return the Document node. + + + + + This fragment contains only text. getRootNode returns the document. + + + +
    + ); +} + +function GetRootNodeMixed() { + return ( + + +
  • Click the "Get Root Node" button
  • +
    + + getRootNode should return the root of the DOM tree for fragments with + mixed text and element content. The result is the same whether checking + from text nodes or element nodes within the fragment. + + + + + Text before element. + + Element + + Text after element. + + + +
    + ); +} + +export default function TextNodesCase() { + return ( + + +

    + This section demonstrates how various FragmentInstance methods work + with text nodes. +

    +

    + Supported: getClientRects, compareDocumentPosition, + scrollIntoView, getRootNode, addEventListener, removeEventListener, + dispatchEvent +

    +

    + No-op (silent): focus, focusLast (text nodes cannot + receive focus) +

    +

    + Not supported (warns): observeUsing (observers cannot + observe text nodes) +

    +
    + + + + + + + + + + + +
    + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/index.js b/fixtures/dom/src/components/fixtures/fragment-refs/index.js index c560b59fbec..91160618c80 100644 --- a/fixtures/dom/src/components/fixtures/fragment-refs/index.js +++ b/fixtures/dom/src/components/fixtures/fragment-refs/index.js @@ -5,7 +5,9 @@ import IntersectionObserverCase from './IntersectionObserverCase'; import ResizeObserverCase from './ResizeObserverCase'; import FocusCase from './FocusCase'; import GetClientRectsCase from './GetClientRectsCase'; +import CompareDocumentPositionCase from './CompareDocumentPositionCase'; import ScrollIntoViewCase from './ScrollIntoViewCase'; +import TextNodesCase from './TextNodesCase'; const React = window.React; @@ -18,7 +20,9 @@ export default function FragmentRefsPage() { + + ); } diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 2246f74697e..4dc316de366 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -3730,6 +3730,14 @@ function createFakeFunction( fn = function (_) { return _(); }; + // Using the usual {[name]: _() => _()}.bind() trick to avoid minifiers + // doesn't work here since this will produce `Object.*` names. + Object.defineProperty( + fn, + // $FlowFixMe[cannot-write] -- `name` is configurable though. + 'name', + {value: name}, + ); } return fn; } diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 9897c28d7e8..f62ec321501 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -3677,6 +3677,56 @@ describe('ReactFlight', () => { expect(caughtError.digest).toBe('digest("my-error")'); }); + it('can transport function names in stackframes in dev even without eval', async () => { + function a() { + return b(); + } + function b() { + return c(); + } + function c() { + return new Error('boom'); + } + + // eslint-disable-next-line no-eval + const previousEval = globalThis.eval.bind(globalThis); + // eslint-disable-next-line no-eval + globalThis.eval = () => { + throw new Error('eval is disabled'); + }; + + try { + const transport = ReactNoopFlightServer.render( + {model: a()}, + {onError: () => 'digest'}, + ); + + const root = await ReactNoopFlightClient.read(transport); + const receivedError = await root.model; + + if (__DEV__) { + const normalizedErrorStack = normalizeCodeLocInfo( + receivedError.stack.split('\n').slice(0, 4).join('\n'), + ); + + expect(normalizedErrorStack).toEqual( + 'Error: boom' + + '\n in c (at **)' + + '\n in b (at **)' + + '\n in a (at **)', + ); + } else { + expect(receivedError.message).toEqual( + 'An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.', + ); + expect(receivedError).not.toHaveProperty('digest'); + } + } finally { + // eslint-disable-next-line no-eval + globalThis.eval = previousEval; + } + }); + // @gate __DEV__ && enableComponentPerformanceTrack it('can render deep but cut off JSX in debug info', async () => { function createDeepJSX(n) { diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 026f4ae0c8c..09653552aaf 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -127,6 +127,7 @@ import { enableFragmentRefsScrollIntoView, enableProfilerTimer, enableFragmentRefsInstanceHandles, + enableFragmentRefsTextNodes, } from 'shared/ReactFeatureFlags'; import { HostComponent, @@ -2956,6 +2957,7 @@ function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) { this._eventListeners = null; this._observers = null; } + // $FlowFixMe[prop-missing] FragmentInstance.prototype.addEventListener = function ( this: FragmentInstanceType, @@ -3119,6 +3121,12 @@ function setFocusOnFiberIfFocusable( fiber: Fiber, focusOptions?: FocusOptions, ): boolean { + if (enableFragmentRefsTextNodes) { + // Skip text nodes - they are not focusable + if (fiber.tag === HostText) { + return false; + } + } const instance = getInstanceFromHostFiber(fiber); return setFocusIfFocusable(instance, focusOptions); } @@ -3169,6 +3177,28 @@ FragmentInstance.prototype.observeUsing = function ( this: FragmentInstanceType, observer: IntersectionObserver | ResizeObserver, ): void { + if (__DEV__) { + if (enableFragmentRefsTextNodes) { + let hasText = false; + let hasElement = false; + traverseFragmentInstance(this._fragmentFiber, (child: Fiber) => { + if (child.tag === HostText) { + hasText = true; + } else { + // Stop traversal, found element + hasElement = true; + return true; + } + return false; + }); + if (hasText && !hasElement) { + console.error( + 'observeUsing() was called on a FragmentInstance with only text children. ' + + 'Observers do not work on text nodes.', + ); + } + } + } if (this._observers === null) { this._observers = new Set(); } @@ -3179,6 +3209,12 @@ function observeChild( child: Fiber, observer: IntersectionObserver | ResizeObserver, ) { + if (enableFragmentRefsTextNodes) { + // Skip text nodes - observers don't work on them + if (child.tag === HostText) { + return false; + } + } const instance = getInstanceFromHostFiber(child); observer.observe(instance); return false; @@ -3205,6 +3241,12 @@ function unobserveChild( child: Fiber, observer: IntersectionObserver | ResizeObserver, ) { + if (enableFragmentRefsTextNodes) { + // Skip text nodes - they were never observed + if (child.tag === HostText) { + return false; + } + } const instance = getInstanceFromHostFiber(child); observer.unobserve(instance); return false; @@ -3218,9 +3260,17 @@ FragmentInstance.prototype.getClientRects = function ( return rects; }; function collectClientRects(child: Fiber, rects: Array): boolean { - const instance = getInstanceFromHostFiber(child); - // $FlowFixMe[method-unbinding] - rects.push.apply(rects, instance.getClientRects()); + if (enableFragmentRefsTextNodes && child.tag === HostText) { + const textNode: Text = child.stateNode; + const range = textNode.ownerDocument.createRange(); + range.selectNodeContents(textNode); + // $FlowFixMe[method-unbinding] + rects.push.apply(rects, range.getClientRects()); + } else { + const instance = getInstanceFromHostFiber(child); + // $FlowFixMe[method-unbinding] + rects.push.apply(rects, instance.getClientRects()); + } return false; } // $FlowFixMe[prop-missing] @@ -3426,6 +3476,19 @@ if (enableFragmentRefsScrollIntoView) { let i = resolvedAlignToTop ? children.length - 1 : 0; while (i !== (resolvedAlignToTop ? -1 : children.length)) { const child = children[i]; + // For text nodes, use Range API to scroll to their position + if (enableFragmentRefsTextNodes && child.tag === HostText) { + const textNode: Text = child.stateNode; + const range = textNode.ownerDocument.createRange(); + range.selectNodeContents(textNode); + const rect = range.getBoundingClientRect(); + const scrollY = resolvedAlignToTop + ? window.scrollY + rect.top + : window.scrollY + rect.bottom - window.innerHeight; + window.scrollTo(window.scrollX + rect.left, scrollY); + i += resolvedAlignToTop ? -1 : 1; + continue; + } const instance = getInstanceFromHostFiber(child); instance.scrollIntoView(alignToTop); i += resolvedAlignToTop ? -1 : 1; diff --git a/packages/react-dom/src/__tests__/ReactDOMAttribute-test.js b/packages/react-dom/src/__tests__/ReactDOMAttribute-test.js index cd1d055c09d..af128d180e8 100644 --- a/packages/react-dom/src/__tests__/ReactDOMAttribute-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMAttribute-test.js @@ -9,6 +9,13 @@ 'use strict'; +// Fix JSDOM. setAttribute is supposed to throw on things that can't be implicitly toStringed. +const setAttribute = Element.prototype.setAttribute; +Element.prototype.setAttribute = function (name, value) { + // eslint-disable-next-line react-internal/safe-string-coercion + return setAttribute.call(this, name, '' + value); +}; + describe('ReactDOM unknown attribute', () => { let React; let ReactDOMClient; diff --git a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js index 1766db2de8b..1caa5ed8d6e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFloat-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFloat-test.js @@ -602,6 +602,14 @@ describe('ReactDOMFloat', () => { '> '); + if (gate('enableTrustedTypesIntegration')) { + assertConsoleErrorDev([ + 'Encountered a script tag while rendering React component. ' + + 'Scripts inside React components are never executed when rendering on the client. ' + + 'Consider using template tag instead (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template).\n' + + ' in script (at **)\n' + + ' in TogglingComponent (at **)', + ]); + } + const container2 = document.createElement('div'); const root2 = ReactDOMClient.createRoot(container2); expect(() => { @@ -189,6 +202,7 @@ describe('ReactEmptyComponent', () => { 'mount SCRIPT', 'update undefined', ]); + expect(container2.innerHTML).toBe(''); }); it( diff --git a/packages/react-dom/src/__tests__/utils/IntersectionMocks.js b/packages/react-dom/src/__tests__/utils/IntersectionMocks.js index d89a683e8e9..9d36f46ae94 100644 --- a/packages/react-dom/src/__tests__/utils/IntersectionMocks.js +++ b/packages/react-dom/src/__tests__/utils/IntersectionMocks.js @@ -93,3 +93,58 @@ export function setClientRects(target, rects) { })); }; } + +/** + * Mock Range.prototype.getClientRects and getBoundingClientRect since jsdom doesn't implement them. + * Call this in beforeEach to set up the mock. + */ +export function mockRangeClientRects( + rects = [{x: 0, y: 0, width: 100, height: 20}], +) { + const originalCreateRange = document.createRange; + document.createRange = function () { + const range = originalCreateRange.call(document); + range.getClientRects = function () { + return rects.map(({x, y, width, height}) => ({ + width, + height, + left: x, + right: x + width, + top: y, + bottom: y + height, + x, + y, + })); + }; + range.getBoundingClientRect = function () { + // Return the bounding rect that encompasses all rects + if (rects.length === 0) { + return { + width: 0, + height: 0, + left: 0, + right: 0, + top: 0, + bottom: 0, + x: 0, + y: 0, + }; + } + const first = rects[0]; + return { + width: first.width, + height: first.height, + left: first.x, + right: first.x + first.width, + top: first.y, + bottom: first.y + first.height, + x: first.x, + y: first.y, + }; + }; + return range; + }; + return function restore() { + document.createRange = originalCreateRange; + }; +} diff --git a/packages/react-dom/src/client/__tests__/trustedTypes-test.internal.js b/packages/react-dom/src/client/__tests__/trustedTypes-test.internal.js index 5a43a9ec2f0..06744581ae9 100644 --- a/packages/react-dom/src/client/__tests__/trustedTypes-test.internal.js +++ b/packages/react-dom/src/client/__tests__/trustedTypes-test.internal.js @@ -12,7 +12,6 @@ describe('when Trusted Types are available in global object', () => { let React; let ReactDOMClient; - let ReactFeatureFlags; let act; let assertConsoleErrorDev; let container; @@ -33,8 +32,6 @@ describe('when Trusted Types are available in global object', () => { isScript: () => false, isScriptURL: () => false, }; - ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.enableTrustedTypesIntegration = true; React = require('react'); ReactDOMClient = require('react-dom/client'); ({act, assertConsoleErrorDev} = require('internal-test-utils')); @@ -118,7 +115,11 @@ describe('when Trusted Types are available in global object', () => { expect(setAttributeCalls[0][0]).toBe(container.firstChild); expect(setAttributeCalls[0][1]).toBe('data-foo'); // Ensure it didn't get stringified when passed to a DOM sink: - expect(setAttributeCalls[0][2]).toBe(ttObject1); + if (gate('enableTrustedTypesIntegration')) { + expect(setAttributeCalls[0][2]).toBe(ttObject1); + } else { + expect(setAttributeCalls[0][2]).toBe('Hi'); + } setAttributeCalls.length = 0; await act(() => { @@ -129,7 +130,11 @@ describe('when Trusted Types are available in global object', () => { expect(setAttributeCalls[0][0]).toBe(container.firstChild); expect(setAttributeCalls[0][1]).toBe('data-foo'); // Ensure it didn't get stringified when passed to a DOM sink: - expect(setAttributeCalls[0][2]).toBe(ttObject2); + if (gate('enableTrustedTypesIntegration')) { + expect(setAttributeCalls[0][2]).toBe(ttObject2); + } else { + expect(setAttributeCalls[0][2]).toBe('Bye'); + } } finally { Element.prototype.setAttribute = setAttribute; } @@ -153,7 +158,11 @@ describe('when Trusted Types are available in global object', () => { expect(setAttributeCalls[0][0]).toBe(container.firstChild); expect(setAttributeCalls[0][1]).toBe('class'); // Ensure it didn't get stringified when passed to a DOM sink: - expect(setAttributeCalls[0][2]).toBe(ttObject1); + if (gate('enableTrustedTypesIntegration')) { + expect(setAttributeCalls[0][2]).toBe(ttObject1); + } else { + expect(setAttributeCalls[0][2]).toBe('Hi'); + } setAttributeCalls.length = 0; await act(() => { @@ -164,7 +173,11 @@ describe('when Trusted Types are available in global object', () => { expect(setAttributeCalls[0][0]).toBe(container.firstChild); expect(setAttributeCalls[0][1]).toBe('class'); // Ensure it didn't get stringified when passed to a DOM sink: - expect(setAttributeCalls[0][2]).toBe(ttObject2); + if (gate('enableTrustedTypesIntegration')) { + expect(setAttributeCalls[0][2]).toBe(ttObject2); + } else { + expect(setAttributeCalls[0][2]).toBe('Bye'); + } } finally { Element.prototype.setAttribute = setAttribute; } @@ -189,7 +202,11 @@ describe('when Trusted Types are available in global object', () => { expect(setAttributeNSCalls[0][1]).toBe('http://www.w3.org/1999/xlink'); expect(setAttributeNSCalls[0][2]).toBe('xlink:href'); // Ensure it didn't get stringified when passed to a DOM sink: - expect(setAttributeNSCalls[0][3]).toBe(ttObject1); + if (gate('enableTrustedTypesIntegration')) { + expect(setAttributeNSCalls[0][3]).toBe(ttObject1); + } else { + expect(setAttributeNSCalls[0][3]).toBe('Hi'); + } setAttributeNSCalls.length = 0; await act(() => { @@ -201,7 +218,11 @@ describe('when Trusted Types are available in global object', () => { expect(setAttributeNSCalls[0][1]).toBe('http://www.w3.org/1999/xlink'); expect(setAttributeNSCalls[0][2]).toBe('xlink:href'); // Ensure it didn't get stringified when passed to a DOM sink: - expect(setAttributeNSCalls[0][3]).toBe(ttObject2); + if (gate('enableTrustedTypesIntegration')) { + expect(setAttributeNSCalls[0][3]).toBe(ttObject2); + } else { + expect(setAttributeNSCalls[0][3]).toBe('Bye'); + } } finally { Element.prototype.setAttributeNS = setAttributeNS; } @@ -212,13 +233,15 @@ describe('when Trusted Types are available in global object', () => { await act(() => { root.render(); }); - assertConsoleErrorDev([ - 'Encountered a script tag while rendering React component. ' + - 'Scripts inside React components are never executed when rendering ' + - 'on the client. Consider using template tag instead ' + - '(https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template).\n' + - ' in script (at **)', - ]); + if (gate('enableTrustedTypesIntegration')) { + assertConsoleErrorDev([ + 'Encountered a script tag while rendering React component. ' + + 'Scripts inside React components are never executed when rendering ' + + 'on the client. Consider using template tag instead ' + + '(https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template).\n' + + ' in script (at **)', + ]); + } // check that the warning is printed only once await act(() => { diff --git a/packages/react-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js index 530b65d6995..8b4c52b4ea2 100644 --- a/packages/react-reconciler/src/ReactFiberTreeReflection.js +++ b/packages/react-reconciler/src/ReactFiberTreeReflection.js @@ -29,6 +29,7 @@ import { Fragment, } from './ReactWorkTags'; import {NoFlags, Placement, Hydrating} from './ReactFiberFlags'; +import {enableFragmentRefsTextNodes} from 'shared/ReactFeatureFlags'; export function getNearestMountedFiber(fiber: Fiber): null | Fiber { let node = fiber; @@ -373,7 +374,10 @@ function traverseVisibleHostChildren( c: C, ): boolean { while (child !== null) { - if (child.tag === HostComponent && fn(child, a, b, c)) { + const isHostNode = + child.tag === HostComponent || + (enableFragmentRefsTextNodes && child.tag === HostText); + if (isHostNode && fn(child, a, b, c)) { return true; } else if ( child.tag === OffscreenComponent && @@ -473,6 +477,7 @@ function findFragmentInstanceSiblings( export function getInstanceFromHostFiber(fiber: Fiber): I { switch (fiber.tag) { case HostComponent: + case HostText: return fiber.stateNode; case HostRoot: return fiber.stateNode.containerInfo; diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 6542fc9da6a..113370d1eb7 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -144,6 +144,7 @@ export const enableInfiniteRenderLoopDetection: boolean = false; export const enableFragmentRefs: boolean = true; export const enableFragmentRefsScrollIntoView: boolean = true; export const enableFragmentRefsInstanceHandles: boolean = false; +export const enableFragmentRefsTextNodes: boolean = true; export const enableInternalInstanceMap: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index fe7777eda71..8082e77e1d6 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js @@ -25,3 +25,4 @@ export const passChildrenWhenCloningPersistedNodes = __VARIANT__; export const enableFragmentRefs = __VARIANT__; export const enableFragmentRefsScrollIntoView = __VARIANT__; export const enableFragmentRefsInstanceHandles = __VARIANT__; +export const enableFragmentRefsTextNodes = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index d516581486e..dcee1c3b15e 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -27,6 +27,7 @@ export const { enableFragmentRefs, enableFragmentRefsScrollIntoView, enableFragmentRefsInstanceHandles, + enableFragmentRefsTextNodes, } = dynamicFlags; // The rest of the flags are static for better dead code elimination. diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 52c4204ef00..f64a2165d0d 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -72,6 +72,7 @@ export const ownerStackLimit = 1e4; export const enableFragmentRefs: boolean = true; export const enableFragmentRefsScrollIntoView: boolean = false; export const enableFragmentRefsInstanceHandles: boolean = false; +export const enableFragmentRefsTextNodes: boolean = false; export const enableInternalInstanceMap: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 4a1ded43c7d..3db934f13de 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -73,6 +73,7 @@ export const ownerStackLimit = 1e4; export const enableFragmentRefs: boolean = true; export const enableFragmentRefsScrollIntoView: boolean = true; export const enableFragmentRefsInstanceHandles: boolean = false; +export const enableFragmentRefsTextNodes: boolean = true; export const enableInternalInstanceMap: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 67ae35f04b9..a80ca9eedda 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -65,6 +65,8 @@ export const enableHydrationChangeEvent = false; export const enableDefaultTransitionIndicator = true; export const enableFragmentRefs = false; export const enableFragmentRefsScrollIntoView = false; +export const enableFragmentRefsInstanceHandles = false; +export const enableFragmentRefsTextNodes = false; export const ownerStackLimit = 1e4; export const enableOptimisticKey = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 4434cfa6c64..d8a1e797d85 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -78,6 +78,7 @@ export const enableDefaultTransitionIndicator: boolean = true; export const enableFragmentRefs: boolean = false; export const enableFragmentRefsScrollIntoView: boolean = false; export const enableFragmentRefsInstanceHandles: boolean = false; +export const enableFragmentRefsTextNodes: boolean = false; export const ownerStackLimit = 1e4; export const enableInternalInstanceMap: boolean = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index a8d829ee3e3..391c50e9956 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -34,13 +34,13 @@ export const enableViewTransition: boolean = __VARIANT__; export const enableScrollEndPolyfill: boolean = __VARIANT__; export const enableFragmentRefs: boolean = __VARIANT__; export const enableFragmentRefsScrollIntoView: boolean = __VARIANT__; +export const enableFragmentRefsTextNodes: boolean = __VARIANT__; export const enableAsyncDebugInfo: boolean = __VARIANT__; - export const enableInternalInstanceMap: boolean = __VARIANT__; +export const enableTrustedTypesIntegration: boolean = __VARIANT__; // TODO: These flags are hard-coded to the default values used in open source. // Update the tests so that they pass in either mode, then set these // to __VARIANT__. -export const enableTrustedTypesIntegration: boolean = false; // You probably *don't* want to add more hardcoded ones. // Instead, try to add them above with the __VARIANT__ value. diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 8a710cc429d..5e206f489fe 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -32,6 +32,7 @@ export const { enableScrollEndPolyfill, enableFragmentRefs, enableFragmentRefsScrollIntoView, + enableFragmentRefsTextNodes, enableAsyncDebugInfo, enableInternalInstanceMap, } = dynamicFeatureFlags;