@samitouri / QOS-React / commits / cd28a946d5

Add observer methods to fragment instances (#32619)

This implements `observeUsing(observer)` and `unobserverUsing(observer)` on fragment instances. IntersectionObservers and ResizeObservers can be passed to observe each host child of the fragment. This is the equivalent to calling `observer.observe(child)` or `observer.unobserve(child)` for each child target. Just like the addEventListener, the observer is held on the fragment instance and applied to any newly mounted child. So you can do things like wrap a paginated list in a fragment and have each child automatically observed as they commit in. Unlike, the event listeners though, we don't `unobserve` when a child is removed. If a removed child is currently intersecting, the observer callback will be called when it is removed with an empty rect. This lets you track all the currently intersecting elements by setting state from the observer callback and either adding or removing them from your list depending on the intersecting state. If you want to track the removal of items offscreen, you'd have to maintain that state separately and append intersecting data to it in the observer callback. This is what the fixture example does. There could be more convenient ways of managing the state of multiple child intersections, but basic examples are able to be modeled with the simple implementation. Let's see how the usage goes as we integrate this with more advanced loggers and other features. For now you can only attach one observer to an instance. This could change based on usage but the fragments are composable and could be stacked as one way to apply multiple observers to the same elements. In practice, one pattern we expect to enable is more composable logging such as ```javascript function Feed({ items }) { return ( <ImpressionLogger> {items.map((item) => ( <FeedItem /> ))} </ImpressionLogger> ); } ``` where `ImpressionLogger` would set up the IntersectionObserver using a fragment ref with the required business logic and various components could layer it wherever the logging is needed. Currently most callsites use a hook form, which can require wiring up refs through the tree and merging refs for multiple loggers.

Jack Pope committed Mar 17, 2025 at 11:40 UTC cd28a946d57695a025581c0ff851bde08ea6ca27
9 files changed +615 -180
fixtures/dom/src/components/fixtures/fragment-refs/EventListenerCase.js new
+96
@@ -0,0 +1,96 @@
1 +import TestCase from '../../TestCase';
2 +import Fixture from '../../Fixture';
3 +
4 +const React = window.React;
5 +const {Fragment, useEffect, useRef, useState} = React;
6 +
7 +function WrapperComponent(props) {
8 + return props.children;
9 +}
10 +
11 +function handler(e) {
12 + const text = e.currentTarget.innerText;
13 + alert('You clicked: ' + text);
14 +}
15 +
16 +export default function EventListenerCase() {
17 + const fragmentRef = useRef(null);
18 + const [extraChildCount, setExtraChildCount] = useState(0);
19 +
20 + useEffect(() => {
21 + fragmentRef.current.addEventListener('click', handler);
22 +
23 + const lastFragmentRefValue = fragmentRef.current;
24 + return () => {
25 + lastFragmentRefValue.removeEventListener('click', handler);
26 + };
27 + });
28 +
29 + return (
30 + <TestCase title="Event Registration">
31 + <TestCase.Steps>
32 + <li>Click one of the children, observe the alert</li>
33 + <li>Add a new child, click it, observe the alert</li>
34 + <li>Remove the event listeners, click a child, observe no alert</li>
35 + <li>Add the event listeners back, click a child, observe the alert</li>
36 + </TestCase.Steps>
37 +
38 + <TestCase.ExpectedResult>
39 + <p>
40 + Fragment refs can manage event listeners on the first level of host
41 + children. This page loads with an effect that sets up click event
42 + hanndlers on each child card. Clicking on a card will show an alert
43 + with the card's text.
44 + </p>
45 + <p>
46 + New child nodes will also have event listeners applied. Removed nodes
47 + will have their listeners cleaned up.
48 + </p>
49 + </TestCase.ExpectedResult>
50 +
51 + <Fixture>
52 + <div className="control-box">
53 + <div>Target count: {extraChildCount + 3}</div>
54 + <button
55 + onClick={() => {
56 + setExtraChildCount(prev => prev + 1);
57 + }}>
58 + Add Child
59 + </button>
60 + <button
61 + onClick={() => {
62 + fragmentRef.current.addEventListener('click', handler);
63 + }}>
64 + Add click event listeners
65 + </button>
66 + <button
67 + onClick={() => {
68 + fragmentRef.current.removeEventListener('click', handler);
69 + }}>
70 + Remove click event listeners
71 + </button>
72 + <div class="card-container">
73 + <Fragment ref={fragmentRef}>
74 + <div className="card" id="child-a">
75 + Child A
76 + </div>
77 + <div className="card" id="child-b">
78 + Child B
79 + </div>
80 + <WrapperComponent>
81 + <div className="card" id="child-c">
82 + Child C
83 + </div>
84 + {Array.from({length: extraChildCount}).map((_, index) => (
85 + <div className="card" id={'extra-child-' + index} key={index}>
86 + Extra Child {index}
87 + </div>
88 + ))}
89 + </WrapperComponent>
90 + </Fragment>
91 + </div>
92 + </div>
93 + </Fixture>
94 + </TestCase>
95 + );
96 +}
fixtures/dom/src/components/fixtures/fragment-refs/IntersectionObserverCase.js new
+153
@@ -0,0 +1,153 @@
1 +import TestCase from '../../TestCase';
2 +import Fixture from '../../Fixture';
3 +
4 +const React = window.React;
5 +const {Fragment, useEffect, useRef, useState} = React;
6 +
7 +function WrapperComponent(props) {
8 + return props.children;
9 +}
10 +
11 +function ObservedChild({id}) {
12 + return (
13 + <div id={id} className="observable-card">
14 + {id}
15 + </div>
16 + );
17 +}
18 +
19 +const initialItems = [
20 + ['A', false],
21 + ['B', false],
22 + ['C', false],
23 +];
24 +
25 +export default function IntersectionObserverCase() {
26 + const fragmentRef = useRef(null);
27 + const [items, setItems] = useState(initialItems);
28 + const addedItems = items.slice(3);
29 + const anyOnScreen = items.some(([, onScreen]) => onScreen);
30 + const observerRef = useRef(null);
31 +
32 + useEffect(() => {
33 + if (observerRef.current === null) {
34 + observerRef.current = new IntersectionObserver(
35 + entries => {
36 + setItems(prev => {
37 + const newItems = [...prev];
38 + entries.forEach(entry => {
39 + const index = newItems.findIndex(
40 + ([id]) => id === entry.target.id
41 + );
42 + newItems[index] = [entry.target.id, entry.isIntersecting];
43 + });
44 + return newItems;
45 + });
46 + },
47 + {
48 + threshold: [0.5],
49 + }
50 + );
51 + }
52 + fragmentRef.current.observeUsing(observerRef.current);
53 +
54 + const lastFragmentRefValue = fragmentRef.current;
55 + return () => {
56 + lastFragmentRefValue.unobserveUsing(observerRef.current);
57 + observerRef.current = null;
58 + };
59 + }, []);
60 +
61 + return (
62 + <TestCase title="Intersection Observer">
63 + <TestCase.Steps>
64 + <li>
65 + Scroll the children into view, observe the sidebar appears and shows
66 + which children are in the viewport
67 + </li>
68 + <li>
69 + Add a new child and observe that the Intersection Observer is applied
70 + </li>
71 + <li>
72 + Click Unobserve and observe that the state of children in the viewport
73 + is no longer updated
74 + </li>
75 + <li>
76 + Click Observe and observe that the state of children in the viewport
77 + is updated again
78 + </li>
79 + </TestCase.Steps>
80 +
81 + <TestCase.ExpectedResult>
82 + <p>
83 + Fragment refs manage Intersection Observers on the first level of host
84 + children. This page loads with an effect that sets up an Inersection
85 + Observer applied to each child card.
86 + </p>
87 + <p>
88 + New child nodes will also have the observer applied. Removed nodes
89 + will be unobserved.
90 + </p>
91 + </TestCase.ExpectedResult>
92 + <Fixture>
93 + <button
94 + onClick={() => {
95 + setItems(prev => [
96 + ...prev,
97 + [`Extra child: ${prev.length + 1}`, false],
98 + ]);
99 + }}>
100 + Add Child
101 + </button>
102 + <button
103 + onClick={() => {
104 + setItems(prev => {
105 + if (prev.length === 3) {
106 + return prev;
107 + }
108 + return prev.slice(0, prev.length - 1);
109 + });
110 + }}>
111 + Remove Child
112 + </button>
113 + <button
114 + onClick={() => {
115 + fragmentRef.current.observeUsing(observerRef.current);
116 + }}>
117 + Observe
118 + </button>
119 + <button
120 + onClick={() => {
121 + fragmentRef.current.unobserveUsing(observerRef.current);
122 + setItems(prev => {
123 + return prev.map(item => [item[0], false]);
124 + });
125 + }}>
126 + Unobserve
127 + </button>
128 + {anyOnScreen && (
129 + <div className="fixed-sidebar card-container">
130 + <p>
131 + <strong>Children on screen:</strong>
132 + </p>
133 + {items.map(item => (
134 + <div className={`card ${item[1] ? 'onscreen' : null}`}>
135 + {item[0]}
136 + </div>
137 + ))}
138 + </div>
139 + )}
140 + <Fragment ref={fragmentRef}>
141 + <ObservedChild id="A" />
142 + <WrapperComponent>
143 + <ObservedChild id="B" />
144 + </WrapperComponent>
145 + <ObservedChild id="C" />
146 + {addedItems.map((_, index) => (
147 + <ObservedChild id={`Extra child: ${index + 4}`} />
148 + ))}
149 + </Fragment>
150 + </Fixture>
151 + </TestCase>
152 + );
153 +}
fixtures/dom/src/components/fixtures/fragment-refs/ResizeObserverCase.js new
+63
@@ -0,0 +1,63 @@
1 +import TestCase from '../../TestCase';
2 +import Fixture from '../../Fixture';
3 +
4 +const React = window.React;
5 +const {Fragment, useEffect, useRef, useState} = React;
6 +
7 +export default function ResizeObserverCase() {
8 + const fragmentRef = useRef(null);
9 + const [width, setWidth] = useState([0, 0, 0]);
10 +
11 + useEffect(() => {
12 + const resizeObserver = new window.ResizeObserver(entries => {
13 + if (entries.length > 0) {
14 + setWidth(prev => {
15 + const newWidth = [...prev];
16 + entries.forEach(entry => {
17 + const index = parseInt(entry.target.id, 10);
18 + newWidth[index] = Math.round(entry.contentRect.width);
19 + });
20 + return newWidth;
21 + });
22 + }
23 + });
24 +
25 + fragmentRef.current.observeUsing(resizeObserver);
26 + const lastFragmentRefValue = fragmentRef.current;
27 + return () => {
28 + lastFragmentRefValue.unobserveUsing(resizeObserver);
29 + };
30 + }, []);
31 +
32 + return (
33 + <TestCase title="Resize Observer">
34 + <TestCase.Steps>
35 + <li>Resize the viewport width until the children respond</li>
36 + <li>See that the width data updates as they elements resize</li>
37 + </TestCase.Steps>
38 + <TestCase.ExpectedResult>
39 + The Fragment Ref has a ResizeObserver attached which has a callback to
40 + update the width state of each child node.
41 + </TestCase.ExpectedResult>
42 + <Fixture>
43 + <Fragment ref={fragmentRef}>
44 + <div className="card" id="0" style={{width: '100%'}}>
45 + <p>
46 + Width: <b>{width[0]}px</b>
47 + </p>
48 + </div>
49 + <div className="card" id="1" style={{width: '80%'}}>
50 + <p>
51 + Width: <b>{width[1]}px</b>
52 + </p>
53 + </div>
54 + <div className="card" id="2" style={{width: '50%'}}>
55 + <p>
56 + Width: <b>{width[2]}px</b>
57 + </p>
58 + </div>
59 + </Fragment>
60 + </Fixture>
61 + </TestCase>
62 + );
63 +}
fixtures/dom/src/components/fixtures/fragment-refs/index.js
+6 -94
@@ -1,104 +1,16 @@
1 -import Fixture from '../../Fixture';
1 import FixtureSet from '../../FixtureSet';
3 -import TestCase from '../../TestCase';
2 +import EventListenerCase from './EventListenerCase';
3 +import IntersectionObserverCase from './IntersectionObserverCase';
4 +import ResizeObserverCase from './ResizeObserverCase';
5
6 const React = window.React;
6 -const {Fragment, useEffect, useRef, useState} = React;
7 -
8 -function WrapperComponent(props) {
9 - return props.children;
10 -}
11 -
12 -function handler(e) {
13 - const text = e.currentTarget.innerText;
14 - alert('You clicked: ' + text);
15 -}
7
8 export default function FragmentRefsPage() {
18 - const fragmentRef = useRef(null);
19 - const [extraChildCount, setExtraChildCount] = useState(0);
20 -
21 - React.useEffect(() => {
22 - fragmentRef.current.addEventListener('click', handler);
23 -
24 - const lastFragmentRefValue = fragmentRef.current;
25 - return () => {
26 - lastFragmentRefValue.removeEventListener('click', handler);
27 - };
28 - });
29 -
9 return (
10 <FixtureSet title="Fragment Refs">
32 - <TestCase title="Event registration">
33 - <TestCase.Steps>
34 - <li>Click one of the children, observe the alert</li>
35 - <li>Add a new child, click it, observe the alert</li>
36 - <li>Remove the event listeners, click a child, observe no alert</li>
37 - <li>
38 - Add the event listeners back, click a child, observe the alert
39 - </li>
40 - </TestCase.Steps>
41 -
42 - <TestCase.ExpectedResult>
43 - <p>
44 - Fragment refs can manage event listeners on the first level of host
45 - children. This page loads with an effect that sets up click event
46 - hanndlers on each child card. Clicking on a card will show an alert
47 - with the card's text.
48 - </p>
49 - <p>
50 - New child nodes will also have event listeners applied. Removed
51 - nodes will have their listeners cleaned up.
52 - </p>
53 - </TestCase.ExpectedResult>
54 -
55 - <Fixture>
56 - <div className="control-box" id="control-box">
57 - <div>Target count: {extraChildCount + 3}</div>
58 - <button
59 - onClick={() => {
60 - setExtraChildCount(prev => prev + 1);
61 - }}>
62 - Add Child
63 - </button>
64 - <button
65 - onClick={() => {
66 - fragmentRef.current.addEventListener('click', handler);
67 - }}>
68 - Add click event listeners
69 - </button>
70 - <button
71 - onClick={() => {
72 - fragmentRef.current.removeEventListener('click', handler);
73 - }}>
74 - Remove click event listeners
75 - </button>
76 - <div class="card-container">
77 - <Fragment ref={fragmentRef}>
78 - <div className="card" id="child-a">
79 - Child A
80 - </div>
81 - <div className="card" id="child-b">
82 - Child B
83 - </div>
84 - <WrapperComponent>
85 - <div className="card" id="child-c">
86 - Child C
87 - </div>
88 - {Array.from({length: extraChildCount}).map((_, index) => (
89 - <div
90 - className="card"
91 - id={'extra-child-' + index}
92 - key={index}>
93 - Extra Child {index}
94 - </div>
95 - ))}
96 - </WrapperComponent>
97 - </Fragment>
98 - </div>
99 - </div>
100 - </Fixture>
101 - </TestCase>
11 + <EventListenerCase />
12 + <IntersectionObserverCase />
13 + <ResizeObserverCase />
14 </FixtureSet>
15 );
16 }
fixtures/dom/src/style.css
+36
@@ -322,3 +322,39 @@ tbody tr:nth-child(even) {
322 margin: 10px;
323 padding: 10px;
324 }
325 +
326 +.observable-card {
327 + height: 200px;
328 + border: 1px solid black;
329 + background: #e0e0e0;
330 + padding: 20px;
331 + font-size: 18px;
332 + overflow: auto;
333 + margin-bottom: 50px;
334 + position: relative;
335 +}
336 +
337 +.observable-card::after {
338 + content: "";
339 + position: absolute;
340 + top: 50%;
341 + left: 0;
342 + width: 100%;
343 + border-top: 1px dotted red;
344 +}
345 +
346 +.fixed-sidebar {
347 + position: fixed;
348 + top: 0;
349 + left: 0;
350 + height: 100%;
351 + width: 200px;
352 + z-index: 1000;
353 + background-color: gray;
354 + display: flex;
355 + flex-direction: column;
356 +}
357 +
358 +.onscreen {
359 + background-color: green;
360 +}
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+51
@@ -2186,6 +2186,7 @@ type StoredEventListener = {
2186 export type FragmentInstanceType = {
2187 _fragmentFiber: Fiber,
2188 _eventListeners: null | Array<StoredEventListener>,
2189 + _observers: null | Set<IntersectionObserver | ResizeObserver>,
2190 addEventListener(
2191 type: string,
2192 listener: EventListener,
@@ -2197,11 +2198,14 @@ export type FragmentInstanceType = {
2198 optionsOrUseCapture?: EventListenerOptionsOrUseCapture,
2199 ): void,
2200 focus(): void,
2201 + observeUsing(observer: IntersectionObserver | ResizeObserver): void,
2202 + unobserveUsing(observer: IntersectionObserver | ResizeObserver): void,
2203 };
2204
2205 function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) {
2206 this._fragmentFiber = fragmentFiber;
2207 this._eventListeners = null;
2208 + this._observers = null;
2209 }
2210 // $FlowFixMe[prop-missing]
2211 FragmentInstance.prototype.addEventListener = function (
@@ -2284,6 +2288,48 @@ function removeEventListenerFromChild(
2288 FragmentInstance.prototype.focus = function (this: FragmentInstanceType) {
2289 traverseFragmentInstance(this._fragmentFiber, setFocusIfFocusable);
2290 };
2291 +// $FlowFixMe[prop-missing]
2292 +FragmentInstance.prototype.observeUsing = function (
2293 + this: FragmentInstanceType,
2294 + observer: IntersectionObserver | ResizeObserver,
2295 +): void {
2296 + if (this._observers === null) {
2297 + this._observers = new Set();
2298 + }
2299 + this._observers.add(observer);
2300 + traverseFragmentInstance(this._fragmentFiber, observeChild, observer);
2301 +};
2302 +function observeChild(
2303 + child: Instance,
2304 + observer: IntersectionObserver | ResizeObserver,
2305 +) {
2306 + observer.observe(child);
2307 + return false;
2308 +}
2309 +// $FlowFixMe[prop-missing]
2310 +FragmentInstance.prototype.unobserveUsing = function (
2311 + this: FragmentInstanceType,
2312 + observer: IntersectionObserver | ResizeObserver,
2313 +): void {
2314 + if (this._observers === null || !this._observers.has(observer)) {
2315 + if (__DEV__) {
2316 + console.error(
2317 + 'You are calling unobserveUsing() with an observer that is not being observed with this fragment ' +
2318 + 'instance. First attach the observer with observeUsing()',
2319 + );
2320 + }
2321 + } else {
2322 + this._observers.delete(observer);
2323 + traverseFragmentInstance(this._fragmentFiber, unobserveChild, observer);
2324 + }
2325 +};
2326 +function unobserveChild(
2327 + child: Instance,
2328 + observer: IntersectionObserver | ResizeObserver,
2329 +) {
2330 + observer.unobserve(child);
2331 + return false;
2332 +}
2333
2334 function normalizeListenerOptions(
2335 opts: ?EventListenerOptionsOrUseCapture,
@@ -2343,6 +2389,11 @@ export function commitNewChildToFragmentInstance(
2389 childElement.addEventListener(type, listener, optionsOrUseCapture);
2390 }
2391 }
2392 + if (fragmentInstance._observers !== null) {
2393 + fragmentInstance._observers.forEach(observer => {
2394 + observer.observe(childElement);
2395 + });
2396 + }
2397 }
2398
2399 export function deleteChildFromFragmentInstance(
packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
+114
@@ -15,6 +15,9 @@ let act;
15 let container;
16 let Fragment;
17 let Activity;
18 +let mockIntersectionObserver;
19 +let simulateIntersection;
20 +let assertConsoleErrorDev;
21
22 describe('FragmentRefs', () => {
23 beforeEach(() => {
@@ -24,6 +27,12 @@ describe('FragmentRefs', () => {
27 Activity = React.unstable_Activity;
28 ReactDOMClient = require('react-dom/client');
29 act = require('internal-test-utils').act;
30 + const IntersectionMocks = require('./utils/IntersectionMocks');
31 + mockIntersectionObserver = IntersectionMocks.mockIntersectionObserver;
32 + simulateIntersection = IntersectionMocks.simulateIntersection;
33 + assertConsoleErrorDev =
34 + require('internal-test-utils').assertConsoleErrorDev;
35 +
36 container = document.createElement('div');
37 document.body.appendChild(container);
38 });
@@ -617,4 +626,109 @@ describe('FragmentRefs', () => {
626 });
627 });
628 });
629 +
630 + describe('observers', () => {
631 + beforeEach(() => {
632 + mockIntersectionObserver();
633 + });
634 +
635 + // @gate enableFragmentRefs
636 + it('attaches intersection observers to children', async () => {
637 + let logs = [];
638 + const observer = new IntersectionObserver(entries => {
639 + entries.forEach(entry => {
640 + logs.push(entry.target.id);
641 + });
642 + });
643 + function Test({showB}) {
644 + const fragmentRef = React.useRef(null);
645 + React.useEffect(() => {
646 + fragmentRef.current.observeUsing(observer);
647 + const lastRefValue = fragmentRef.current;
648 + return () => {
649 + lastRefValue.unobserveUsing(observer);
650 + };
651 + }, []);
652 + return (
653 + <div id="parent">
654 + <React.Fragment ref={fragmentRef}>
655 + <div id="childA">A</div>
656 + {showB && <div id="childB">B</div>}
657 + </React.Fragment>
658 + </div>
659 + );
660 + }
661 +
662 + function simulateAllChildrenIntersecting() {
663 + const parent = container.firstChild;
664 + if (parent) {
665 + const children = Array.from(parent.children).map(child => {
666 + return [child, {y: 0, x: 0, width: 1, height: 1}, 1];
667 + });
668 + simulateIntersection(...children);
669 + }
670 + }
671 +
672 + const root = ReactDOMClient.createRoot(container);
673 + await act(() => root.render(<Test showB={false} />));
674 + simulateAllChildrenIntersecting();
675 + expect(logs).toEqual(['childA']);
676 +
677 + // Reveal child and expect it to be observed
678 + logs = [];
679 + await act(() => root.render(<Test showB={true} />));
680 + simulateAllChildrenIntersecting();
681 + expect(logs).toEqual(['childA', 'childB']);
682 +
683 + // Hide child and expect it to be unobserved
684 + logs = [];
685 + await act(() => root.render(<Test showB={false} />));
686 + simulateAllChildrenIntersecting();
687 + expect(logs).toEqual(['childA']);
688 +
689 + // Unmount component and expect all children to be unobserved
690 + logs = [];
691 + await act(() => root.render(null));
692 + simulateAllChildrenIntersecting();
693 + expect(logs).toEqual([]);
694 + });
695 +
696 + // @gate enableFragmentRefs
697 + it('warns when unobserveUsing() is called with an observer that was not observed', async () => {
698 + const fragmentRef = React.createRef();
699 + const observer = new IntersectionObserver(() => {});
700 + const observer2 = new IntersectionObserver(() => {});
701 + function Test() {
702 + return (
703 + <React.Fragment ref={fragmentRef}>
704 + <div />
705 + </React.Fragment>
706 + );
707 + }
708 +
709 + const root = ReactDOMClient.createRoot(container);
710 + await act(() => root.render(<Test />));
711 +
712 + // Warning when there is no attached observer
713 + fragmentRef.current.unobserveUsing(observer);
714 + assertConsoleErrorDev(
715 + [
716 + 'You are calling unobserveUsing() with an observer that is not being observed with this fragment ' +
717 + 'instance. First attach the observer with observeUsing()',
718 + ],
719 + {withoutStack: true},
720 + );
721 +
722 + // Warning when the attached observer does not match
723 + fragmentRef.current.observeUsing(observer);
724 + fragmentRef.current.unobserveUsing(observer2);
725 + assertConsoleErrorDev(
726 + [
727 + 'You are calling unobserveUsing() with an observer that is not being observed with this fragment ' +
728 + 'instance. First attach the observer with observeUsing()',
729 + ],
730 + {withoutStack: true},
731 + );
732 + });
733 + });
734 });
packages/react-dom/src/__tests__/ReactDOMTestSelectors-test.js
+19 -86
@@ -23,6 +23,9 @@ describe('ReactDOMTestSelectors', () => {
23 let focusWithin;
24 let getFindAllNodesFailureDescription;
25 let observeVisibleRects;
26 + let mockIntersectionObserver;
27 + let simulateIntersection;
28 + let setBoundingClientRect;
29
30 let container;
31
@@ -51,6 +54,10 @@ describe('ReactDOMTestSelectors', () => {
54
55 container = document.createElement('div');
56 document.body.appendChild(container);
57 + const IntersectionMocks = require('./utils/IntersectionMocks');
58 + mockIntersectionObserver = IntersectionMocks.mockIntersectionObserver;
59 + simulateIntersection = IntersectionMocks.simulateIntersection;
60 + setBoundingClientRect = IntersectionMocks.setBoundingClientRect;
61 });
62
63 afterEach(() => {
@@ -608,21 +615,6 @@ No matching component was found for:
615 });
616
617 describe('findBoundingRects', () => {
611 - // Stub out getBoundingClientRect for the specified target.
612 - // This API is required by the test selectors but it isn't implemented by jsdom.
613 - function setBoundingClientRect(target, {x, y, width, height}) {
614 - target.getBoundingClientRect = function () {
615 - return {
616 - width,
617 - height,
618 - left: x,
619 - right: x + width,
620 - top: y,
621 - bottom: y + height,
622 - };
623 - };
624 - }
625 -
618 // @gate www || experimental
619 it('should return a single rect for a component that returns a single root host element', async () => {
620 const ref = React.createRef();
@@ -1223,69 +1215,10 @@ No matching component was found for:
1215 });
1216
1217 describe('observeVisibleRects', () => {
1226 - // Stub out getBoundingClientRect for the specified target.
1227 - // This API is required by the test selectors but it isn't implemented by jsdom.
1228 - function setBoundingClientRect(target, {x, y, width, height}) {
1229 - target.getBoundingClientRect = function () {
1230 - return {
1231 - width,
1232 - height,
1233 - left: x,
1234 - right: x + width,
1235 - top: y,
1236 - bottom: y + height,
1237 - };
1238 - };
1239 - }
1240 -
1241 - function simulateIntersection(...entries) {
1242 - callback(
1243 - entries.map(([target, rect, ratio]) => ({
1244 - boundingClientRect: {
1245 - top: rect.y,
1246 - left: rect.x,
1247 - width: rect.width,
1248 - height: rect.height,
1249 - },
1250 - intersectionRatio: ratio,
1251 - target,
1252 - })),
1253 - );
1254 - }
1255 -
1256 - let callback;
1257 - let observedTargets;
1218 + let observerMock;
1219
1220 beforeEach(() => {
1260 - callback = null;
1261 - observedTargets = [];
1262 -
1263 - class IntersectionObserver {
1264 - constructor() {
1265 - callback = arguments[0];
1266 - }
1267 -
1268 - disconnect() {
1269 - callback = null;
1270 - observedTargets.splice(0);
1271 - }
1272 -
1273 - observe(target) {
1274 - observedTargets.push(target);
1275 - }
1276 -
1277 - unobserve(target) {
1278 - const index = observedTargets.indexOf(target);
1279 - if (index >= 0) {
1280 - observedTargets.splice(index, 1);
1281 - }
1282 - }
1283 - }
1284 -
1285 - // This is a broken polyfill.
1286 - // It is only intended to provide bare minimum test coverage.
1287 - // More meaningful tests will require the use of fixtures.
1288 - window.IntersectionObserver = IntersectionObserver;
1221 + observerMock = mockIntersectionObserver();
1222 });
1223
1224 // @gate www || experimental
@@ -1317,8 +1250,8 @@ No matching component was found for:
1250 handleVisibilityChange,
1251 );
1252
1320 - expect(callback).not.toBeNull();
1321 - expect(observedTargets).toHaveLength(1);
1253 + expect(observerMock.callback).not.toBeNull();
1254 + expect(observerMock.observedTargets).toHaveLength(1);
1255 expect(handleVisibilityChange).not.toHaveBeenCalled();
1256
1257 // Simulate IntersectionObserver notification.
@@ -1370,8 +1303,8 @@ No matching component was found for:
1303 handleVisibilityChange,
1304 );
1305
1373 - expect(callback).not.toBeNull();
1374 - expect(observedTargets).toHaveLength(2);
1306 + expect(observerMock.callback).not.toBeNull();
1307 + expect(observerMock.observedTargets).toHaveLength(2);
1308 expect(handleVisibilityChange).not.toHaveBeenCalled();
1309
1310 // Simulate IntersectionObserver notification.
@@ -1437,12 +1370,12 @@ No matching component was found for:
1370 handleVisibilityChange,
1371 );
1372
1440 - expect(callback).not.toBeNull();
1441 - expect(observedTargets).toHaveLength(1);
1373 + expect(observerMock.callback).not.toBeNull();
1374 + expect(observerMock.observedTargets).toHaveLength(1);
1375 expect(handleVisibilityChange).not.toHaveBeenCalled();
1376
1377 disconnect();
1445 - expect(callback).toBeNull();
1378 + expect(observerMock.callback).toBeNull();
1379 });
1380
1381 // 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:
1503 handleVisibilityChange,
1504 );
1505
1573 - expect(callback).not.toBeNull();
1574 - expect(observedTargets).toHaveLength(1);
1575 - expect(observedTargets[0]).toBe(ref1.current);
1506 + expect(observerMock.callback).not.toBeNull();
1507 + expect(observerMock.observedTargets).toHaveLength(1);
1508 + expect(observerMock.observedTargets[0]).toBe(ref1.current);
1509 });
1510 });
1511 });
packages/react-dom/src/__tests__/utils/IntersectionMocks.js new
+77
@@ -0,0 +1,77 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + */
8 +
9 +const intersectionObserverMock = {callback: null, observedTargets: []};
10 +
11 +/**
12 + * This is a broken polyfill.
13 + * It is only intended to provide bare minimum test coverage.
14 + * More meaningful tests will require the use of fixtures.
15 + */
16 +export function mockIntersectionObserver() {
17 + intersectionObserverMock.callback = null;
18 + intersectionObserverMock.observedTargets = [];
19 +
20 + class IntersectionObserver {
21 + constructor() {
22 + intersectionObserverMock.callback = arguments[0];
23 + }
24 +
25 + disconnect() {
26 + intersectionObserverMock.callback = null;
27 + intersectionObserverMock.observedTargets.splice(0);
28 + }
29 +
30 + observe(target) {
31 + intersectionObserverMock.observedTargets.push(target);
32 + }
33 +
34 + unobserve(target) {
35 + const index = intersectionObserverMock.observedTargets.indexOf(target);
36 + if (index >= 0) {
37 + intersectionObserverMock.observedTargets.splice(index, 1);
38 + }
39 + }
40 + }
41 +
42 + window.IntersectionObserver = IntersectionObserver;
43 +
44 + return intersectionObserverMock;
45 +}
46 +
47 +export function simulateIntersection(...entries) {
48 + intersectionObserverMock.callback(
49 + entries.map(([target, rect, ratio]) => ({
50 + boundingClientRect: {
51 + top: rect.y,
52 + left: rect.x,
53 + width: rect.width,
54 + height: rect.height,
55 + },
56 + intersectionRatio: ratio,
57 + target,
58 + })),
59 + );
60 +}
61 +
62 +/**
63 + * Stub out getBoundingClientRect for the specified target.
64 + * This API is required by the test selectors but it isn't implemented by jsdom.
65 + */
66 +export function setBoundingClientRect(target, {x, y, width, height}) {
67 + target.getBoundingClientRect = function () {
68 + return {
69 + width,
70 + height,
71 + left: x,
72 + right: x + width,
73 + top: y,
74 + bottom: y + height,
75 + };
76 + };
77 +}