Skip to content

Commit cd28a94

Browse files
authored
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.
1 parent 8243f3f commit cd28a94

File tree

9 files changed

+615
-180
lines changed

9 files changed

+615
-180
lines changed
Original file line numberDiff line numberDiff line change
@@ -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+
}
Original file line numberDiff line numberDiff line change
@@ -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+
}
Original file line numberDiff line numberDiff line change
@@ -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+
}

0 commit comments

Comments
 (0)