Open History to see the last commit
1type IntersectionCallback = (entry: IntersectionObserverEntry) => void;
2
3export interface ObserveIntersectionOptions {
4 /** A scroll container to intersect with instead of the viewport. */
5 readonly root?: Element | null;
6 readonly rootMargin?: string;
7 readonly threshold?: number;
8}
9
10interface Subscription {
11 readonly callback: IntersectionCallback;
12 active: boolean;
13}
14
15interface Target {
16 readonly subscriptions: Set<Subscription>;
17 waiting: Set<Subscription> | null;
18 last: IntersectionObserverEntry | null;
19}
20
21interface SharedObserver {
22 readonly observer: IntersectionObserver;
23 readonly targets: Map<Element, Target>;
24}
25
26// One observer per root and distinct option set.
27const viewportObservers = new Map<string, SharedObserver>();
28const elementRootObservers = new WeakMap<Element, Map<string, SharedObserver>>();
29
30function deliver(shared: SharedObserver, entries: readonly IntersectionObserverEntry[]) {
31 for (const entry of entries) {
32 const target = shared.targets.get(entry.target);
33 if (!target) continue;
34 let recipients = target.subscriptions;
35 if (target.waiting) {
36 // Re-observing for a new subscriber redelivers an unchanged state to everyone else too.
37 if (target.last?.isIntersecting === entry.isIntersecting) recipients = target.waiting;
38 target.waiting = null;
39 }
40 target.last = entry;
41 for (const subscription of recipients) {
42 if (!subscription.active) continue;
43 try {
44 subscription.callback(entry);
45 } catch (error) {
46 queueMicrotask(() => {
47 throw error;
48 });
49 }
50 }
51 }
52}
53
54function observersFor(root: Element): Map<string, SharedObserver> {
55 let observers = elementRootObservers.get(root);
56 if (!observers) {
57 observers = new Map();
58 elementRootObservers.set(root, observers);
59 }
60 return observers;
61}
62
63/**
64 * Observes an element's intersection with the viewport, or with `root`, through
65 * one shared IntersectionObserver per root and option set. Returns the unsubscribe, so it can be
66 * returned from a ref callback.
67 */
68export function observeIntersection(
69 element: Element,
70 callback: IntersectionCallback,
71 options: ObserveIntersectionOptions = {},
72): () => void {
73 if (typeof IntersectionObserver === "undefined") return () => {};
74 const root = options.root ?? null;
75 const key = `${options.rootMargin ?? ""}|${options.threshold ?? 0}`;
76 const observersForRoot = root === null ? viewportObservers : observersFor(root);
77 let shared = observersForRoot.get(key);
78 if (!shared) {
79 const targets = new Map<Element, Target>();
80 const created: SharedObserver = {
81 targets,
82 observer: new IntersectionObserver((entries) => deliver(created, entries), {
83 ...(root === null ? {} : { root }),
84 ...(options.rootMargin === undefined ? {} : { rootMargin: options.rootMargin }),
85 ...(options.threshold === undefined ? {} : { threshold: options.threshold }),
86 }),
87 };
88 shared = created;
89 observersForRoot.set(key, shared);
90 }
91 const owner = shared;
92 const subscription: Subscription = { callback, active: true };
93 const target = owner.targets.get(element);
94 if (!target) {
95 owner.targets.set(element, {
96 subscriptions: new Set([subscription]),
97 waiting: null,
98 last: null,
99 });
100 owner.observer.observe(element);
101 } else {
102 target.subscriptions.add(subscription);
103 if (target.last) {
104 (target.waiting ??= new Set()).add(subscription);
105 owner.observer.unobserve(element);
106 owner.observer.observe(element);
107 }
108 }
109 return () => {
110 if (!subscription.active) return;
111 subscription.active = false;
112 const current = owner.targets.get(element);
113 if (!current?.subscriptions.delete(subscription)) return;
114 current.waiting?.delete(subscription);
115 if (current.subscriptions.size > 0) return;
116 owner.targets.delete(element);
117 if (owner.targets.size > 0) {
118 owner.observer.unobserve(element);
119 return;
120 }
121 owner.observer.disconnect();
122 observersForRoot.delete(key);
123 if (root !== null && observersForRoot.size === 0) elementRootObservers.delete(root);
124 };
125}