[DOM] Unobserve fragment IntersectionObserver targets after exit
Project: facebook/react | Language: JavaScript | Concepts: memory-management, observability, refactoring, error-handling | Difficulty: senior
Link: https://github.com/react/react/pull/37408
The problem
When a child node of a React Fragment was removed, if it was being observed by an IntersectionObserver, React would keep it observed so the observer could deliver an exit record (isIntersecting: false). However, this meant the observer retained a strong reference to a detached DOM node, causing potential memory leaks. There was also no mechanism to clean up these references if the node was re-inserted before the observer was unregistered.
Why it's non-trivial
A naive fix would simply unobserve the node immediately upon removal. This would prevent the observer from delivering the necessary exit record, breaking expected behavior for consumers relying on isIntersecting: false when nodes leave the DOM. Additionally, handling the case where a node is removed and then re-inserted before the delayed unobserve is tricky; unobserving in this scenario could lead to missed observations or errors.
The approach
The solution introduces a deferred unobserve mechanism for IntersectionObserver targets, ensuring exit records are delivered, and adds logic to cancel unobserves if a node is re-inserted before the flush.
1. Schedule delayed unobserve for IntersectionObserver targets after node removal, but unobserve ResizeObserver targets immediately:
+ if (isIntersectionObserver(observer)) {
+ // Stay observed until the next IntersectionObserver delivery so a
+ // disconnected target still gets an isIntersecting: false record.
+ schedulePendingIntersectionUnobserve(
+ fragmentInstance,
+ observer as any as IntersectionObserver,
+ instance,
+ );
+ } else {
+ observer.unobserve(instance);
+ }
This code ensures that when a child is deleted, if the observer is an IntersectionObserver, unobserve is scheduled after paint (so the exit record can fire). If it's a ResizeObserver, unobserve happens immediately.
2. Implement a queue and flush mechanism for pending unobserves:
+let pendingIntersectionUnobserves: Array<PendingIntersectionUnobserve> = [];
+let intersectionUnobserveScheduled: boolean = false;
...
+function schedulePendingIntersectionUnobserve(
+ fragmentInstance: FragmentInstanceType,
+ observer: IntersectionObserver,
+ instance: Instance,
+): void {
+ pendingIntersectionUnobserves.push({
+ fragmentInstance,
+ observer,
+ instance,
+ });
+ if (!intersectionUnobserveScheduled) {
+ intersectionUnobserveScheduled = true;
+ // Unobserving in this commit would cancel the record IntersectionObserver
+ // delivers for the now disconnected target. Wait until after paint so the
+ // exit fires first, then drop the observer's strong ref to the node.
+ requestPostPaintCallback(() => {
+ intersectionUnobserveScheduled = false;
+ const pending = pendingIntersectionUnobserves;
+ pendingIntersectionUnobserves = [];
+ for (let i = 0; i < pending.length; i++) {
+ const item = pending[i];
+ item.observer.unobserve(item.instance);
+ }
+ });
+ }
+}
This code introduces a queue for pending unobserves and schedules a post-paint callback to flush them, ensuring the observer can deliver the exit record before the strong reference is dropped.
3. Cancel a pending unobserve if the same node is re-inserted before the flush:
+function cancelPendingIntersectionUnobserve(
+ fragmentInstance: FragmentInstanceType,
+ observer: IntersectionObserver | ResizeObserver,
+ instance: Instance,
+): void {
+ let writeIdx = 0;
+ for (let i = 0; i < pendingIntersectionUnobserves.length; i++) {
+ const pending = pendingIntersectionUnobserves[i];
+ if (
+ pending.fragmentInstance !== fragmentInstance ||
+ pending.observer !== observer ||
+ pending.instance !== instance
+ ) {
+ pendingIntersectionUnobserves[writeIdx++] = pending;
+ }
+ }
+ pendingIntersectionUnobserves.length = writeIdx;
+}
This function removes a scheduled unobserve for a node if it is re-observed (re-inserted) before the post-paint flush, preventing accidental detachment of a node that is now visible again.
4. Ensure all pending unobserves for deleted children are flushed if unobserveUsing is called:
+function unobservePendingChildren(
+ fragmentInstance: FragmentInstanceType,
+ observer: IntersectionObserver | ResizeObserver,
+): void {
+ let writeIdx = 0;
+ for (let i = 0; i < pendingIntersectionUnobserves.length; i++) {
+ const pending = pendingIntersectionUnobserves[i];
+ if (
+ pending.fragmentInstance === fragmentInstance &&
+ pending.observer === observer
+ ) {
+ observer.unobserve(pending.instance);
+ } else {
+ pendingIntersectionUnobserves[writeIdx++] = pending;
+ }
+ }
+ pendingIntersectionUnobserves.length = writeIdx;
+}
This ensures that if an observer is being explicitly unobserved from a fragment, any pending unobserves for deleted children are immediately flushed as well.
5. Update tests to cover the new behavior:
Several new and updated tests were added to ensure:
- IntersectionObserver targets are unobserved only after exit records are delivered.
- ResizeObserver targets are unobserved immediately.
- Nodes reinserted before the flush are not unobserved.
- All pending unobserves are flushed if
unobserveUsingis called.
From the review thread
No notable review discussion on this PR.
Takeaways
- Defer cleanup when external contracts require post-removal events: When removing an object that is still needed by an external observer (e.g., to deliver an exit event), schedule cleanup after the event has a chance to fire.
- Guard against race conditions in deferred cleanup: If a resource (like a DOM node) is reinserted before the deferred cleanup runs, cancel the pending cleanup to avoid detaching or cleaning up an active resource.
- Differentiate cleanup strategies by observer type: Not all observers have the same event delivery semantics; tailor cleanup logic to the requirements of each observer to avoid breaking expected behavior.