@@ -5,6 +5,15 @@ import type {
55} from "./index" ;
66import { 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