-
Notifications
You must be signed in to change notification settings - Fork 190
feat: refactor useInView to utilize useOnInView and useCallback #742
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -90,7 +90,4 @@ export type InViewHookResponse = [ | |||||
| entry?: IntersectionObserverEntry; | ||||||
| }; | ||||||
|
|
||||||
| export type IntersectionEffectOptions = Omit< | ||||||
| IntersectionOptions, | ||||||
| "onChange" | "fallbackInView" | "initialInView" | ||||||
| >; | ||||||
| export type IntersectionEffectOptions = IntersectionOptions; | ||||||
|
||||||
| export type IntersectionEffectOptions = IntersectionOptions; | |
| export type IntersectionEffectOptions = Omit<IntersectionOptions, "onChange">; |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,5 @@ | ||||||||||||||
| import * as React from "react"; | ||||||||||||||
|
|
||||||||||||||
| const major = Number.parseInt(React.version?.split(".")[0] ?? "", 10); | ||||||||||||||
|
||||||||||||||
| const major = Number.parseInt(React.version?.split(".")[0] ?? "", 10); | |
| const reactVersion = React.version; | |
| const major = | |
| typeof reactVersion === "string" && reactVersion.length > 0 | |
| ? Number.parseInt(reactVersion.split(".")[0], 10) | |
| : NaN; |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,6 +1,7 @@ | ||||||
| import * as React from "react"; | ||||||
| import type { IntersectionOptions, InViewHookResponse } from "./index"; | ||||||
| import { observe } from "./observe"; | ||||||
| import { supportsRefCleanup } from "./reactVersion"; | ||||||
| import { useOnInView } from "./useOnInView"; | ||||||
|
|
||||||
| type State = { | ||||||
| inView: boolean; | ||||||
|
|
@@ -33,116 +34,61 @@ type State = { | |||||
| * }; | ||||||
| * ``` | ||||||
| */ | ||||||
| export function useInView({ | ||||||
| threshold, | ||||||
| delay, | ||||||
| trackVisibility, | ||||||
| rootMargin, | ||||||
| root, | ||||||
| triggerOnce, | ||||||
| skip, | ||||||
| initialInView, | ||||||
| fallbackInView, | ||||||
| onChange, | ||||||
| }: IntersectionOptions = {}): InViewHookResponse { | ||||||
| const [ref, setRef] = React.useState<Element | null>(null); | ||||||
| const callback = React.useRef<IntersectionOptions["onChange"]>(onChange); | ||||||
| const lastInViewRef = React.useRef<boolean | undefined>(initialInView); | ||||||
| export function useInView( | ||||||
| options: IntersectionOptions = {}, | ||||||
| ): InViewHookResponse { | ||||||
| const [state, setState] = React.useState<State>({ | ||||||
| inView: !!initialInView, | ||||||
| inView: !!options.initialInView, | ||||||
| entry: undefined, | ||||||
| }); | ||||||
| const optionsRef = React.useRef(options); | ||||||
| optionsRef.current = options; | ||||||
| const entryTargetRef = React.useRef<Element | undefined>(undefined); | ||||||
|
|
||||||
| // Store the onChange callback in a `ref`, so we can access the latest instance | ||||||
| // inside the `useEffect`, but without triggering a rerender. | ||||||
| callback.current = onChange; | ||||||
|
|
||||||
| // biome-ignore lint/correctness/useExhaustiveDependencies: threshold is not correctly detected as a dependency | ||||||
| React.useEffect( | ||||||
| () => { | ||||||
| if (lastInViewRef.current === undefined) { | ||||||
| lastInViewRef.current = initialInView; | ||||||
| } | ||||||
| // Ensure we have node ref, and that we shouldn't skip observing | ||||||
| if (skip || !ref) return; | ||||||
|
|
||||||
| let unobserve: (() => void) | undefined; | ||||||
| unobserve = observe( | ||||||
| ref, | ||||||
| (inView, entry) => { | ||||||
| const previousInView = lastInViewRef.current; | ||||||
| lastInViewRef.current = inView; | ||||||
|
|
||||||
| // Ignore the very first `false` notification so consumers only hear about actual state changes. | ||||||
| if (previousInView === undefined && !inView) { | ||||||
| return; | ||||||
| } | ||||||
| const inViewRef = useOnInView((inView, entry) => { | ||||||
| entryTargetRef.current = entry.target; | ||||||
| setState({ inView, entry }); | ||||||
| if (optionsRef.current.onChange) { | ||||||
| optionsRef.current.onChange(inView, entry); | ||||||
| } | ||||||
| }, options); | ||||||
|
||||||
| }, options); | |
| }, optionsRef.current); |
Copilot
AI
Dec 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In React versions prior to 19, this callback returns undefined for non-null nodes, which means the cleanup logic in resetIfNeeded() will never be called when the element is unmounted in React 17/18. This is because React 17/18 doesn't call ref callbacks with null on unmount, and the cleanup function is not returned.
The original implementation handled this by using a useEffect that tracked the ref changes and cleaned up appropriately. With this new approach, the reset logic may not execute properly in React 17/18 when components unmount, potentially leaving stale state.
Copilot
AI
Dec 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The refCallback only includes inViewRef in its dependency array, but it also reads from optionsRef.current (lines 59-63) and entryTargetRef.current (line 64). While these are refs and their .current values can change without triggering re-creation of the callback, this could potentially cause issues.
Specifically, if options like skip, triggerOnce, or initialInView change between renders, the resetIfNeeded logic will use the latest values from optionsRef.current, but the inViewRef callback won't be updated because it was created with the old options. This could lead to inconsistent behavior where the reset logic uses different option values than the observation logic.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ import type { | |||||||||||||||||||||||||||||||||
| IntersectionEffectOptions, | ||||||||||||||||||||||||||||||||||
| } from "./index"; | ||||||||||||||||||||||||||||||||||
| import { observe } from "./observe"; | ||||||||||||||||||||||||||||||||||
| import { supportsRefCleanup } from "./reactVersion"; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const useSyncEffect = | ||||||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||||||
|
|
@@ -55,18 +56,21 @@ export const useOnInView = <TElement extends Element>( | |||||||||||||||||||||||||||||||||
| delay, | ||||||||||||||||||||||||||||||||||
| triggerOnce, | ||||||||||||||||||||||||||||||||||
| skip, | ||||||||||||||||||||||||||||||||||
| initialInView, | ||||||||||||||||||||||||||||||||||
| fallbackInView, | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+59
to
+60
|
||||||||||||||||||||||||||||||||||
| }: IntersectionEffectOptions = {}, | ||||||||||||||||||||||||||||||||||
| ) => { | ||||||||||||||||||||||||||||||||||
| const onIntersectionChangeRef = React.useRef(onIntersectionChange); | ||||||||||||||||||||||||||||||||||
| const initialInViewValue = initialInView ? true : undefined; | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
| const initialInViewValue = initialInView ? true : undefined; | |
| const initialInViewValue = initialInView ?? undefined; |
Copilot
AI
Dec 25, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Setting lastInViewRef.current to initialInViewValue when an element is being observed (line 100) may not be correct. The lastInViewRef is used to track the previous intersection state to determine if a state change has occurred. By resetting it to initialInViewValue on every new element observation, you're potentially losing the actual last intersection state.
This could cause the logic at line 110 ("Ignore the very first false notification") to behave incorrectly when re-observing elements or when the ref changes to a different element. The lastInViewRef should only be initialized once, not reset every time an element is observed.
| lastInViewRef.current = initialInViewValue; | |
| return undefined; | |
| } | |
| cleanupExisting(); | |
| observedElementRef.current = element; | |
| lastInViewRef.current = initialInViewValue; | |
| lastInViewRef.current = undefined; | |
| return undefined; | |
| } | |
| cleanupExisting(); | |
| observedElementRef.current = element; | |
| lastInViewRef.current = undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation now states that
useOnInViewaccepts the same options asuseInViewexceptonChange, but the type definition at line 93 insrc/index.tsxshows thatIntersectionEffectOptionsis now identical toIntersectionOptions, which includes theonChangeproperty.This creates a discrepancy between the documentation and the actual type definition. Either the type should exclude
onChange(as it did before), or the documentation should be updated to reflect thatonChangeis technically allowed in the options (though the first parameteronIntersectionChangeshould be used instead).