unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
facebook/react
senior8/10
memory-managementobservabilityrefactoringerror-handling

[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 unobserveUsing is 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.