-
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?
Conversation
|
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
commit: |
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.
Pull request overview
This PR refactors the useInView hook to internally use useOnInView, adds React 19 ref cleanup support, and unifies the option types between the two hooks. The main goal is to improve compatibility with React 19's new ref cleanup behavior while maintaining backward compatibility with React 17/18.
Key changes:
- Introduces a
supportsRefCleanuputility to detect React 19+ and conditionally return cleanup functions from ref callbacks - Makes
IntersectionEffectOptionsidentical toIntersectionOptions, allowinguseOnInViewto acceptinitialInViewandfallbackInViewoptions - Refactors
useInViewto delegate observation logic touseOnInView, simplifying the implementation
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/reactVersion.ts | New utility file that detects React version and determines if ref cleanup is supported (React 19+) |
| src/useOnInView.tsx | Updated to support initialInView and fallbackInView options, and conditionally return cleanup functions based on React version |
| src/useInView.tsx | Refactored to use useOnInView internally, with a wrapper ref callback that handles state management and reset logic |
| src/index.tsx | Changed IntersectionEffectOptions from omitting certain options to being identical to IntersectionOptions |
| src/tests/useOnInView.test.tsx | Updated test imports to use React namespace and added supportsRefCleanup check in merged refs test |
| README.md | Updated documentation to reflect that useOnInView now only omits onChange option, not initialInView or fallbackInView |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| }: IntersectionEffectOptions = {}, | ||
| ) => { | ||
| const onIntersectionChangeRef = React.useRef(onIntersectionChange); | ||
| const initialInViewValue = initialInView ? true : 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.
The initialInViewValue is set to true when initialInView is truthy, but undefined otherwise. However, this doesn't correctly handle the case when initialInView is explicitly set to false.
When initialInView is false, the intention is to start with inView: false, but initialInViewValue will be set to undefined instead of false. This means the initial state and behavior won't match the user's expectation.
Consider changing the logic to: const initialInViewValue = initialInView ?? undefined; to properly preserve the false value when explicitly set.
| const initialInViewValue = initialInView ? true : undefined; | |
| const initialInViewValue = initialInView ?? undefined; |
| if (!supportsRefCleanup) { | ||
| return; | ||
| } |
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.
|
|
||
| `useOnInView` accepts the same options as `useInView` except `onChange`, | ||
| `initialInView`, and `fallbackInView`. | ||
| `useOnInView` accepts the same options as `useInView` except `onChange`. |
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 documentation now states that useOnInView accepts the same options as useInView except onChange, but the type definition at line 93 in src/index.tsx shows that IntersectionEffectOptions is now identical to IntersectionOptions, which includes the onChange property.
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 that onChange is technically allowed in the options (though the first parameter onIntersectionChange should be used instead).
| [inViewRef], | ||
| ); |
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.
| @@ -0,0 +1,5 @@ | |||
| import * as React from "react"; | |||
|
|
|||
| const major = Number.parseInt(React.version?.split(".")[0] ?? "", 10); | |||
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 version parsing logic assumes that React.version is always a string in the format "major.minor.patch", but if React.version is undefined or an empty string, split(".")[0] will return an empty string, which when passed to Number.parseInt will result in NaN. While the code handles NaN correctly by checking Number.isFinite, this could be more defensive.
Consider adding a check to ensure React.version exists before attempting to parse it, or provide a more explicit default value for better clarity.
| 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; |
| IntersectionOptions, | ||
| "onChange" | "fallbackInView" | "initialInView" | ||
| >; | ||
| export type IntersectionEffectOptions = IntersectionOptions; |
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 IntersectionEffectOptions type now includes the onChange property from IntersectionOptions, but useOnInView already has onIntersectionChange as its first parameter. This creates ambiguity - users could potentially pass both onIntersectionChange as the first argument and onChange in the options object.
The original design where IntersectionEffectOptions omitted onChange was intentional to prevent this confusion. Consider reverting to Omit<IntersectionOptions, "onChange"> or adding runtime logic to handle/validate when both are provided.
| export type IntersectionEffectOptions = IntersectionOptions; | |
| export type IntersectionEffectOptions = Omit<IntersectionOptions, "onChange">; |
| lastInViewRef.current = initialInViewValue; | ||
| return undefined; | ||
| } | ||
|
|
||
| cleanupExisting(); | ||
|
|
||
| observedElementRef.current = element; | ||
| lastInViewRef.current = initialInViewValue; |
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; |
| if (optionsRef.current.onChange) { | ||
| optionsRef.current.onChange(inView, entry); | ||
| } | ||
| }, options); |
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 useOnInView hook is called with options directly on line 54, but options is a new object reference on every render when passed inline (e.g., useInView({ threshold: 0.5 })). This will cause useOnInView to return a new ref callback on every render because its dependency array (lines 147-157 in useOnInView.tsx) will see different values.
While the optionsRef pattern is used to avoid issues with onChange callback changes, the same approach is not applied to the options passed to useOnInView. This could lead to unnecessary re-creation of IntersectionObservers and potential performance issues. Consider memoizing the options or using a similar ref pattern for the options passed to useOnInView.
| }, options); | |
| }, optionsRef.current); |
| initialInView, | ||
| fallbackInView, |
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 useOnInView hook now supports initialInView and fallbackInView options (lines 59-60), but there are no tests in the test file to verify this new functionality works correctly.
Tests should be added to ensure:
- The callback behaves correctly when
initialInView: trueis set - The callback behaves correctly when
initialInView: falseis set - The
fallbackInViewoption is properly passed through to theobservefunction - The initial state tracking in
lastInViewRefworks as expected with these options
This pull request refactors the
useInViewanduseOnInViewhooks to improve compatibility with React 19's ref cleanup behavior, simplifies option handling, and fixes inconsistencies between the hooks' option types.Importantly, it keeps support for React 17/18, while supporting the new
useCallback.Because it now uses the
useOnInViewhook, it meansentrywill be undefined until the first time it's added.React 19 ref cleanup support
supportsRefCleanupinsrc/reactVersion.ts) to detect if the current React version supports ref cleanup (i.e., React 19+), and updated all ref callback returns inuseInViewanduseOnInViewto only provide cleanup functions when supported. [1] [2] [3] [4] [5] [6]Hook option handling and type consistency
IntersectionEffectOptionstype to be identical toIntersectionOptions, allowinguseOnInViewto accept all the same options asuseInView, includinginitialInViewandfallbackInView.useOnInViewto supportinitialInViewandfallbackInViewoptions, and ensured correct initial state handling and cleanup. [1] [2] [3] [4]Refactoring and code simplification
useInViewto delegate intersection logic touseOnInView, simplifying its code and ensuring consistent option handling between the two hooks.Documentation updates
useOnInViewnow only omits theonChangeoption, notinitialInVieworfallbackInView.Test improvements
supportsRefCleanuputility for compatibility checks. [1] [2]