Skip to content

Commit 805d8be

Browse files
committed
handle older versions of React
1 parent ab1859d commit 805d8be

File tree

1 file changed

+67
-28
lines changed

1 file changed

+67
-28
lines changed

src/useOnInView.tsx

Lines changed: 67 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ import type {
55
} from "./index";
66
import { observe } from "./observe";
77

8+
const useSyncEffect =
9+
(
10+
React as typeof React & {
11+
useInsertionEffect?: typeof React.useEffect;
12+
}
13+
).useInsertionEffect ??
14+
React.useLayoutEffect ??
15+
React.useEffect;
16+
817
/**
918
* React Hooks make it easy to monitor when elements come into and leave view. Call
1019
* the `useOnInView` hook with your callback and (optional) [options](#options).
@@ -52,69 +61,99 @@ export const useOnInView = <TElement extends Element>(
5261
}: IntersectionEffectOptions = {},
5362
) => {
5463
const onIntersectionChangeRef = React.useRef(onIntersectionChange);
55-
const syncEffect =
56-
(
57-
React as typeof React & {
58-
useInsertionEffect?: typeof React.useEffect;
59-
}
60-
).useInsertionEffect ??
61-
React.useLayoutEffect ??
62-
React.useEffect;
64+
const observedElementRef = React.useRef<TElement | null>(null);
65+
const observerCleanupRef = React.useRef<(() => void) | undefined>(undefined);
66+
const callbackCleanupRef =
67+
React.useRef<ReturnType<IntersectionChangeEffect<TElement>>>(undefined);
6368

64-
syncEffect(() => {
69+
useSyncEffect(() => {
6570
onIntersectionChangeRef.current = onIntersectionChange;
6671
}, [onIntersectionChange]);
6772

68-
// biome-ignore lint/correctness/useExhaustiveDependencies: Threshold is validated to be stable
73+
// biome-ignore lint/correctness/useExhaustiveDependencies: Threshold arrays are normalized inside the callback
6974
return React.useCallback(
7075
(element: TElement | undefined | null) => {
76+
// React <19 never calls ref callbacks with `null` during unmount, so we
77+
// eagerly tear down existing observers manually whenever the target changes.
78+
const cleanupExisting = () => {
79+
if (observerCleanupRef.current) {
80+
const cleanup = observerCleanupRef.current;
81+
observerCleanupRef.current = undefined;
82+
cleanup();
83+
} else if (callbackCleanupRef.current) {
84+
const cleanup = callbackCleanupRef.current;
85+
callbackCleanupRef.current = undefined;
86+
cleanup();
87+
}
88+
};
89+
90+
if (element === observedElementRef.current) {
91+
return observerCleanupRef.current;
92+
}
93+
7194
if (!element || skip) {
95+
observedElementRef.current = element ?? null;
96+
cleanupExisting();
7297
return;
7398
}
7499

75-
let callbackCleanup:
76-
| undefined
77-
| ReturnType<IntersectionChangeEffect<TElement>>;
100+
cleanupExisting();
78101

102+
observedElementRef.current = element;
79103
const intersectionsStateTrigger = trigger !== "leave";
104+
let destroyed = false;
80105

81-
const destroyInViewObserver = observe(
106+
const destroyObserver = observe(
82107
element,
83108
(inView, entry) => {
84-
if (callbackCleanup) {
85-
callbackCleanup(entry);
86-
callbackCleanup = undefined;
109+
if (callbackCleanupRef.current) {
110+
const cleanup = callbackCleanupRef.current;
111+
callbackCleanupRef.current = undefined;
112+
cleanup(entry);
87113
if (triggerOnce) {
88-
destroyInViewObserver();
114+
stopObserving();
89115
return;
90116
}
91117
}
92118

93119
if (inView === intersectionsStateTrigger) {
94-
callbackCleanup = onIntersectionChangeRef.current(
120+
const nextCleanup = onIntersectionChangeRef.current(
95121
entry,
96-
destroyInViewObserver,
122+
stopObserving,
97123
);
124+
callbackCleanupRef.current =
125+
typeof nextCleanup === "function" ? nextCleanup : undefined;
98126

99-
if (triggerOnce && !callbackCleanup) {
100-
destroyInViewObserver();
127+
if (triggerOnce && !callbackCleanupRef.current) {
128+
stopObserving();
101129
}
102130
}
103131
},
104132
{
105133
threshold,
106134
root,
107135
rootMargin,
108-
// @ts-expect-error Track visibility is a non-standard extension
109136
trackVisibility,
110137
delay,
111-
},
138+
} as IntersectionObserverInit,
112139
);
113140

114-
return () => {
115-
destroyInViewObserver();
116-
callbackCleanup?.();
117-
};
141+
function stopObserving() {
142+
// Centralized teardown so both manual destroys and React ref updates share
143+
// the same cleanup path (needed for React versions that never call the ref with `null`).
144+
if (destroyed) return;
145+
destroyed = true;
146+
destroyObserver();
147+
observedElementRef.current = null;
148+
const cleanup = callbackCleanupRef.current;
149+
callbackCleanupRef.current = undefined;
150+
cleanup?.();
151+
observerCleanupRef.current = undefined;
152+
}
153+
154+
observerCleanupRef.current = stopObserving;
155+
156+
return observerCleanupRef.current;
118157
},
119158
[
120159
Array.isArray(threshold) ? threshold.toString() : threshold,

0 commit comments

Comments
 (0)