1import { flushSync } from "react-dom";
2
3type ResizeCallback = (entries: readonly ResizeObserverEntry[]) => void;
4
5interface Subscription {
6 readonly callback: ResizeCallback;
7 active: boolean;
8}
9
10interface Target {
11 readonly subscriptions: Set<Subscription>;
12 /** Subscribers that joined after the first delivery and still await their initial entry. */
13 waiting: Set<Subscription> | null;
14 last: ResizeObserverEntry | null;
15}
16
17let observer: ResizeObserver | null = null;
18const targets = new Map<Element, Target>();
19
20function deliver(entries: readonly ResizeObserverEntry[]) {
21 const batches = new Map<Subscription, ResizeObserverEntry[]>();
22 for (const entry of entries) {
23 const target = targets.get(entry.target);
24 if (!target) continue;
25 let recipients = target.subscriptions;
26 if (target.waiting) {
27 // Re-observing for a new subscriber redelivers an unchanged size to everyone else too.
28 const previous = target.last?.contentRect;
29 if (
30 previous?.width === entry.contentRect.width &&
31 previous.height === entry.contentRect.height
32 ) {
33 recipients = target.waiting;
34 }
35 target.waiting = null;
36 }
37 target.last = entry;
38 for (const subscription of recipients) {
39 const batch = batches.get(subscription);
40 if (batch) batch.push(entry);
41 else batches.set(subscription, [entry]);
42 }
43 }
44 if (batches.size === 0) return;
45 flushSync(() => {
46 for (const [subscription, batch] of batches) {
47 if (!subscription.active) continue;
48 try {
49 subscription.callback(batch);
50 } catch (error) {
51 queueMicrotask(() => {
52 throw error;
53 });
54 }
55 }
56 });
57}
58
59/**
60 * Observes element sizes on one shared ResizeObserver. Its callbacks run after
61 * layout and before paint, but React commits state set there after the paint,
62 * so layout derived from an observed size would land one frame late. Every
63 * resize delivered in a frame runs inside one flushSync, so all size-derived
64 * state commits in a single render before that paint. A callback gets one call
65 * with the entries for all of its targets, starting with an initial one each.
66 */
67export function observeResize(
68 elements: Element | readonly Element[],
69 callback: ResizeCallback,
70): () => void {
71 if (typeof ResizeObserver === "undefined") return () => {};
72 const shared = (observer ??= new ResizeObserver(deliver));
73 const subscription: Subscription = { callback, active: true };
74 const observed: readonly Element[] = Array.isArray(elements) ? elements : [elements as Element];
75 for (const element of observed) {
76 const target = targets.get(element);
77 if (!target) {
78 targets.set(element, { subscriptions: new Set([subscription]), waiting: null, last: null });
79 shared.observe(element);
80 continue;
81 }
82 target.subscriptions.add(subscription);
83 // An initial entry still pending reaches every subscriber; after it, only
84 // observing afresh makes the browser report the current size again.
85 if (!target.last) continue;
86 (target.waiting ??= new Set()).add(subscription);
87 shared.unobserve(element);
88 shared.observe(element);
89 }
90 return () => {
91 if (!subscription.active) return;
92 subscription.active = false;
93 const unobserved = observed.filter((element) => {
94 const target = targets.get(element);
95 if (!target?.subscriptions.delete(subscription)) return false;
96 target.waiting?.delete(subscription);
97 return target.subscriptions.size === 0 && targets.delete(element);
98 });
99 if (targets.size > 0) {
100 for (const element of unobserved) shared.unobserve(element);
101 } else {
102 // A fresh observer next time also picks up a ResizeObserver a test stubbed since.
103 observer?.disconnect();
104 observer = null;
105 }
106 };
107}