Skip to content

Conversation

@thebuilder
Copy link
Owner

This pull request refactors the useInView and useOnInView hooks 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 useOnInView hook, it means entry will be undefined until the first time it's added.

React 19 ref cleanup support

  • Added a new utility (supportsRefCleanup in src/reactVersion.ts) to detect if the current React version supports ref cleanup (i.e., React 19+), and updated all ref callback returns in useInView and useOnInView to only provide cleanup functions when supported. [1] [2] [3] [4] [5] [6]

Hook option handling and type consistency

  • Changed IntersectionEffectOptions type to be identical to IntersectionOptions, allowing useOnInView to accept all the same options as useInView, including initialInView and fallbackInView.
  • Updated the implementation of useOnInView to support initialInView and fallbackInView options, and ensured correct initial state handling and cleanup. [1] [2] [3] [4]

Refactoring and code simplification

  • Refactored useInView to delegate intersection logic to useOnInView, simplifying its code and ensuring consistent option handling between the two hooks.

Documentation updates

  • Updated the documentation to clarify that useOnInView now only omits the onChange option, not initialInView or fallbackInView.

Test improvements

  • Updated tests to import React as a namespace and to use the new supportsRefCleanup utility for compatibility checks. [1] [2]

Copilot AI review requested due to automatic review settings December 25, 2025 22:58
@bolt-new-by-stackblitz
Copy link

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@vercel
Copy link

vercel bot commented Dec 25, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
react-intersection-observer Ready Ready Preview, Comment Dec 25, 2025 10:58pm

@pkg-pr-new
Copy link

pkg-pr-new bot commented Dec 25, 2025

npm i https://pkg.pr.new/thebuilder/react-intersection-observer@742

commit: 94d9844

Copy link

Copilot AI left a 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 supportsRefCleanup utility to detect React 19+ and conditionally return cleanup functions from ref callbacks
  • Makes IntersectionEffectOptions identical to IntersectionOptions, allowing useOnInView to accept initialInView and fallbackInView options
  • Refactors useInView to delegate observation logic to useOnInView, 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;
Copy link

Copilot AI Dec 25, 2025

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.

Suggested change
const initialInViewValue = initialInView ? true : undefined;
const initialInViewValue = initialInView ?? undefined;

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +81
if (!supportsRefCleanup) {
return;
}
Copy link

Copilot AI Dec 25, 2025

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 uses AI. Check for mistakes.

`useOnInView` accepts the same options as `useInView` except `onChange`,
`initialInView`, and `fallbackInView`.
`useOnInView` accepts the same options as `useInView` except `onChange`.
Copy link

Copilot AI Dec 25, 2025

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).

Copilot uses AI. Check for mistakes.
Comment on lines +88 to 89
[inViewRef],
);
Copy link

Copilot AI Dec 25, 2025

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.

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,5 @@
import * as React from "react";

const major = Number.parseInt(React.version?.split(".")[0] ?? "", 10);
Copy link

Copilot AI Dec 25, 2025

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
IntersectionOptions,
"onChange" | "fallbackInView" | "initialInView"
>;
export type IntersectionEffectOptions = IntersectionOptions;
Copy link

Copilot AI Dec 25, 2025

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.

Suggested change
export type IntersectionEffectOptions = IntersectionOptions;
export type IntersectionEffectOptions = Omit<IntersectionOptions, "onChange">;

Copilot uses AI. Check for mistakes.
Comment on lines +93 to +100
lastInViewRef.current = initialInViewValue;
return undefined;
}

cleanupExisting();

observedElementRef.current = element;
lastInViewRef.current = initialInViewValue;
Copy link

Copilot AI Dec 25, 2025

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.

Suggested change
lastInViewRef.current = initialInViewValue;
return undefined;
}
cleanupExisting();
observedElementRef.current = element;
lastInViewRef.current = initialInViewValue;
lastInViewRef.current = undefined;
return undefined;
}
cleanupExisting();
observedElementRef.current = element;
lastInViewRef.current = undefined;

Copilot uses AI. Check for mistakes.
if (optionsRef.current.onChange) {
optionsRef.current.onChange(inView, entry);
}
}, options);
Copy link

Copilot AI Dec 25, 2025

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.

Suggested change
}, options);
}, optionsRef.current);

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +60
initialInView,
fallbackInView,
Copy link

Copilot AI Dec 25, 2025

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:

  1. The callback behaves correctly when initialInView: true is set
  2. The callback behaves correctly when initialInView: false is set
  3. The fallbackInView option is properly passed through to the observe function
  4. The initial state tracking in lastInViewRef works as expected with these options

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants