Skip to content

Add observer methods to fragment instances #32619

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 17, 2025

Conversation

jackpope
Copy link
Member

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

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.

@react-sizebot
Copy link

react-sizebot commented Mar 14, 2025

Comparing: df31952...765f9dc

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.68 kB 6.68 kB +0.11% 1.83 kB 1.83 kB
oss-stable/react-dom/cjs/react-dom-client.production.js = 517.29 kB 517.29 kB = 92.26 kB 92.26 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.69 kB 6.69 kB +0.05% 1.83 kB 1.83 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js = 605.54 kB 605.54 kB = 107.43 kB 107.43 kB
facebook-www/ReactDOM-prod.classic.js +0.14% 650.98 kB 651.92 kB +0.12% 114.69 kB 114.83 kB
facebook-www/ReactDOM-prod.modern.js +0.15% 641.26 kB 642.20 kB +0.12% 113.10 kB 113.24 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
test_utils/ReactAllWarnings.js +0.25% 63.77 kB 63.93 kB +0.35% 15.95 kB 16.00 kB

Generated by 🚫 dangerJS against 6e372d6

@@ -0,0 +1,96 @@
import TestCase from '../../TestCase';
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just moving into a new module from index.js

@@ -0,0 +1,77 @@
/**
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved these utilities out of ReactDOMTestSelectors-test.js so we can reuse in ReactDOMFragmentRefs-test.js

@sebmarkbage
Copy link
Collaborator

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

I forgot why I even mentioned this originally. To clarify, I believe it's because IntersectionObservers don't fire any more events if you unobserve them. So if we unobserved them, there wouldn't be any more events fired and you wouldn't know to track them.

If a child isn't deleted but just goes Offscreen while the parent Fragment is still mounted, then presumably the same thing effectively happens since it will be invisible due to display: none. However, if it was "Offscreen" but one of those modes where it would actually still be visible while effects unmount, then it would still fire events. An unfortunate consequence of this is that we'd be in a state where events regarding an unmounted tree still keep firing while it's Offscreen. Normally we expect everything to either remove event listeners or for the events to not ever fire while it's offscreen. This ensures that we could warn for calling setState inside an Offscreen tree since it suggests a leak. It would lead to false positives in this case.

A similar thing is what should happen when the Fragment itself unmounts? The issue is that the empty rect events in that scenario fires, after it has unmounted. If those then call setState that suggests a leak since there are setStates happening on an already unmounted. We have currently relaxed the setState warnings due to Promises so often leading to temporary leaks by not implementing aborting but we could do a version of it still and it suggests that something isn't quite right.

We could consider calling unobserve on all children when we unmount the Fragment itself. Because that is signal in itself to clean it up and ensure that it can be done eagerly at the same time that the rest of the tree cleans up instead of calling setState as a second pass later. A counter argument to this though is that maybe it's actually the responsibility of the creator of the IntersectionObserver itself to clean itself up on unmount. It should maybe be an error/warning if you don't do that.

In other words, to use this correctly you should actually do this:

useEffect(() => {
  const observer = new IntersectionObserver(...);
  fragmentRef.current.observeUsing(observer);
  return () => {
    observer.disconnect(); // or fragmentRef.current.unobserveUsing(observer);
  }
}, []);

Stashing the observer in a ref is a sign of an anti-pattern because you're only doing that because you're not cleaning it up.

You should update the fixtures to use this pattern instead.

'You are attaching an observer to a fragment instance that already has one. Fragment instances ' +
'can only have one observer. Use multiple fragment instances or first call unobserveUsing() to ' +
'remove the previous observer.',
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for this limitation? It doesn't seem strictly necessary to be able to have multiple observers but also not necessary to limit it since it's just an array.

In the original RFC I mentioned this:

Since this causes the observer events to be passed the underlying DOM nodes as the target it can be hard to keep track of which Fragment ref they belong to so it can be best to keep on observer per Fragment.

To clarify that point, there can still be many-to-many. I meant that users should probably create one Observer for every Fragment that they want to observe but you can still many Observers observing a single Fragment. It doesn't have to be strictly enforced but just best practice.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was going back and forth on this and ended up thinking it might be simpler to start with a single observer per instance. But it doesn't really take away anything to make it more flexible. I'll extend it to handle multiple and drop this error.

@jackpope
Copy link
Member Author

The reason for storing the observer on a ref in IntersectionObserverCase is to access it from the "Observe"/"Unobserve" click handlers on the buttons within that component. Otherwise yeah it can just be created and cleaned up in the effect itself.

I like the idea of making it the creators responsibility to clean up but providing a warning on unmount. I'll follow up with that change.

@jackpope jackpope force-pushed the fragment-refs-observe branch from 40328d6 to 6e372d6 Compare March 17, 2025 15:02
@jackpope jackpope merged commit cd28a94 into facebook:main Mar 17, 2025
194 checks passed
@jackpope jackpope deleted the fragment-refs-observe branch March 17, 2025 15:40
github-actions bot pushed a commit that referenced this pull request Mar 17, 2025
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.

DiffTrain build for [cd28a94](cd28a94)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants