diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/EventListenerCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/EventListenerCase.js new file mode 100644 index 0000000000000..b3baf5a381d7a --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/EventListenerCase.js @@ -0,0 +1,96 @@ +import TestCase from '../../TestCase'; +import Fixture from '../../Fixture'; + +const React = window.React; +const {Fragment, useEffect, useRef, 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
  • +
    + + +

    + 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. +

    +

    + New child nodes will also have event listeners applied. Removed nodes + will have their listeners cleaned up. +

    +
    + + +
    +
    Target count: {extraChildCount + 3}
    + + + +
    + +
    + Child A +
    +
    + Child B +
    + +
    + Child C +
    + {Array.from({length: extraChildCount}).map((_, index) => ( +
    + Extra Child {index} +
    + ))} +
    +
    +
    +
    +
    +
    + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/IntersectionObserverCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/IntersectionObserverCase.js new file mode 100644 index 0000000000000..e087215aee4df --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/IntersectionObserverCase.js @@ -0,0 +1,153 @@ +import TestCase from '../../TestCase'; +import Fixture from '../../Fixture'; + +const React = window.React; +const {Fragment, useEffect, useRef, useState} = React; + +function WrapperComponent(props) { + return props.children; +} + +function ObservedChild({id}) { + return ( +
    + {id} +
    + ); +} + +const initialItems = [ + ['A', false], + ['B', false], + ['C', false], +]; + +export default function IntersectionObserverCase() { + const fragmentRef = useRef(null); + const [items, setItems] = useState(initialItems); + const addedItems = items.slice(3); + const anyOnScreen = items.some(([, onScreen]) => onScreen); + const observerRef = useRef(null); + + useEffect(() => { + if (observerRef.current === null) { + observerRef.current = new IntersectionObserver( + entries => { + setItems(prev => { + const newItems = [...prev]; + entries.forEach(entry => { + const index = newItems.findIndex( + ([id]) => id === entry.target.id + ); + newItems[index] = [entry.target.id, entry.isIntersecting]; + }); + return newItems; + }); + }, + { + threshold: [0.5], + } + ); + } + fragmentRef.current.observeUsing(observerRef.current); + + const lastFragmentRefValue = fragmentRef.current; + return () => { + lastFragmentRefValue.unobserveUsing(observerRef.current); + observerRef.current = null; + }; + }, []); + + return ( + + +
  • + Scroll the children into view, observe the sidebar appears and shows + which children are in the viewport +
  • +
  • + Add a new child and observe that the Intersection Observer is applied +
  • +
  • + Click Unobserve and observe that the state of children in the viewport + is no longer updated +
  • +
  • + Click Observe and observe that the state of children in the viewport + is updated again +
  • +
    + + +

    + Fragment refs manage Intersection Observers on the first level of host + children. This page loads with an effect that sets up an Inersection + Observer applied to each child card. +

    +

    + New child nodes will also have the observer applied. Removed nodes + will be unobserved. +

    +
    + + + + + + {anyOnScreen && ( +
    +

    + Children on screen: +

    + {items.map(item => ( +
    + {item[0]} +
    + ))} +
    + )} + + + + + + + {addedItems.map((_, index) => ( + + ))} + +
    +
    + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/ResizeObserverCase.js b/fixtures/dom/src/components/fixtures/fragment-refs/ResizeObserverCase.js new file mode 100644 index 0000000000000..ecacd50921374 --- /dev/null +++ b/fixtures/dom/src/components/fixtures/fragment-refs/ResizeObserverCase.js @@ -0,0 +1,63 @@ +import TestCase from '../../TestCase'; +import Fixture from '../../Fixture'; + +const React = window.React; +const {Fragment, useEffect, useRef, useState} = React; + +export default function ResizeObserverCase() { + const fragmentRef = useRef(null); + const [width, setWidth] = useState([0, 0, 0]); + + useEffect(() => { + const resizeObserver = new window.ResizeObserver(entries => { + if (entries.length > 0) { + setWidth(prev => { + const newWidth = [...prev]; + entries.forEach(entry => { + const index = parseInt(entry.target.id, 10); + newWidth[index] = Math.round(entry.contentRect.width); + }); + return newWidth; + }); + } + }); + + fragmentRef.current.observeUsing(resizeObserver); + const lastFragmentRefValue = fragmentRef.current; + return () => { + lastFragmentRefValue.unobserveUsing(resizeObserver); + }; + }, []); + + return ( + + +
  • Resize the viewport width until the children respond
  • +
  • See that the width data updates as they elements resize
  • +
    + + The Fragment Ref has a ResizeObserver attached which has a callback to + update the width state of each child node. + + + +
    +

    + Width: {width[0]}px +

    +
    +
    +

    + Width: {width[1]}px +

    +
    +
    +

    + Width: {width[2]}px +

    +
    +
    +
    +
    + ); +} diff --git a/fixtures/dom/src/components/fixtures/fragment-refs/index.js b/fixtures/dom/src/components/fixtures/fragment-refs/index.js index bd4468126e43b..03d95ec30a98d 100644 --- a/fixtures/dom/src/components/fixtures/fragment-refs/index.js +++ b/fixtures/dom/src/components/fixtures/fragment-refs/index.js @@ -1,104 +1,16 @@ -import Fixture from '../../Fixture'; import FixtureSet from '../../FixtureSet'; -import TestCase from '../../TestCase'; +import EventListenerCase from './EventListenerCase'; +import IntersectionObserverCase from './IntersectionObserverCase'; +import ResizeObserverCase from './ResizeObserverCase'; const React = window.React; -const {Fragment, useEffect, useRef, useState} = React; - -function WrapperComponent(props) { - return props.children; -} - -function handler(e) { - const text = e.currentTarget.innerText; - alert('You clicked: ' + text); -} export default function FragmentRefsPage() { - const fragmentRef = useRef(null); - const [extraChildCount, setExtraChildCount] = useState(0); - - React.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 -
  • -
    - - -

    - 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. -

    -

    - New child nodes will also have event listeners applied. Removed - nodes will have their listeners cleaned up. -

    -
    - - -
    -
    Target count: {extraChildCount + 3}
    - - - -
    - -
    - Child A -
    -
    - Child B -
    - -
    - Child C -
    - {Array.from({length: extraChildCount}).map((_, index) => ( -
    - Extra Child {index} -
    - ))} -
    -
    -
    -
    -
    -
    + + +
    ); } diff --git a/fixtures/dom/src/style.css b/fixtures/dom/src/style.css index 568d8b4b0d1b5..30a9ed5fff972 100644 --- a/fixtures/dom/src/style.css +++ b/fixtures/dom/src/style.css @@ -322,3 +322,39 @@ tbody tr:nth-child(even) { margin: 10px; padding: 10px; } + +.observable-card { + height: 200px; + border: 1px solid black; + background: #e0e0e0; + padding: 20px; + font-size: 18px; + overflow: auto; + margin-bottom: 50px; + position: relative; +} + +.observable-card::after { + content: ""; + position: absolute; + top: 50%; + left: 0; + width: 100%; + border-top: 1px dotted red; +} + +.fixed-sidebar { + position: fixed; + top: 0; + left: 0; + height: 100%; + width: 200px; + z-index: 1000; + background-color: gray; + display: flex; + flex-direction: column; +} + +.onscreen { + background-color: green; +} diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 1e9ee657e0ca8..2d34bc94005be 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2186,6 +2186,7 @@ type StoredEventListener = { export type FragmentInstanceType = { _fragmentFiber: Fiber, _eventListeners: null | Array, + _observers: null | Set, addEventListener( type: string, listener: EventListener, @@ -2197,11 +2198,14 @@ export type FragmentInstanceType = { optionsOrUseCapture?: EventListenerOptionsOrUseCapture, ): void, focus(): void, + observeUsing(observer: IntersectionObserver | ResizeObserver): void, + unobserveUsing(observer: IntersectionObserver | ResizeObserver): void, }; function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) { this._fragmentFiber = fragmentFiber; this._eventListeners = null; + this._observers = null; } // $FlowFixMe[prop-missing] FragmentInstance.prototype.addEventListener = function ( @@ -2284,6 +2288,48 @@ function removeEventListenerFromChild( FragmentInstance.prototype.focus = function (this: FragmentInstanceType) { traverseFragmentInstance(this._fragmentFiber, setFocusIfFocusable); }; +// $FlowFixMe[prop-missing] +FragmentInstance.prototype.observeUsing = function ( + this: FragmentInstanceType, + observer: IntersectionObserver | ResizeObserver, +): void { + if (this._observers === null) { + this._observers = new Set(); + } + this._observers.add(observer); + traverseFragmentInstance(this._fragmentFiber, observeChild, observer); +}; +function observeChild( + child: Instance, + observer: IntersectionObserver | ResizeObserver, +) { + observer.observe(child); + return false; +} +// $FlowFixMe[prop-missing] +FragmentInstance.prototype.unobserveUsing = function ( + this: FragmentInstanceType, + observer: IntersectionObserver | ResizeObserver, +): void { + if (this._observers === null || !this._observers.has(observer)) { + if (__DEV__) { + console.error( + 'You are calling unobserveUsing() with an observer that is not being observed with this fragment ' + + 'instance. First attach the observer with observeUsing()', + ); + } + } else { + this._observers.delete(observer); + traverseFragmentInstance(this._fragmentFiber, unobserveChild, observer); + } +}; +function unobserveChild( + child: Instance, + observer: IntersectionObserver | ResizeObserver, +) { + observer.unobserve(child); + return false; +} function normalizeListenerOptions( opts: ?EventListenerOptionsOrUseCapture, @@ -2343,6 +2389,11 @@ export function commitNewChildToFragmentInstance( childElement.addEventListener(type, listener, optionsOrUseCapture); } } + if (fragmentInstance._observers !== null) { + fragmentInstance._observers.forEach(observer => { + observer.observe(childElement); + }); + } } export function deleteChildFromFragmentInstance( diff --git a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js index 727fd3014201f..e1d8532b7ee99 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js @@ -15,6 +15,9 @@ let act; let container; let Fragment; let Activity; +let mockIntersectionObserver; +let simulateIntersection; +let assertConsoleErrorDev; describe('FragmentRefs', () => { beforeEach(() => { @@ -24,6 +27,12 @@ describe('FragmentRefs', () => { Activity = React.unstable_Activity; ReactDOMClient = require('react-dom/client'); act = require('internal-test-utils').act; + const IntersectionMocks = require('./utils/IntersectionMocks'); + mockIntersectionObserver = IntersectionMocks.mockIntersectionObserver; + simulateIntersection = IntersectionMocks.simulateIntersection; + assertConsoleErrorDev = + require('internal-test-utils').assertConsoleErrorDev; + container = document.createElement('div'); document.body.appendChild(container); }); @@ -617,4 +626,109 @@ describe('FragmentRefs', () => { }); }); }); + + describe('observers', () => { + beforeEach(() => { + mockIntersectionObserver(); + }); + + // @gate enableFragmentRefs + it('attaches intersection observers to children', async () => { + let logs = []; + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + logs.push(entry.target.id); + }); + }); + function Test({showB}) { + const fragmentRef = React.useRef(null); + React.useEffect(() => { + fragmentRef.current.observeUsing(observer); + const lastRefValue = fragmentRef.current; + return () => { + lastRefValue.unobserveUsing(observer); + }; + }, []); + return ( +
    + +
    A
    + {showB &&
    B
    } +
    +
    + ); + } + + function simulateAllChildrenIntersecting() { + const parent = container.firstChild; + if (parent) { + const children = Array.from(parent.children).map(child => { + return [child, {y: 0, x: 0, width: 1, height: 1}, 1]; + }); + simulateIntersection(...children); + } + } + + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + simulateAllChildrenIntersecting(); + expect(logs).toEqual(['childA']); + + // Reveal child and expect it to be observed + logs = []; + await act(() => root.render()); + simulateAllChildrenIntersecting(); + expect(logs).toEqual(['childA', 'childB']); + + // Hide child and expect it to be unobserved + logs = []; + await act(() => root.render()); + simulateAllChildrenIntersecting(); + expect(logs).toEqual(['childA']); + + // Unmount component and expect all children to be unobserved + logs = []; + await act(() => root.render(null)); + simulateAllChildrenIntersecting(); + expect(logs).toEqual([]); + }); + + // @gate enableFragmentRefs + it('warns when unobserveUsing() is called with an observer that was not observed', async () => { + const fragmentRef = React.createRef(); + const observer = new IntersectionObserver(() => {}); + const observer2 = new IntersectionObserver(() => {}); + function Test() { + return ( + +
    + + ); + } + + const root = ReactDOMClient.createRoot(container); + await act(() => root.render()); + + // Warning when there is no attached observer + fragmentRef.current.unobserveUsing(observer); + assertConsoleErrorDev( + [ + 'You are calling unobserveUsing() with an observer that is not being observed with this fragment ' + + 'instance. First attach the observer with observeUsing()', + ], + {withoutStack: true}, + ); + + // Warning when the attached observer does not match + fragmentRef.current.observeUsing(observer); + fragmentRef.current.unobserveUsing(observer2); + assertConsoleErrorDev( + [ + 'You are calling unobserveUsing() with an observer that is not being observed with this fragment ' + + 'instance. First attach the observer with observeUsing()', + ], + {withoutStack: true}, + ); + }); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMTestSelectors-test.js b/packages/react-dom/src/__tests__/ReactDOMTestSelectors-test.js index 65bb49e06a1c8..8b6eb0d49d5e7 100644 --- a/packages/react-dom/src/__tests__/ReactDOMTestSelectors-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMTestSelectors-test.js @@ -23,6 +23,9 @@ describe('ReactDOMTestSelectors', () => { let focusWithin; let getFindAllNodesFailureDescription; let observeVisibleRects; + let mockIntersectionObserver; + let simulateIntersection; + let setBoundingClientRect; let container; @@ -51,6 +54,10 @@ describe('ReactDOMTestSelectors', () => { container = document.createElement('div'); document.body.appendChild(container); + const IntersectionMocks = require('./utils/IntersectionMocks'); + mockIntersectionObserver = IntersectionMocks.mockIntersectionObserver; + simulateIntersection = IntersectionMocks.simulateIntersection; + setBoundingClientRect = IntersectionMocks.setBoundingClientRect; }); afterEach(() => { @@ -608,21 +615,6 @@ No matching component was found for: }); describe('findBoundingRects', () => { - // Stub out getBoundingClientRect for the specified target. - // This API is required by the test selectors but it isn't implemented by jsdom. - function setBoundingClientRect(target, {x, y, width, height}) { - target.getBoundingClientRect = function () { - return { - width, - height, - left: x, - right: x + width, - top: y, - bottom: y + height, - }; - }; - } - // @gate www || experimental it('should return a single rect for a component that returns a single root host element', async () => { const ref = React.createRef(); @@ -1223,69 +1215,10 @@ No matching component was found for: }); describe('observeVisibleRects', () => { - // Stub out getBoundingClientRect for the specified target. - // This API is required by the test selectors but it isn't implemented by jsdom. - function setBoundingClientRect(target, {x, y, width, height}) { - target.getBoundingClientRect = function () { - return { - width, - height, - left: x, - right: x + width, - top: y, - bottom: y + height, - }; - }; - } - - function simulateIntersection(...entries) { - callback( - entries.map(([target, rect, ratio]) => ({ - boundingClientRect: { - top: rect.y, - left: rect.x, - width: rect.width, - height: rect.height, - }, - intersectionRatio: ratio, - target, - })), - ); - } - - let callback; - let observedTargets; + let observerMock; beforeEach(() => { - callback = null; - observedTargets = []; - - class IntersectionObserver { - constructor() { - callback = arguments[0]; - } - - disconnect() { - callback = null; - observedTargets.splice(0); - } - - observe(target) { - observedTargets.push(target); - } - - unobserve(target) { - const index = observedTargets.indexOf(target); - if (index >= 0) { - observedTargets.splice(index, 1); - } - } - } - - // This is a broken polyfill. - // It is only intended to provide bare minimum test coverage. - // More meaningful tests will require the use of fixtures. - window.IntersectionObserver = IntersectionObserver; + observerMock = mockIntersectionObserver(); }); // @gate www || experimental @@ -1317,8 +1250,8 @@ No matching component was found for: handleVisibilityChange, ); - expect(callback).not.toBeNull(); - expect(observedTargets).toHaveLength(1); + expect(observerMock.callback).not.toBeNull(); + expect(observerMock.observedTargets).toHaveLength(1); expect(handleVisibilityChange).not.toHaveBeenCalled(); // Simulate IntersectionObserver notification. @@ -1370,8 +1303,8 @@ No matching component was found for: handleVisibilityChange, ); - expect(callback).not.toBeNull(); - expect(observedTargets).toHaveLength(2); + expect(observerMock.callback).not.toBeNull(); + expect(observerMock.observedTargets).toHaveLength(2); expect(handleVisibilityChange).not.toHaveBeenCalled(); // Simulate IntersectionObserver notification. @@ -1437,12 +1370,12 @@ No matching component was found for: handleVisibilityChange, ); - expect(callback).not.toBeNull(); - expect(observedTargets).toHaveLength(1); + expect(observerMock.callback).not.toBeNull(); + expect(observerMock.observedTargets).toHaveLength(1); expect(handleVisibilityChange).not.toHaveBeenCalled(); disconnect(); - expect(callback).toBeNull(); + expect(observerMock.callback).toBeNull(); }); // This test reuires gating because it relies on the __DEV__ only commit hook to work. @@ -1570,9 +1503,9 @@ No matching component was found for: handleVisibilityChange, ); - expect(callback).not.toBeNull(); - expect(observedTargets).toHaveLength(1); - expect(observedTargets[0]).toBe(ref1.current); + expect(observerMock.callback).not.toBeNull(); + expect(observerMock.observedTargets).toHaveLength(1); + expect(observerMock.observedTargets[0]).toBe(ref1.current); }); }); }); diff --git a/packages/react-dom/src/__tests__/utils/IntersectionMocks.js b/packages/react-dom/src/__tests__/utils/IntersectionMocks.js new file mode 100644 index 0000000000000..a6a6b68caa0c7 --- /dev/null +++ b/packages/react-dom/src/__tests__/utils/IntersectionMocks.js @@ -0,0 +1,77 @@ +/** + * 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. + * + */ + +const intersectionObserverMock = {callback: null, observedTargets: []}; + +/** + * This is a broken polyfill. + * It is only intended to provide bare minimum test coverage. + * More meaningful tests will require the use of fixtures. + */ +export function mockIntersectionObserver() { + intersectionObserverMock.callback = null; + intersectionObserverMock.observedTargets = []; + + class IntersectionObserver { + constructor() { + intersectionObserverMock.callback = arguments[0]; + } + + disconnect() { + intersectionObserverMock.callback = null; + intersectionObserverMock.observedTargets.splice(0); + } + + observe(target) { + intersectionObserverMock.observedTargets.push(target); + } + + unobserve(target) { + const index = intersectionObserverMock.observedTargets.indexOf(target); + if (index >= 0) { + intersectionObserverMock.observedTargets.splice(index, 1); + } + } + } + + window.IntersectionObserver = IntersectionObserver; + + return intersectionObserverMock; +} + +export function simulateIntersection(...entries) { + intersectionObserverMock.callback( + entries.map(([target, rect, ratio]) => ({ + boundingClientRect: { + top: rect.y, + left: rect.x, + width: rect.width, + height: rect.height, + }, + intersectionRatio: ratio, + target, + })), + ); +} + +/** + * Stub out getBoundingClientRect for the specified target. + * This API is required by the test selectors but it isn't implemented by jsdom. + */ +export function setBoundingClientRect(target, {x, y, width, height}) { + target.getBoundingClientRect = function () { + return { + width, + height, + left: x, + right: x + width, + top: y, + bottom: y + height, + }; + }; +}