|
1 | 1 | import * as React from "react"; |
2 | 2 | import type { IntersectionOptions, InViewHookResponse } from "./index"; |
3 | | -import { observe } from "./observe"; |
| 3 | +import { supportsRefCleanup } from "./reactVersion"; |
| 4 | +import { useOnInView } from "./useOnInView"; |
4 | 5 |
|
5 | 6 | type State = { |
6 | 7 | inView: boolean; |
@@ -33,116 +34,61 @@ type State = { |
33 | 34 | * }; |
34 | 35 | * ``` |
35 | 36 | */ |
36 | | -export function useInView({ |
37 | | - threshold, |
38 | | - delay, |
39 | | - trackVisibility, |
40 | | - rootMargin, |
41 | | - root, |
42 | | - triggerOnce, |
43 | | - skip, |
44 | | - initialInView, |
45 | | - fallbackInView, |
46 | | - onChange, |
47 | | -}: IntersectionOptions = {}): InViewHookResponse { |
48 | | - const [ref, setRef] = React.useState<Element | null>(null); |
49 | | - const callback = React.useRef<IntersectionOptions["onChange"]>(onChange); |
50 | | - const lastInViewRef = React.useRef<boolean | undefined>(initialInView); |
| 37 | +export function useInView( |
| 38 | + options: IntersectionOptions = {}, |
| 39 | +): InViewHookResponse { |
51 | 40 | const [state, setState] = React.useState<State>({ |
52 | | - inView: !!initialInView, |
| 41 | + inView: !!options.initialInView, |
53 | 42 | entry: undefined, |
54 | 43 | }); |
| 44 | + const optionsRef = React.useRef(options); |
| 45 | + optionsRef.current = options; |
| 46 | + const entryTargetRef = React.useRef<Element | undefined>(undefined); |
55 | 47 |
|
56 | | - // Store the onChange callback in a `ref`, so we can access the latest instance |
57 | | - // inside the `useEffect`, but without triggering a rerender. |
58 | | - callback.current = onChange; |
59 | | - |
60 | | - // biome-ignore lint/correctness/useExhaustiveDependencies: threshold is not correctly detected as a dependency |
61 | | - React.useEffect( |
62 | | - () => { |
63 | | - if (lastInViewRef.current === undefined) { |
64 | | - lastInViewRef.current = initialInView; |
65 | | - } |
66 | | - // Ensure we have node ref, and that we shouldn't skip observing |
67 | | - if (skip || !ref) return; |
68 | | - |
69 | | - let unobserve: (() => void) | undefined; |
70 | | - unobserve = observe( |
71 | | - ref, |
72 | | - (inView, entry) => { |
73 | | - const previousInView = lastInViewRef.current; |
74 | | - lastInViewRef.current = inView; |
75 | | - |
76 | | - // Ignore the very first `false` notification so consumers only hear about actual state changes. |
77 | | - if (previousInView === undefined && !inView) { |
78 | | - return; |
79 | | - } |
| 48 | + const inViewRef = useOnInView((inView, entry) => { |
| 49 | + entryTargetRef.current = entry.target; |
| 50 | + setState({ inView, entry }); |
| 51 | + if (optionsRef.current.onChange) { |
| 52 | + optionsRef.current.onChange(inView, entry); |
| 53 | + } |
| 54 | + }, options); |
80 | 55 |
|
| 56 | + const refCallback = React.useCallback( |
| 57 | + (node: Element | null) => { |
| 58 | + const resetIfNeeded = () => { |
| 59 | + const { |
| 60 | + skip, |
| 61 | + triggerOnce, |
| 62 | + initialInView: latestInitialInView, |
| 63 | + } = optionsRef.current; |
| 64 | + if (!skip && !triggerOnce && entryTargetRef.current) { |
81 | 65 | setState({ |
82 | | - inView, |
83 | | - entry, |
| 66 | + inView: !!latestInitialInView, |
| 67 | + entry: undefined, |
84 | 68 | }); |
85 | | - if (callback.current) callback.current(inView, entry); |
| 69 | + entryTargetRef.current = undefined; |
| 70 | + } |
| 71 | + }; |
| 72 | + const cleanup = inViewRef(node); |
86 | 73 |
|
87 | | - if (entry.isIntersecting && triggerOnce && unobserve) { |
88 | | - // If it should only trigger once, unobserve the element after it's inView |
89 | | - unobserve(); |
90 | | - unobserve = undefined; |
91 | | - } |
92 | | - }, |
93 | | - { |
94 | | - root, |
95 | | - rootMargin, |
96 | | - threshold, |
97 | | - // @ts-expect-error |
98 | | - trackVisibility, |
99 | | - delay, |
100 | | - }, |
101 | | - fallbackInView, |
102 | | - ); |
| 74 | + if (!node) { |
| 75 | + resetIfNeeded(); |
| 76 | + return; |
| 77 | + } |
| 78 | + |
| 79 | + if (!supportsRefCleanup) { |
| 80 | + return; |
| 81 | + } |
103 | 82 |
|
104 | 83 | return () => { |
105 | | - if (unobserve) { |
106 | | - unobserve(); |
107 | | - } |
| 84 | + cleanup?.(); |
| 85 | + resetIfNeeded(); |
108 | 86 | }; |
109 | 87 | }, |
110 | | - // We break the rule here, because we aren't including the actual `threshold` variable |
111 | | - // eslint-disable-next-line react-hooks/exhaustive-deps |
112 | | - [ |
113 | | - // If the threshold is an array, convert it to a string, so it won't change between renders. |
114 | | - Array.isArray(threshold) ? threshold.toString() : threshold, |
115 | | - ref, |
116 | | - root, |
117 | | - rootMargin, |
118 | | - triggerOnce, |
119 | | - skip, |
120 | | - trackVisibility, |
121 | | - fallbackInView, |
122 | | - delay, |
123 | | - ], |
| 88 | + [inViewRef], |
124 | 89 | ); |
125 | 90 |
|
126 | | - const entryTarget = state.entry?.target; |
127 | | - const previousEntryTarget = React.useRef<Element | undefined>(undefined); |
128 | | - if ( |
129 | | - !ref && |
130 | | - entryTarget && |
131 | | - !triggerOnce && |
132 | | - !skip && |
133 | | - previousEntryTarget.current !== entryTarget |
134 | | - ) { |
135 | | - // If we don't have a node ref, then reset the state (unless the hook is set to only `triggerOnce` or `skip`) |
136 | | - // This ensures we correctly reflect the current state - If you aren't observing anything, then nothing is inView |
137 | | - previousEntryTarget.current = entryTarget; |
138 | | - setState({ |
139 | | - inView: !!initialInView, |
140 | | - entry: undefined, |
141 | | - }); |
142 | | - lastInViewRef.current = initialInView; |
143 | | - } |
144 | | - |
145 | | - const result = [setRef, state.inView, state.entry] as InViewHookResponse; |
| 91 | + const result = [refCallback, state.inView, state.entry] as InViewHookResponse; |
146 | 92 |
|
147 | 93 | // Support object destructuring, by adding the specific values. |
148 | 94 | result.ref = result[0]; |
|
0 commit comments