From 148d32621512340f6f461e6733c1fef236a3d823 Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Fri, 28 Feb 2025 16:39:33 +0100 Subject: [PATCH 01/16] use react 19 useCallback for reference tracking --- biome.json | 3 + package.json | 8 +- pnpm-lock.yaml | 32 ++++++-- src/InView.tsx | 25 ++---- src/__tests__/InView.test.tsx | 60 --------------- src/__tests__/hooks.test.tsx | 56 +------------- src/index.tsx | 8 +- src/observe.ts | 33 -------- src/useInView.tsx | 138 ++++++++++++++-------------------- src/useOnInViewChanged.tsx | 130 ++++++++++++++++++++++++++++++++ 10 files changed, 230 insertions(+), 263 deletions(-) create mode 100644 src/useOnInViewChanged.tsx diff --git a/biome.json b/biome.json index 89230f14..4cd61786 100644 --- a/biome.json +++ b/biome.json @@ -30,6 +30,9 @@ }, "a11y": { "noSvgWithoutTitle": "off" + }, + "correctness": { + "useExhaustiveDependencies": "off" } } }, diff --git a/package.json b/package.json index a6401816..1b2b3821 100644 --- a/package.json +++ b/package.json @@ -107,8 +107,8 @@ } ], "peerDependencies": { - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "devDependencies": { "@arethetypeswrong/cli": "^0.17.2", @@ -116,8 +116,8 @@ "@size-limit/preset-small-lib": "^11.1.6", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", - "@types/react": "^19.0.2", - "@types/react-dom": "^19.0.2", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", "@vitest/browser": "^2.1.8", "@vitest/coverage-istanbul": "^2.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bff4c67..df3c634a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,13 +22,13 @@ importers: version: 6.6.3 '@testing-library/react': specifier: ^16.1.0 - version: 16.1.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 16.1.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@types/react': - specifier: ^19.0.2 - version: 19.0.2 + specifier: ^19.0.10 + version: 19.0.10 '@types/react-dom': - specifier: ^19.0.2 - version: 19.0.2(@types/react@19.0.2) + specifier: ^19.0.4 + version: 19.0.4(@types/react@19.0.10) '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.3.4(vite@5.4.11(@types/node@22.10.2)(terser@5.37.0)) @@ -2124,6 +2124,14 @@ packages: peerDependencies: '@types/react': ^19.0.0 + '@types/react-dom@19.0.4': + resolution: {integrity: sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==} + peerDependencies: + '@types/react': ^19.0.0 + + '@types/react@19.0.10': + resolution: {integrity: sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==} + '@types/react@19.0.2': resolution: {integrity: sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg==} @@ -7024,15 +7032,15 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/react@16.1.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.2(@types/react@19.0.2))(@types/react@19.0.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + '@testing-library/react@16.1.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@babel/runtime': 7.26.0 '@testing-library/dom': 10.4.0 react: 19.0.0 react-dom: 19.0.0(react@19.0.0) optionalDependencies: - '@types/react': 19.0.2 - '@types/react-dom': 19.0.2(@types/react@19.0.2) + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': dependencies: @@ -7087,6 +7095,14 @@ snapshots: dependencies: '@types/react': 19.0.2 + '@types/react-dom@19.0.4(@types/react@19.0.10)': + dependencies: + '@types/react': 19.0.10 + + '@types/react@19.0.10': + dependencies: + csstype: 3.1.3 + '@types/react@19.0.2': dependencies: csstype: 3.1.3 diff --git a/src/InView.tsx b/src/InView.tsx index 0c27ccfa..b48930b0 100644 --- a/src/InView.tsx +++ b/src/InView.tsx @@ -103,29 +103,17 @@ export class InView extends React.Component< observeNode() { if (!this.node || this.props.skip) return; - const { + const { threshold, root, rootMargin, trackVisibility, delay } = this.props; + + this._unobserveCb = observe(this.node, this.handleChange, { threshold, root, rootMargin, + // @ts-ignore trackVisibility, + // @ts-ignore delay, - fallbackInView, - } = this.props; - - this._unobserveCb = observe( - this.node, - this.handleChange, - { - threshold, - root, - rootMargin, - // @ts-ignore - trackVisibility, - // @ts-ignore - delay, - }, - fallbackInView, - ); + }); } unobserve() { @@ -184,7 +172,6 @@ export class InView extends React.Component< trackVisibility, delay, initialInView, - fallbackInView, ...props } = this.props as PlainChildrenProps; diff --git a/src/__tests__/InView.test.tsx b/src/__tests__/InView.test.tsx index 29370559..577fd28c 100644 --- a/src/__tests__/InView.test.tsx +++ b/src/__tests__/InView.test.tsx @@ -2,7 +2,6 @@ import { render, screen } from "@testing-library/react"; import { userEvent } from "@vitest/browser/context"; import React from "react"; import { InView } from "../InView"; -import { defaultFallbackInView } from "../observe"; import { intersectionMockInstance, mockAllIsIntersecting } from "../test-utils"; test("Should render intersecting", () => { @@ -157,62 +156,3 @@ test("plain children should not catch bubbling onChange event", async () => { await userEvent.type(input, "changed value"); expect(onChange).not.toHaveBeenCalled(); }); - -test("should render with fallback", () => { - const cb = vi.fn(); - // @ts-ignore - window.IntersectionObserver = undefined; - render( - - Inner - , - ); - expect(cb).toHaveBeenLastCalledWith( - true, - expect.objectContaining({ isIntersecting: true }), - ); - - render( - - Inner - , - ); - expect(cb).toHaveBeenLastCalledWith( - false, - expect.objectContaining({ isIntersecting: false }), - ); - - expect(() => { - vi.spyOn(console, "error").mockImplementation(() => {}); - render(Inner); - // @ts-ignore - console.error.mockRestore(); - }).toThrow(); -}); - -test("should render with global fallback", () => { - const cb = vi.fn(); - // @ts-ignore - window.IntersectionObserver = undefined; - defaultFallbackInView(true); - render(Inner); - expect(cb).toHaveBeenLastCalledWith( - true, - expect.objectContaining({ isIntersecting: true }), - ); - - defaultFallbackInView(false); - render(Inner); - expect(cb).toHaveBeenLastCalledWith( - false, - expect.objectContaining({ isIntersecting: false }), - ); - - defaultFallbackInView(undefined); - expect(() => { - vi.spyOn(console, "error").mockImplementation(() => {}); - render(Inner); - // @ts-ignore - console.error.mockRestore(); - }).toThrow(); -}); diff --git a/src/__tests__/hooks.test.tsx b/src/__tests__/hooks.test.tsx index 7cee2f6f..b6623691 100644 --- a/src/__tests__/hooks.test.tsx +++ b/src/__tests__/hooks.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from "@testing-library/react"; import React, { useCallback } from "react"; -import { type IntersectionOptions, defaultFallbackInView } from "../index"; +import type { IntersectionOptions } from "../index"; import { destroyIntersectionMocking, intersectionMockInstance, @@ -235,9 +235,7 @@ test("should handle ref removed", () => { const MergeRefsComponent = ({ options }: { options?: IntersectionOptions }) => { const [inViewRef, inView] = useInView(options); const setRef = useCallback( - (node: Element | null) => { - inViewRef(node); - }, + (node: Element | null) => inViewRef(node), [inViewRef], ); @@ -263,9 +261,8 @@ const MultipleHookComponent = ({ const mergedRefs = useCallback( (node: Element | null) => { - ref1(node); - ref2(node); - ref3(node); + const cleanup = [ref1(node), ref2(node), ref3(node)]; + return () => cleanup.forEach((fn) => fn()); }, [ref1, ref2, ref3], ); @@ -342,51 +339,6 @@ test("should set intersection ratio as the largest threshold smaller than trigge screen.getByText(/intersectionRatio: 0.5/); }); -test("should handle fallback if unsupported", () => { - destroyIntersectionMocking(); - // @ts-ignore - window.IntersectionObserver = undefined; - const { rerender } = render( - , - ); - screen.getByText("true"); - - rerender(); - screen.getByText("false"); - - expect(() => { - vi.spyOn(console, "error").mockImplementation(() => {}); - rerender(); - // @ts-ignore - console.error.mockRestore(); - }).toThrowErrorMatchingInlineSnapshot( - `[TypeError: IntersectionObserver is not a constructor]`, - ); -}); - -test("should handle defaultFallbackInView if unsupported", () => { - destroyIntersectionMocking(); - // @ts-ignore - window.IntersectionObserver = undefined; - defaultFallbackInView(true); - const { rerender } = render(); - screen.getByText("true"); - - defaultFallbackInView(false); - rerender(); - screen.getByText("false"); - - defaultFallbackInView(undefined); - expect(() => { - vi.spyOn(console, "error").mockImplementation(() => {}); - rerender(); - // @ts-ignore - console.error.mockRestore(); - }).toThrowErrorMatchingInlineSnapshot( - `[TypeError: IntersectionObserver is not a constructor]`, - ); -}); - test("should restore the browser IntersectionObserver", () => { expect(vi.isMockFunction(window.IntersectionObserver)).toBe(true); destroyIntersectionMocking(); diff --git a/src/index.tsx b/src/index.tsx index cec16ed2..14442e2d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,7 @@ import type * as React from "react"; export { InView } from "./InView"; export { useInView } from "./useInView"; -export { observe, defaultFallbackInView } from "./observe"; +export { observe } from "./observe"; type Omit = Pick>; @@ -32,8 +32,6 @@ export interface IntersectionOptions extends IntersectionObserverInit { skip?: boolean; /** Set the initial value of the `inView` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. */ initialInView?: boolean; - /** Fallback to this inView state if the IntersectionObserver is unsupported, and a polyfill wasn't loaded */ - fallbackInView?: boolean; /** IntersectionObserver v2 - Track the actual visibility of the element */ trackVisibility?: boolean; /** IntersectionObserver v2 - Set a minimum delay between notifications */ @@ -74,11 +72,11 @@ export type PlainChildrenProps = IntersectionOptions & { * The Hook response supports both array and object destructing */ export type InViewHookResponse = [ - (node?: Element | null) => void, + (node?: Element | null) => () => void, boolean, IntersectionObserverEntry | undefined, ] & { - ref: (node?: Element | null) => void; + ref: (node?: Element | null) => () => void; inView: boolean; entry?: IntersectionObserverEntry; }; diff --git a/src/observe.ts b/src/observe.ts index 5e0a79d3..97552603 100644 --- a/src/observe.ts +++ b/src/observe.ts @@ -12,18 +12,6 @@ const observerMap = new Map< const RootIds: WeakMap = new WeakMap(); let rootId = 0; -let unsupportedValue: boolean | undefined = undefined; - -/** - * What should be the default behavior if the IntersectionObserver is unsupported? - * Ideally the polyfill has been loaded, you can have the following happen: - * - `undefined`: Throw an error - * - `true` or `false`: Set the `inView` value to this regardless of intersection state - * **/ -export function defaultFallbackInView(inView: boolean | undefined) { - unsupportedValue = inView; -} - /** * Generate a unique ID for the root element * @param root @@ -112,34 +100,13 @@ function createObserver(options: IntersectionObserverInit) { * @param element - DOM Element to observe * @param callback - Callback function to trigger when intersection status changes * @param options - Intersection Observer options - * @param fallbackInView - Fallback inView value. * @return Function - Cleanup function that should be triggered to unregister the observer */ export function observe( element: Element, callback: ObserverInstanceCallback, options: IntersectionObserverInit = {}, - fallbackInView = unsupportedValue, ) { - if ( - typeof window.IntersectionObserver === "undefined" && - fallbackInView !== undefined - ) { - const bounds = element.getBoundingClientRect(); - callback(fallbackInView, { - isIntersecting: fallbackInView, - target: element, - intersectionRatio: - typeof options.threshold === "number" ? options.threshold : 0, - time: 0, - boundingClientRect: bounds, - intersectionRect: bounds, - rootBounds: bounds, - }); - return () => { - // Nothing to cleanup - }; - } // An observer with the same options can be reused, so lets use this fact const { id, observer, elements } = createObserver(options); diff --git a/src/useInView.tsx b/src/useInView.tsx index 4eda4969..888fbe8b 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import type { InViewHookResponse, IntersectionOptions } from "./index"; -import { observe } from "./observe"; +import { useOnInViewChanged } from "./useOnInViewChanged"; type State = { inView: boolean; @@ -33,106 +33,80 @@ type State = { * }; * ``` */ -export function useInView({ - threshold, - delay, - trackVisibility, - rootMargin, - root, - triggerOnce, - skip, - initialInView, - fallbackInView, - onChange, -}: IntersectionOptions = {}): InViewHookResponse { - const [ref, setRef] = React.useState(null); - const callback = React.useRef(onChange); +export function useInView( + options: IntersectionOptions = {}, +): InViewHookResponse { + const { + threshold, + delay, + trackVisibility, + rootMargin, + root, + triggerOnce, + skip, + initialInView, + } = options; + + // State for tracking inView status and the IntersectionObserverEntry const [state, setState] = React.useState({ inView: !!initialInView, entry: 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; + // Store the onChange callback in a ref + const latestOptions = React.useRef(options); + latestOptions.current = options; - // biome-ignore lint/correctness/useExhaustiveDependencies: threshold is not correctly detected as a dependency - React.useEffect( - () => { - // Ensure we have node ref, and that we shouldn't skip observing - if (skip || !ref) return; + // Create the ref tracking function using useOnInViewChanged + const refCallback = useOnInViewChanged( + // Combined callback - updates state, calls onChange, and returns cleanup if needed + (inView, entry) => { + setState({ inView, entry }); - let unobserve: (() => void) | undefined; - unobserve = observe( - ref, - (inView, entry) => { - setState({ - inView, - entry, - }); - if (callback.current) callback.current(inView, entry); + // Call the external onChange if provided + const { onChange } = latestOptions.current; + if (onChange && entry) { + onChange(inView, entry); + } - if (entry.isIntersecting && triggerOnce && unobserve) { - // If it should only trigger once, unobserve the element after it's inView - unobserve(); - unobserve = undefined; - } - }, - { - root, - rootMargin, - threshold, - // @ts-ignore - trackVisibility, - // @ts-ignore - delay, - }, - fallbackInView, - ); + // If triggerOnce is true, we don't need to reset state in cleanup + if (triggerOnce) { + return undefined; + } - return () => { - if (unobserve) { - unobserve(); + // Return cleanup function that will run when element is removed or goes out of view + return (entry) => { + // Call the external onChange if provided + if (onChange && entry) { + onChange(false, entry); + } + // should not reset current state if changing skip + if (!latestOptions.current.skip) { + setState({ + inView: false, + entry: undefined, + }); } }; }, - // We break the rule here, because we aren't including the actual `threshold` variable - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - // If the threshold is an array, convert it to a string, so it won't change between renders. - Array.isArray(threshold) ? threshold.toString() : threshold, - ref, + { root, rootMargin, - triggerOnce, - skip, + threshold, + // @ts-ignore trackVisibility, - fallbackInView, + // @ts-ignore delay, - ], + initialInView, + triggerOnce, + skip, + }, ); - const entryTarget = state.entry?.target; - const previousEntryTarget = React.useRef(undefined); - if ( - !ref && - entryTarget && - !triggerOnce && - !skip && - previousEntryTarget.current !== entryTarget - ) { - // If we don't have a node ref, then reset the state (unless the hook is set to only `triggerOnce` or `skip`) - // This ensures we correctly reflect the current state - If you aren't observing anything, then nothing is inView - previousEntryTarget.current = entryTarget; - setState({ - inView: !!initialInView, - entry: undefined, - }); - } - - const result = [setRef, state.inView, state.entry] as InViewHookResponse; + // Build the result with the same API as the original hook + const result = [refCallback, state.inView, state.entry] as InViewHookResponse; - // Support object destructuring, by adding the specific values. + // Add named properties for object destructuring support result.ref = result[0]; result.inView = result[1]; result.entry = result[2]; diff --git a/src/useOnInViewChanged.tsx b/src/useOnInViewChanged.tsx new file mode 100644 index 00000000..37a414a3 --- /dev/null +++ b/src/useOnInViewChanged.tsx @@ -0,0 +1,130 @@ +import * as React from "react"; +import type { IntersectionOptions } from "./index"; +import { observe } from "./observe"; + +/** + * React Hooks make it easy to monitor when elements come into and leave view. Call + * the `useOnInViewChanged` hook with your callback and (optional) [options](#options). + * It will return a ref callback that you can assign to the DOM element you want to monitor. + * When the element enters or leaves the viewport, your callback will be triggered. + * + * This hook triggers no re-renders, and is useful for performance-critical use-cases or + * when you need to trigger render independent side-effects like tracking or logging. + * + * @example + * ```jsx + * import React from 'react'; + * import { useOnInViewChanged } from 'react-intersection-observer'; + * + * const Component = () => { + * const inViewRef = useOnInViewChanged((inView, entry, element) => { + * console.log(`Element is ${inView ? 'in view' : 'out of view'}`); + * // Optional: cleanup function: + * return () => { + * console.log('Element moved out of view or unmounted'); + * }; + * }, { + * threshold: 0, + * }); + * + * return ( + *
+ *

This element is being monitored

+ *
+ * ); + * }; + * ``` + */ +export const useOnInViewChanged = ( + onGetsIntoView: ( + inView: boolean, + entry: IntersectionObserverEntry | undefined, + element: TElement, + ) => undefined | ((entry: IntersectionObserverEntry | undefined) => void), + { + threshold, + delay, + trackVisibility, + rootMargin, + root, + triggerOnce, + skip, + initialInView, + }: IntersectionOptions = {}, + dependencies: React.DependencyList = [], +) => { + // Store the onGetsIntoView in a ref to avoid triggering recreation + const onGetsIntoViewRef = React.useRef(onGetsIntoView); + onGetsIntoViewRef.current = onGetsIntoView; + + return React.useCallback( + (element: TElement | undefined | null) => { + if (!element || skip) { + return; + } + + let callbackCleanup: + | undefined + | ((entry?: IntersectionObserverEntry) => void); + let didTriggerOnce = false; + + // If initialInView is true, we have to call the callback immediately + // to get a cleanup function for the out of view event + if (initialInView) { + callbackCleanup = onGetsIntoViewRef.current(true, undefined, element); + } + + const destroyInviewObserver = observe( + element, + (inView, entry) => { + // Call cleanup when going out of view + if (!inView) { + if (triggerOnce && didTriggerOnce) { + destroyInviewObserver?.(); + } + callbackCleanup?.(entry); + callbackCleanup = undefined; + return; + } + + // Call callback with inView state, entry, and element + callbackCleanup = onGetsIntoViewRef.current(inView, entry, element); + + didTriggerOnce = true; + + // if the cleanup is not waiting for the element to go out of view + // and triggerOnce is true, we can destroy the observer + if (triggerOnce && !callbackCleanup) { + destroyInviewObserver?.(); + } + }, + { + root, + rootMargin, + threshold, + // @ts-ignore + trackVisibility, + // @ts-ignore + delay, + }, + ); + // Return cleanup function for React 19's ref callback + return () => { + destroyInviewObserver(); + callbackCleanup?.(); + }; + }, + // We break the rule here, because we aren't including the actual `threshold` variable + [ + ...dependencies, + root, + rootMargin, + // Convert threshold array to string for stable dependency + Array.isArray(threshold) ? threshold.toString() : threshold, + trackVisibility, + delay, + triggerOnce, + skip, + ], + ); +}; From d4bb4c4ea82a5feef67ea261e9b76370c32fb4fe Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Fri, 28 Feb 2025 17:21:48 +0100 Subject: [PATCH 02/16] comments --- src/index.tsx | 1 + src/useInView.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 14442e2d..adb4b29a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,7 @@ import type * as React from "react"; export { InView } from "./InView"; export { useInView } from "./useInView"; +export { useOnInViewChanged } from "./useOnInViewChanged"; export { observe } from "./observe"; type Omit = Pick>; diff --git a/src/useInView.tsx b/src/useInView.tsx index 888fbe8b..1a767ede 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -63,20 +63,24 @@ export function useInView( (inView, entry) => { setState({ inView, entry }); - // Call the external onChange if provided const { onChange } = latestOptions.current; + // Call the external onChange if provided + // entry is undefined only if this is triggered by initialInView if (onChange && entry) { onChange(inView, entry); } - // If triggerOnce is true, we don't need to reset state in cleanup + // If triggerOnce is true no reset state is done in the cleanup + // this allows destroying the observer as soon as the element is inView if (triggerOnce) { return undefined; } // Return cleanup function that will run when element is removed or goes out of view return (entry) => { + const { onChange } = latestOptions.current; // Call the external onChange if provided + // entry is undefined if the element is getting unmounted if (onChange && entry) { onChange(false, entry); } From 2ff6b92376b20acfc4595002102d353e566811b4 Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Fri, 28 Feb 2025 23:08:39 +0100 Subject: [PATCH 03/16] types, tests, readme --- README.md | 54 ++++ src/__tests__/useOnInViewChanged.test.tsx | 361 ++++++++++++++++++++++ src/index.tsx | 17 +- src/useInView.tsx | 51 ++- src/useOnInViewChanged.tsx | 19 +- 5 files changed, 465 insertions(+), 37 deletions(-) create mode 100644 src/__tests__/useOnInViewChanged.test.tsx diff --git a/README.md b/README.md index 242c9201..81435f36 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,60 @@ You can read more about this on these links: - [w3c/IntersectionObserver: Cannot track intersection with an iframe's viewport](https://github.com/w3c/IntersectionObserver/issues/372) - [w3c/Support iframe viewport tracking](https://github.com/w3c/IntersectionObserver/pull/465) +### `useOnInViewChanged` hook + +```js +const inViewRef = useOnInViewChanged( + (element, entry) => { + // Do something with the element that came into view + console.log('Element is in view', element); + + // Optionally return a cleanup function + return (entry) => { + console.log('Element moved out of view or unmounted'); + }; + }, + options // Optional IntersectionObserver options +); +``` + +The `useOnInViewChanged` hook provides a more direct alternative to `useInView`. It takes a callback function and returns a ref that you can assign to the DOM element you want to monitor. When the element enters the viewport, your callback will be triggered. + +Key differences from `useInView`: +- **No re-renders** - This hook doesn't update any state, making it ideal for performance-critical scenarios +- **Direct element access** - Your callback receives the actual DOM element and the IntersectionObserverEntry +- **Optional cleanup** - Return a function from your callback to run when the element leaves the viewport or is unmounted +- **Same options** - Accepts all the same [options](#options) as `useInView` except `onChange` + +```jsx +import React from "react"; +import { useOnInViewChanged } from "react-intersection-observer"; + +const Component = () => { + // Track when element appears without causing re-renders + const trackingRef = useOnInViewChanged((element, entry) => { + // Element is in view - perhaps log an impression + console.log("Element appeared in view"); + + // Return optional cleanup function + return () => { + console.log("Element left view"); + }; + }, { + /* Optional options */ + threshold: 0.5, + }); + + return ( +
+

This element is being tracked without re-renders

+
+ ); +}; + +export default Component; +``` + ## Testing > [!TIP] diff --git a/src/__tests__/useOnInViewChanged.test.tsx b/src/__tests__/useOnInViewChanged.test.tsx new file mode 100644 index 00000000..ae9f7662 --- /dev/null +++ b/src/__tests__/useOnInViewChanged.test.tsx @@ -0,0 +1,361 @@ +import { render } from "@testing-library/react"; +import React, { useCallback } from "react"; +import type { IntersectionListenerOptions } from "../index"; +import { intersectionMockInstance, mockAllIsIntersecting } from "../test-utils"; +import { useOnInViewChanged } from "../useOnInViewChanged"; + +const OnInViewChangedComponent = ({ + options, + unmount, +}: { + options?: IntersectionListenerOptions; + unmount?: boolean; +}) => { + const [inView, setInView] = React.useState(false); + const [callCount, setCallCount] = React.useState(0); + const [cleanupCount, setCleanupCount] = React.useState(0); + + const inViewRef = useOnInViewChanged((element, entry) => { + setInView(entry ? entry.isIntersecting : false); + setCallCount((prev) => prev + 1); + + // Return cleanup function + return (entry) => { + setCleanupCount((prev) => prev + 1); + if (entry) { + setInView(false); + } + }; + }, options); + + return ( +
+ {inView.toString()} +
+ ); +}; + +const LazyOnInViewChangedComponent = ({ + options, +}: { + options?: IntersectionListenerOptions; +}) => { + const [isLoading, setIsLoading] = React.useState(true); + const [inView, setInView] = React.useState(false); + + React.useEffect(() => { + setIsLoading(false); + }, []); + + const inViewRef = useOnInViewChanged((element, entry) => { + setInView(entry ? entry.isIntersecting : false); + return () => setInView(false); + }, options); + + if (isLoading) return
Loading
; + + return ( +
+ {inView.toString()} +
+ ); +}; + +const OnInViewChangedComponentWithoutClenaup = ({ + options, + unmount, +}: { + options?: IntersectionListenerOptions; + unmount?: boolean; +}) => { + const [callCount, setCallCount] = React.useState(0); + const inViewRef = useOnInViewChanged(() => { + setCallCount((prev) => prev + 1); + }, options); + + return ( +
+ ); +}; + +test("should create a hook with useOnInViewChanged", () => { + const { getByTestId } = render(); + const wrapper = getByTestId("wrapper"); + const instance = intersectionMockInstance(wrapper); + + expect(instance.observe).toHaveBeenCalledWith(wrapper); +}); + +test("should create a hook with array threshold", () => { + const { getByTestId } = render( + , + ); + const wrapper = getByTestId("wrapper"); + const instance = intersectionMockInstance(wrapper); + + expect(instance.observe).toHaveBeenCalledWith(wrapper); +}); + +test("should create a lazy hook with useOnInViewChanged", () => { + const { getByTestId } = render(); + const wrapper = getByTestId("wrapper"); + const instance = intersectionMockInstance(wrapper); + + expect(instance.observe).toHaveBeenCalledWith(wrapper); +}); + +test("should call the callback when element comes into view", () => { + const { getByTestId } = render(); + mockAllIsIntersecting(true); + + const wrapper = getByTestId("wrapper"); + expect(wrapper.getAttribute("data-inview")).toBe("true"); + expect(wrapper.getAttribute("data-call-count")).toBe("1"); +}); + +test("should call cleanup when element leaves view", () => { + const { getByTestId } = render(); + mockAllIsIntersecting(true); + mockAllIsIntersecting(false); + + const wrapper = getByTestId("wrapper"); + expect(wrapper.getAttribute("data-inview")).toBe("false"); + expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); +}); + +test("should respect threshold values", () => { + const { getByTestId } = render( + , + ); + const wrapper = getByTestId("wrapper"); + + mockAllIsIntersecting(0.2); + expect(wrapper.getAttribute("data-inview")).toBe("false"); + + mockAllIsIntersecting(0.5); + expect(wrapper.getAttribute("data-inview")).toBe("true"); + + mockAllIsIntersecting(1); + expect(wrapper.getAttribute("data-inview")).toBe("true"); +}); + +test("should call callback with initialInView", () => { + const { getByTestId } = render( + , + ); + const wrapper = getByTestId("wrapper"); + + // initialInView should have triggered the callback once + expect(wrapper.getAttribute("data-call-count")).toBe("1"); + + mockAllIsIntersecting(false); + // Should call cleanup + expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); +}); + +test("should respect triggerOnce option", () => { + const { getByTestId } = render( + <> + + + , + ); + const wrapper = getByTestId("wrapper"); + const wrapperTriggerOnce = getByTestId("wrapper-no-cleanup"); + + mockAllIsIntersecting(true); + expect(wrapper.getAttribute("data-call-count")).toBe("1"); + expect(wrapperTriggerOnce.getAttribute("data-call-count")).toBe("1"); + mockAllIsIntersecting(false); + expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); + mockAllIsIntersecting(true); + expect(wrapper.getAttribute("data-call-count")).toBe("2"); + expect(wrapperTriggerOnce.getAttribute("data-call-count")).toBe("1"); +}); + +test("should respect skip option", () => { + const { getByTestId, rerender } = render( + , + ); + mockAllIsIntersecting(true); + + const wrapper = getByTestId("wrapper"); + expect(wrapper.getAttribute("data-inview")).toBe("false"); + expect(wrapper.getAttribute("data-call-count")).toBe("0"); + + rerender(); + mockAllIsIntersecting(true); + + expect(wrapper.getAttribute("data-inview")).toBe("true"); + expect(wrapper.getAttribute("data-call-count")).toBe("1"); +}); + +test("should handle unmounting properly", () => { + const { unmount, getByTestId } = render(); + const wrapper = getByTestId("wrapper"); + const instance = intersectionMockInstance(wrapper); + + unmount(); + expect(instance.unobserve).toHaveBeenCalledWith(wrapper); +}); + +test("should handle ref changes", () => { + const { rerender, getByTestId } = render(); + mockAllIsIntersecting(true); + + rerender(); + + // Component should clean up when ref is removed + const wrapper = getByTestId("wrapper"); + expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); + + // Add the ref back + rerender(); + mockAllIsIntersecting(true); + + expect(wrapper.getAttribute("data-inview")).toBe("true"); +}); + +// Test for merging refs +const MergeRefsComponent = ({ + options, +}: { options?: IntersectionListenerOptions }) => { + const [inView, setInView] = React.useState(false); + + const inViewRef = useOnInViewChanged((element, entry) => { + setInView(entry ? entry.isIntersecting : false); + return () => setInView(false); + }, options); + + const setRef = useCallback( + (node: Element | null) => inViewRef(node), + [inViewRef], + ); + + return ( +
+ ); +}; + +test("should handle merged refs", () => { + const { rerender, getByTestId } = render(); + mockAllIsIntersecting(true); + rerender(); + + expect(getByTestId("inview").getAttribute("data-inview")).toBe("true"); +}); + +// Test multiple callbacks on the same element +const MultipleCallbacksComponent = ({ + options, +}: { options?: IntersectionListenerOptions }) => { + const [inView1, setInView1] = React.useState(false); + const [inView2, setInView2] = React.useState(false); + const [inView3, setInView3] = React.useState(false); + + const ref1 = useOnInViewChanged((element, entry) => { + setInView1(entry ? entry.isIntersecting : false); + return () => setInView1(false); + }, options); + + const ref2 = useOnInViewChanged((element, entry) => { + setInView2(entry ? entry.isIntersecting : false); + return () => setInView2(false); + }, options); + + const ref3 = useOnInViewChanged((element, entry) => { + setInView3(entry ? entry.isIntersecting : false); + return () => setInView3(false); + }); + + const mergedRefs = useCallback( + (node: Element | null) => { + const cleanup = [ref1(node), ref2(node), ref3(node)]; + return () => cleanup.forEach((fn) => fn?.()); + }, + [ref1, ref2, ref3], + ); + + return ( +
+
+ {inView1.toString()} +
+
+ {inView2.toString()} +
+
+ {inView3.toString()} +
+
+ ); +}; + +test("should handle multiple callbacks on the same element", () => { + const { getByTestId } = render( + , + ); + mockAllIsIntersecting(true); + + expect(getByTestId("item-1").getAttribute("data-inview")).toBe("true"); + expect(getByTestId("item-2").getAttribute("data-inview")).toBe("true"); + expect(getByTestId("item-3").getAttribute("data-inview")).toBe("true"); +}); + +test("should pass the element to the callback", () => { + let capturedElement: Element | undefined; + + const ElementTestComponent = () => { + const inViewRef = useOnInViewChanged((element, entry) => { + capturedElement = element; + return undefined; + }); + + return
; + }; + + const { getByTestId } = render(); + const element = getByTestId("element-test"); + mockAllIsIntersecting(true); + + expect(capturedElement).toBe(element); +}); + +test("should handle dependencies properly", () => { + const dependencyTest = vi.fn(); + + const DependencyTestComponent = ({ value }: { value: number }) => { + const inViewRef = useOnInViewChanged( + (element, entry) => { + dependencyTest(value); + return undefined; + }, + {}, + [value], + ); + + return
; + }; + + const { rerender } = render(); + mockAllIsIntersecting(true); + + expect(dependencyTest).toHaveBeenCalledWith(1); + + // Update the dependency + rerender(); + mockAllIsIntersecting(true); + + // Should be called with new value + expect(dependencyTest).toHaveBeenCalledWith(2); +}); diff --git a/src/index.tsx b/src/index.tsx index adb4b29a..4ef57e7d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -20,7 +20,7 @@ interface RenderProps { ref: React.RefObject | ((node?: Element | null) => void); } -export interface IntersectionOptions extends IntersectionObserverInit { +export interface IntersectionListenerOptions extends IntersectionObserverInit { /** The IntersectionObserver interface's read-only `root` property identifies the Element or Document whose bounds are treated as the bounding box of the viewport for the element which is the observer's target. If the `root` is null, then the bounds of the actual document viewport are used.*/ root?: Element | Document | null; /** Margin around the root. Can have values similar to the CSS margin property, e.g. `10px 20px 30px 40px` (top, right, bottom, left). */ @@ -37,6 +37,9 @@ export interface IntersectionOptions extends IntersectionObserverInit { trackVisibility?: boolean; /** IntersectionObserver v2 - Set a minimum delay between notifications */ delay?: number; +} + +export interface IntersectionOptions extends IntersectionListenerOptions { /** Call this function whenever the in view state changes */ onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void; } @@ -81,3 +84,15 @@ export type InViewHookResponse = [ inView: boolean; entry?: IntersectionObserverEntry; }; + +/** + * The callback called by the useOnInViewChanged hook + */ +export type InViewHookChangeListener = ( + element: TElement, + /** Entry is always defined except when `initialInView` is true for the first call */ + entry: IntersectionObserverEntry | undefined, +) => // biome-ignore lint/suspicious/noConfusingVoidType: Allow no return statement + | void + | undefined + | ((entry?: IntersectionObserverEntry | undefined) => void); diff --git a/src/useInView.tsx b/src/useInView.tsx index 1a767ede..9c918fa4 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -53,45 +53,44 @@ export function useInView( entry: undefined, }); - // Store the onChange callback in a ref + // The cleanup function is created when the element gets in view and therefore + // needs a ref to access the latest options const latestOptions = React.useRef(options); latestOptions.current = options; // Create the ref tracking function using useOnInViewChanged const refCallback = useOnInViewChanged( // Combined callback - updates state, calls onChange, and returns cleanup if needed - (inView, entry) => { - setState({ inView, entry }); + (_, entry) => { + setState({ inView: true, entry }); const { onChange } = latestOptions.current; // Call the external onChange if provided // entry is undefined only if this is triggered by initialInView if (onChange && entry) { - onChange(inView, entry); + onChange(true, entry); } - // If triggerOnce is true no reset state is done in the cleanup - // this allows destroying the observer as soon as the element is inView - if (triggerOnce) { - return undefined; - } - - // Return cleanup function that will run when element is removed or goes out of view - return (entry) => { - const { onChange } = latestOptions.current; - // Call the external onChange if provided - // entry is undefined if the element is getting unmounted - if (onChange && entry) { - onChange(false, entry); - } - // should not reset current state if changing skip - if (!latestOptions.current.skip) { - setState({ - inView: false, - entry: undefined, - }); - } - }; + return triggerOnce + ? // If triggerOnce is true no reset state is done in the cleanup + // this allows destroying the observer as soon as the element is inView + undefined + : // Return cleanup function that will run when element is removed or goes out of view + (entry) => { + const { onChange, skip } = latestOptions.current; + // Call the external onChange if provided + // entry is undefined if the element is getting unmounted + if (onChange && entry) { + onChange(false, entry); + } + // should not reset current state if changing skip + if (!skip) { + setState({ + inView: false, + entry: undefined, + }); + } + }; }, { root, diff --git a/src/useOnInViewChanged.tsx b/src/useOnInViewChanged.tsx index 37a414a3..ff937991 100644 --- a/src/useOnInViewChanged.tsx +++ b/src/useOnInViewChanged.tsx @@ -1,5 +1,8 @@ import * as React from "react"; -import type { IntersectionOptions } from "./index"; +import type { + InViewHookChangeListener, + IntersectionListenerOptions, +} from "./index"; import { observe } from "./observe"; /** @@ -36,11 +39,7 @@ import { observe } from "./observe"; * ``` */ export const useOnInViewChanged = ( - onGetsIntoView: ( - inView: boolean, - entry: IntersectionObserverEntry | undefined, - element: TElement, - ) => undefined | ((entry: IntersectionObserverEntry | undefined) => void), + onGetsIntoView: InViewHookChangeListener, { threshold, delay, @@ -50,7 +49,7 @@ export const useOnInViewChanged = ( triggerOnce, skip, initialInView, - }: IntersectionOptions = {}, + }: IntersectionListenerOptions = {}, dependencies: React.DependencyList = [], ) => { // Store the onGetsIntoView in a ref to avoid triggering recreation @@ -65,13 +64,13 @@ export const useOnInViewChanged = ( let callbackCleanup: | undefined - | ((entry?: IntersectionObserverEntry) => void); + | ReturnType>; let didTriggerOnce = false; // If initialInView is true, we have to call the callback immediately // to get a cleanup function for the out of view event if (initialInView) { - callbackCleanup = onGetsIntoViewRef.current(true, undefined, element); + callbackCleanup = onGetsIntoViewRef.current(element, undefined); } const destroyInviewObserver = observe( @@ -88,7 +87,7 @@ export const useOnInViewChanged = ( } // Call callback with inView state, entry, and element - callbackCleanup = onGetsIntoViewRef.current(inView, entry, element); + callbackCleanup = onGetsIntoViewRef.current(element, entry); didTriggerOnce = true; From 66207321547bd88de7399b7d87ad08f310e99ebb Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Fri, 28 Feb 2025 23:33:05 +0100 Subject: [PATCH 04/16] drop dependencies --- src/__tests__/useOnInViewChanged.test.tsx | 29 ----------------------- src/useOnInViewChanged.tsx | 2 -- 2 files changed, 31 deletions(-) diff --git a/src/__tests__/useOnInViewChanged.test.tsx b/src/__tests__/useOnInViewChanged.test.tsx index ae9f7662..edf970e3 100644 --- a/src/__tests__/useOnInViewChanged.test.tsx +++ b/src/__tests__/useOnInViewChanged.test.tsx @@ -330,32 +330,3 @@ test("should pass the element to the callback", () => { expect(capturedElement).toBe(element); }); - -test("should handle dependencies properly", () => { - const dependencyTest = vi.fn(); - - const DependencyTestComponent = ({ value }: { value: number }) => { - const inViewRef = useOnInViewChanged( - (element, entry) => { - dependencyTest(value); - return undefined; - }, - {}, - [value], - ); - - return
; - }; - - const { rerender } = render(); - mockAllIsIntersecting(true); - - expect(dependencyTest).toHaveBeenCalledWith(1); - - // Update the dependency - rerender(); - mockAllIsIntersecting(true); - - // Should be called with new value - expect(dependencyTest).toHaveBeenCalledWith(2); -}); diff --git a/src/useOnInViewChanged.tsx b/src/useOnInViewChanged.tsx index ff937991..51474a22 100644 --- a/src/useOnInViewChanged.tsx +++ b/src/useOnInViewChanged.tsx @@ -50,7 +50,6 @@ export const useOnInViewChanged = ( skip, initialInView, }: IntersectionListenerOptions = {}, - dependencies: React.DependencyList = [], ) => { // Store the onGetsIntoView in a ref to avoid triggering recreation const onGetsIntoViewRef = React.useRef(onGetsIntoView); @@ -115,7 +114,6 @@ export const useOnInViewChanged = ( }, // We break the rule here, because we aren't including the actual `threshold` variable [ - ...dependencies, root, rootMargin, // Convert threshold array to string for stable dependency From 27a0793372e72a67cd8f3c87d4c5c080543343b3 Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Mon, 3 Mar 2025 14:48:51 +0100 Subject: [PATCH 05/16] start observing only once --- src/observe.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/observe.ts b/src/observe.ts index 97552603..a6db38f7 100644 --- a/src/observe.ts +++ b/src/observe.ts @@ -111,13 +111,14 @@ export function observe( const { id, observer, elements } = createObserver(options); // Register the callback listener for this element - const callbacks = elements.get(element) || []; - if (!elements.has(element)) { + let callbacks = elements.get(element); + if (!callbacks) { + callbacks = []; elements.set(element, callbacks); + observer.observe(element); } callbacks.push(callback); - observer.observe(element); return function unobserve() { // Remove the callback from the callback list From 58c46bcdb4998e4e7f727c09d316c15d18efcc8f Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Mon, 3 Mar 2025 22:11:18 +0100 Subject: [PATCH 06/16] fix potential selective hydration issues --- src/useOnInViewChanged.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/useOnInViewChanged.tsx b/src/useOnInViewChanged.tsx index 51474a22..aa24d9ab 100644 --- a/src/useOnInViewChanged.tsx +++ b/src/useOnInViewChanged.tsx @@ -53,7 +53,14 @@ export const useOnInViewChanged = ( ) => { // Store the onGetsIntoView in a ref to avoid triggering recreation const onGetsIntoViewRef = React.useRef(onGetsIntoView); - onGetsIntoViewRef.current = onGetsIntoView; + // https://react.dev/reference/react/useRef#caveats + // > Do not write or read ref.current during rendering, except for initialization. This makes your component’s behavior unpredictable. + // + // With useInsertionEffect, ref.current can be updated after a successful render + // from https://github.com/sanity-io/use-effect-event/blob/main/src/useEffectEvent.ts + React.useInsertionEffect(() => { + onGetsIntoViewRef.current = onGetsIntoView; + }, [onGetsIntoView]); return React.useCallback( (element: TElement | undefined | null) => { From ff7c78ee2b8698507a2ec79439f2bfa256f28ba0 Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Tue, 4 Mar 2025 09:45:09 +0100 Subject: [PATCH 07/16] drop element argument --- src/__tests__/useOnInViewChanged.test.tsx | 20 ++++++++++---------- src/index.tsx | 13 +++++++------ src/observe.ts | 15 +++++++++------ src/useInView.tsx | 2 +- src/useOnInViewChanged.tsx | 20 ++++++++++---------- 5 files changed, 37 insertions(+), 33 deletions(-) diff --git a/src/__tests__/useOnInViewChanged.test.tsx b/src/__tests__/useOnInViewChanged.test.tsx index edf970e3..bf8f55a3 100644 --- a/src/__tests__/useOnInViewChanged.test.tsx +++ b/src/__tests__/useOnInViewChanged.test.tsx @@ -15,14 +15,14 @@ const OnInViewChangedComponent = ({ const [callCount, setCallCount] = React.useState(0); const [cleanupCount, setCleanupCount] = React.useState(0); - const inViewRef = useOnInViewChanged((element, entry) => { + const inViewRef = useOnInViewChanged((entry) => { setInView(entry ? entry.isIntersecting : false); setCallCount((prev) => prev + 1); // Return cleanup function - return (entry) => { + return (cleanupEntry) => { setCleanupCount((prev) => prev + 1); - if (entry) { + if (cleanupEntry) { setInView(false); } }; @@ -53,7 +53,7 @@ const LazyOnInViewChangedComponent = ({ setIsLoading(false); }, []); - const inViewRef = useOnInViewChanged((element, entry) => { + const inViewRef = useOnInViewChanged((entry) => { setInView(entry ? entry.isIntersecting : false); return () => setInView(false); }, options); @@ -232,7 +232,7 @@ const MergeRefsComponent = ({ }: { options?: IntersectionListenerOptions }) => { const [inView, setInView] = React.useState(false); - const inViewRef = useOnInViewChanged((element, entry) => { + const inViewRef = useOnInViewChanged((entry) => { setInView(entry ? entry.isIntersecting : false); return () => setInView(false); }, options); @@ -263,17 +263,17 @@ const MultipleCallbacksComponent = ({ const [inView2, setInView2] = React.useState(false); const [inView3, setInView3] = React.useState(false); - const ref1 = useOnInViewChanged((element, entry) => { + const ref1 = useOnInViewChanged((entry) => { setInView1(entry ? entry.isIntersecting : false); return () => setInView1(false); }, options); - const ref2 = useOnInViewChanged((element, entry) => { + const ref2 = useOnInViewChanged((entry) => { setInView2(entry ? entry.isIntersecting : false); return () => setInView2(false); }, options); - const ref3 = useOnInViewChanged((element, entry) => { + const ref3 = useOnInViewChanged((entry) => { setInView3(entry ? entry.isIntersecting : false); return () => setInView3(false); }); @@ -316,8 +316,8 @@ test("should pass the element to the callback", () => { let capturedElement: Element | undefined; const ElementTestComponent = () => { - const inViewRef = useOnInViewChanged((element, entry) => { - capturedElement = element; + const inViewRef = useOnInViewChanged((entry) => { + capturedElement = entry?.target; return undefined; }); diff --git a/src/index.tsx b/src/index.tsx index 4ef57e7d..e17aa64c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,9 +8,9 @@ export { observe } from "./observe"; type Omit = Pick>; -export type ObserverInstanceCallback = ( +export type ObserverInstanceCallback = ( inView: boolean, - entry: IntersectionObserverEntry, + entry: IntersectionObserverEntry & { target: TElement }, ) => void; interface RenderProps { @@ -86,12 +86,13 @@ export type InViewHookResponse = [ }; /** - * The callback called by the useOnInViewChanged hook + * The callback called by the useOnInViewChanged hook once the element is in view + * + * Allows to return a cleanup function that will be called when the element goes out of view or when the observer is destroyed */ -export type InViewHookChangeListener = ( - element: TElement, +export type InViewEnterHookListener = ( /** Entry is always defined except when `initialInView` is true for the first call */ - entry: IntersectionObserverEntry | undefined, + entry: (IntersectionObserverEntry & { target: TElement }) | undefined, ) => // biome-ignore lint/suspicious/noConfusingVoidType: Allow no return statement | void | undefined diff --git a/src/observe.ts b/src/observe.ts index a6db38f7..a1b48efb 100644 --- a/src/observe.ts +++ b/src/observe.ts @@ -102,9 +102,9 @@ function createObserver(options: IntersectionObserverInit) { * @param options - Intersection Observer options * @return Function - Cleanup function that should be triggered to unregister the observer */ -export function observe( - element: Element, - callback: ObserverInstanceCallback, +export function observe( + element: TElement, + callback: ObserverInstanceCallback, options: IntersectionObserverInit = {}, ) { // An observer with the same options can be reused, so lets use this fact @@ -114,15 +114,18 @@ export function observe( let callbacks = elements.get(element); if (!callbacks) { callbacks = []; - elements.set(element, callbacks); + elements.set(element, callbacks as ObserverInstanceCallback[]); observer.observe(element); } - callbacks.push(callback); + callbacks.push(callback as ObserverInstanceCallback); return function unobserve() { // Remove the callback from the callback list - callbacks.splice(callbacks.indexOf(callback), 1); + callbacks.splice( + callbacks.indexOf(callback as ObserverInstanceCallback), + 1, + ); if (callbacks.length === 0) { // No more callback exists for element, so destroy it diff --git a/src/useInView.tsx b/src/useInView.tsx index 9c918fa4..363e2218 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -61,7 +61,7 @@ export function useInView( // Create the ref tracking function using useOnInViewChanged const refCallback = useOnInViewChanged( // Combined callback - updates state, calls onChange, and returns cleanup if needed - (_, entry) => { + (entry) => { setState({ inView: true, entry }); const { onChange } = latestOptions.current; diff --git a/src/useOnInViewChanged.tsx b/src/useOnInViewChanged.tsx index aa24d9ab..0d311ab6 100644 --- a/src/useOnInViewChanged.tsx +++ b/src/useOnInViewChanged.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import type { - InViewHookChangeListener, + InViewEnterHookListener, IntersectionListenerOptions, } from "./index"; import { observe } from "./observe"; @@ -20,12 +20,12 @@ import { observe } from "./observe"; * import { useOnInViewChanged } from 'react-intersection-observer'; * * const Component = () => { - * const inViewRef = useOnInViewChanged((inView, entry, element) => { - * console.log(`Element is ${inView ? 'in view' : 'out of view'}`); + * const inViewRef = useOnInViewChanged((entry) => { + * console.log(`Element is in view`, entry?.target); * // Optional: cleanup function: - * return () => { - * console.log('Element moved out of view or unmounted'); - * }; + * return () => { + * console.log('Element moved out of view or unmounted'); + * }; * }, { * threshold: 0, * }); @@ -39,7 +39,7 @@ import { observe } from "./observe"; * ``` */ export const useOnInViewChanged = ( - onGetsIntoView: InViewHookChangeListener, + onGetsIntoView: InViewEnterHookListener, { threshold, delay, @@ -70,13 +70,13 @@ export const useOnInViewChanged = ( let callbackCleanup: | undefined - | ReturnType>; + | ReturnType>; let didTriggerOnce = false; // If initialInView is true, we have to call the callback immediately // to get a cleanup function for the out of view event if (initialInView) { - callbackCleanup = onGetsIntoViewRef.current(element, undefined); + callbackCleanup = onGetsIntoViewRef.current(undefined); } const destroyInviewObserver = observe( @@ -93,7 +93,7 @@ export const useOnInViewChanged = ( } // Call callback with inView state, entry, and element - callbackCleanup = onGetsIntoViewRef.current(element, entry); + callbackCleanup = onGetsIntoViewRef.current(entry); didTriggerOnce = true; From 7c25f82a674e93a31fb4685692f064a9f67f47bf Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Tue, 4 Mar 2025 09:48:42 +0100 Subject: [PATCH 08/16] update readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 81435f36..f4ea30a4 100644 --- a/README.md +++ b/README.md @@ -240,12 +240,12 @@ You can read more about this on these links: ```js const inViewRef = useOnInViewChanged( - (element, entry) => { + (enterEntry) => { // Do something with the element that came into view - console.log('Element is in view', element); + console.log('Element is in view', enterEntry?.element); // Optionally return a cleanup function - return (entry) => { + return (exitEntry) => { console.log('Element moved out of view or unmounted'); }; }, From d9523b61fd5784998fa258ed6a744ba2a7cef674 Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Wed, 5 Mar 2025 19:24:10 +0100 Subject: [PATCH 09/16] rename useOnInViewChanged to useOnInView - refactor useOnInView initialInView option to trigger --- README.md | 24 ++--- ...wChanged.test.tsx => useOnInView.test.tsx} | 65 +++++++++----- src/index.tsx | 23 +++-- src/useInView.tsx | 27 +++--- ...useOnInViewChanged.tsx => useOnInView.tsx} | 87 ++++++++++--------- 5 files changed, 128 insertions(+), 98 deletions(-) rename src/__tests__/{useOnInViewChanged.test.tsx => useOnInView.test.tsx} (84%) rename src/{useOnInViewChanged.tsx => useOnInView.tsx} (55%) diff --git a/README.md b/README.md index f4ea30a4..9e10d3b7 100644 --- a/README.md +++ b/README.md @@ -236,13 +236,13 @@ You can read more about this on these links: - [w3c/IntersectionObserver: Cannot track intersection with an iframe's viewport](https://github.com/w3c/IntersectionObserver/issues/372) - [w3c/Support iframe viewport tracking](https://github.com/w3c/IntersectionObserver/pull/465) -### `useOnInViewChanged` hook +### `useOnInView` hook ```js -const inViewRef = useOnInViewChanged( +const inViewRef = useOnInView( (enterEntry) => { // Do something with the element that came into view - console.log('Element is in view', enterEntry?.element); + console.log('Element is in view', enterEntry?.target); // Optionally return a cleanup function return (exitEntry) => { @@ -253,23 +253,25 @@ const inViewRef = useOnInViewChanged( ); ``` -The `useOnInViewChanged` hook provides a more direct alternative to `useInView`. It takes a callback function and returns a ref that you can assign to the DOM element you want to monitor. When the element enters the viewport, your callback will be triggered. +The `useOnInView` hook provides a more direct alternative to `useInView`. It takes a callback function and returns a ref that you can assign to the DOM element you want to monitor. When the element enters the viewport, your callback will be triggered. Key differences from `useInView`: - **No re-renders** - This hook doesn't update any state, making it ideal for performance-critical scenarios - **Direct element access** - Your callback receives the actual DOM element and the IntersectionObserverEntry -- **Optional cleanup** - Return a function from your callback to run when the element leaves the viewport or is unmounted -- **Same options** - Accepts all the same [options](#options) as `useInView` except `onChange` +- **Optional cleanup** - Return a function from your callback to run when the element leaves the viewport +- **Similar options** - Accepts all the same [options](#options) as `useInView` except `onChange` and `initialInView` + +The `trigger` option allows to listen for the element entering the viewport or leaving the viewport. The default is `enter`. ```jsx import React from "react"; -import { useOnInViewChanged } from "react-intersection-observer"; +import { useOnInView } from "react-intersection-observer"; const Component = () => { // Track when element appears without causing re-renders - const trackingRef = useOnInViewChanged((element, entry) => { + const trackingRef = useOnInView((entry) => { // Element is in view - perhaps log an impression - console.log("Element appeared in view"); + console.log("Element appeared in view", entry.target); // Return optional cleanup function return () => { @@ -278,6 +280,8 @@ const Component = () => { }, { /* Optional options */ threshold: 0.5, + trigger: "enter", + triggerOnce: true, }); return ( @@ -286,8 +290,6 @@ const Component = () => {
); }; - -export default Component; ``` ## Testing diff --git a/src/__tests__/useOnInViewChanged.test.tsx b/src/__tests__/useOnInView.test.tsx similarity index 84% rename from src/__tests__/useOnInViewChanged.test.tsx rename to src/__tests__/useOnInView.test.tsx index bf8f55a3..8d740a82 100644 --- a/src/__tests__/useOnInViewChanged.test.tsx +++ b/src/__tests__/useOnInView.test.tsx @@ -1,24 +1,23 @@ import { render } from "@testing-library/react"; import React, { useCallback } from "react"; -import type { IntersectionListenerOptions } from "../index"; +import type { IntersectionEffectOptions } from ".."; import { intersectionMockInstance, mockAllIsIntersecting } from "../test-utils"; -import { useOnInViewChanged } from "../useOnInViewChanged"; +import { useOnInView } from "../useOnInView"; const OnInViewChangedComponent = ({ options, unmount, }: { - options?: IntersectionListenerOptions; + options?: IntersectionEffectOptions; unmount?: boolean; }) => { const [inView, setInView] = React.useState(false); const [callCount, setCallCount] = React.useState(0); const [cleanupCount, setCleanupCount] = React.useState(0); - const inViewRef = useOnInViewChanged((entry) => { - setInView(entry ? entry.isIntersecting : false); + const inViewRef = useOnInView((entry) => { + setInView(entry.isIntersecting); setCallCount((prev) => prev + 1); - // Return cleanup function return (cleanupEntry) => { setCleanupCount((prev) => prev + 1); @@ -44,7 +43,7 @@ const OnInViewChangedComponent = ({ const LazyOnInViewChangedComponent = ({ options, }: { - options?: IntersectionListenerOptions; + options?: IntersectionEffectOptions; }) => { const [isLoading, setIsLoading] = React.useState(true); const [inView, setInView] = React.useState(false); @@ -53,7 +52,7 @@ const LazyOnInViewChangedComponent = ({ setIsLoading(false); }, []); - const inViewRef = useOnInViewChanged((entry) => { + const inViewRef = useOnInView((entry) => { setInView(entry ? entry.isIntersecting : false); return () => setInView(false); }, options); @@ -71,11 +70,11 @@ const OnInViewChangedComponentWithoutClenaup = ({ options, unmount, }: { - options?: IntersectionListenerOptions; + options?: IntersectionEffectOptions; unmount?: boolean; }) => { const [callCount, setCallCount] = React.useState(0); - const inViewRef = useOnInViewChanged(() => { + const inViewRef = useOnInView(() => { setCallCount((prev) => prev + 1); }, options); @@ -88,7 +87,7 @@ const OnInViewChangedComponentWithoutClenaup = ({ ); }; -test("should create a hook with useOnInViewChanged", () => { +test("should create a hook with useOnInView", () => { const { getByTestId } = render(); const wrapper = getByTestId("wrapper"); const instance = intersectionMockInstance(wrapper); @@ -106,7 +105,7 @@ test("should create a hook with array threshold", () => { expect(instance.observe).toHaveBeenCalledWith(wrapper); }); -test("should create a lazy hook with useOnInViewChanged", () => { +test("should create a lazy hook with useOnInView", () => { const { getByTestId } = render(); const wrapper = getByTestId("wrapper"); const instance = intersectionMockInstance(wrapper); @@ -149,16 +148,38 @@ test("should respect threshold values", () => { expect(wrapper.getAttribute("data-inview")).toBe("true"); }); -test("should call callback with initialInView", () => { +test("should call callback with trigger: leave", () => { const { getByTestId } = render( - , + , ); const wrapper = getByTestId("wrapper"); - // initialInView should have triggered the callback once + mockAllIsIntersecting(false); + // Should call callback expect(wrapper.getAttribute("data-call-count")).toBe("1"); + mockAllIsIntersecting(true); + // Should call cleanup + expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); +}); + +test("should call callback with trigger: leave and triggerOnce is true", () => { + const { getByTestId } = render( + , + ); + const wrapper = getByTestId("wrapper"); + + mockAllIsIntersecting(true); + // initialInView should have triggered the callback once + expect(wrapper.getAttribute("data-call-count")).toBe("0"); + mockAllIsIntersecting(false); + // Should call callback + expect(wrapper.getAttribute("data-call-count")).toBe("1"); + + mockAllIsIntersecting(true); // Should call cleanup expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); }); @@ -229,10 +250,10 @@ test("should handle ref changes", () => { // Test for merging refs const MergeRefsComponent = ({ options, -}: { options?: IntersectionListenerOptions }) => { +}: { options?: IntersectionEffectOptions }) => { const [inView, setInView] = React.useState(false); - const inViewRef = useOnInViewChanged((entry) => { + const inViewRef = useOnInView((entry) => { setInView(entry ? entry.isIntersecting : false); return () => setInView(false); }, options); @@ -258,22 +279,22 @@ test("should handle merged refs", () => { // Test multiple callbacks on the same element const MultipleCallbacksComponent = ({ options, -}: { options?: IntersectionListenerOptions }) => { +}: { options?: IntersectionEffectOptions }) => { const [inView1, setInView1] = React.useState(false); const [inView2, setInView2] = React.useState(false); const [inView3, setInView3] = React.useState(false); - const ref1 = useOnInViewChanged((entry) => { + const ref1 = useOnInView((entry) => { setInView1(entry ? entry.isIntersecting : false); return () => setInView1(false); }, options); - const ref2 = useOnInViewChanged((entry) => { + const ref2 = useOnInView((entry) => { setInView2(entry ? entry.isIntersecting : false); return () => setInView2(false); }, options); - const ref3 = useOnInViewChanged((entry) => { + const ref3 = useOnInView((entry) => { setInView3(entry ? entry.isIntersecting : false); return () => setInView3(false); }); @@ -316,7 +337,7 @@ test("should pass the element to the callback", () => { let capturedElement: Element | undefined; const ElementTestComponent = () => { - const inViewRef = useOnInViewChanged((entry) => { + const inViewRef = useOnInView((entry) => { capturedElement = entry?.target; return undefined; }); diff --git a/src/index.tsx b/src/index.tsx index e17aa64c..f9dc153e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,7 @@ import type * as React from "react"; export { InView } from "./InView"; export { useInView } from "./useInView"; -export { useOnInViewChanged } from "./useOnInViewChanged"; +export { useOnInView } from "./useOnInView"; export { observe } from "./observe"; type Omit = Pick>; @@ -20,7 +20,7 @@ interface RenderProps { ref: React.RefObject | ((node?: Element | null) => void); } -export interface IntersectionListenerOptions extends IntersectionObserverInit { +export interface IntersectionBaseOptions extends IntersectionObserverInit { /** The IntersectionObserver interface's read-only `root` property identifies the Element or Document whose bounds are treated as the bounding box of the viewport for the element which is the observer's target. If the `root` is null, then the bounds of the actual document viewport are used.*/ root?: Element | Document | null; /** Margin around the root. Can have values similar to the CSS margin property, e.g. `10px 20px 30px 40px` (top, right, bottom, left). */ @@ -31,17 +31,22 @@ export interface IntersectionListenerOptions extends IntersectionObserverInit { triggerOnce?: boolean; /** Skip assigning the observer to the `ref` */ skip?: boolean; - /** Set the initial value of the `inView` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. */ - initialInView?: boolean; /** IntersectionObserver v2 - Track the actual visibility of the element */ trackVisibility?: boolean; /** IntersectionObserver v2 - Set a minimum delay between notifications */ delay?: number; } -export interface IntersectionOptions extends IntersectionListenerOptions { +export interface IntersectionOptions extends IntersectionBaseOptions { /** Call this function whenever the in view state changes */ onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void; + /** Set the initial value of the `inView` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. */ + initialInView?: boolean; +} + +export interface IntersectionEffectOptions extends IntersectionBaseOptions { + /** When to trigger the inView callback - default: "enter" */ + trigger?: "enter" | "leave"; } export interface IntersectionObserverProps extends IntersectionOptions { @@ -86,14 +91,14 @@ export type InViewHookResponse = [ }; /** - * The callback called by the useOnInViewChanged hook once the element is in view + * The callback called by the useOnInView hook once the element is in view * * Allows to return a cleanup function that will be called when the element goes out of view or when the observer is destroyed */ -export type InViewEnterHookListener = ( - /** Entry is always defined except when `initialInView` is true for the first call */ - entry: (IntersectionObserverEntry & { target: TElement }) | undefined, +export type IntersectionChangeEffect = ( + entry: IntersectionObserverEntry & { target: TElement }, ) => // biome-ignore lint/suspicious/noConfusingVoidType: Allow no return statement | void | undefined + /** Entry is defined except when the element is unmounting */ | ((entry?: IntersectionObserverEntry | undefined) => void); diff --git a/src/useInView.tsx b/src/useInView.tsx index 363e2218..3d3b29f8 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import type { InViewHookResponse, IntersectionOptions } from "./index"; -import { useOnInViewChanged } from "./useOnInViewChanged"; +import { useOnInView } from "./useOnInView"; type State = { inView: boolean; @@ -38,10 +38,10 @@ export function useInView( ): InViewHookResponse { const { threshold, - delay, - trackVisibility, - rootMargin, root, + rootMargin, + trackVisibility, + delay, triggerOnce, skip, initialInView, @@ -58,17 +58,18 @@ export function useInView( const latestOptions = React.useRef(options); latestOptions.current = options; - // Create the ref tracking function using useOnInViewChanged - const refCallback = useOnInViewChanged( + // Create the ref tracking function using useOnInView + const refCallback = useOnInView( // Combined callback - updates state, calls onChange, and returns cleanup if needed (entry) => { - setState({ inView: true, entry }); + const inView = !initialInView; + setState({ inView, entry }); const { onChange } = latestOptions.current; // Call the external onChange if provided // entry is undefined only if this is triggered by initialInView - if (onChange && entry) { - onChange(true, entry); + if (onChange) { + onChange(inView, entry); } return triggerOnce @@ -81,28 +82,28 @@ export function useInView( // Call the external onChange if provided // entry is undefined if the element is getting unmounted if (onChange && entry) { - onChange(false, entry); + onChange(!inView, entry); } // should not reset current state if changing skip if (!skip) { setState({ - inView: false, + inView: !inView, entry: undefined, }); } }; }, { + threshold, root, rootMargin, - threshold, // @ts-ignore trackVisibility, // @ts-ignore delay, - initialInView, triggerOnce, skip, + trigger: initialInView ? "leave" : undefined, }, ); diff --git a/src/useOnInViewChanged.tsx b/src/useOnInView.tsx similarity index 55% rename from src/useOnInViewChanged.tsx rename to src/useOnInView.tsx index 0d311ab6..4f4491ce 100644 --- a/src/useOnInViewChanged.tsx +++ b/src/useOnInView.tsx @@ -1,13 +1,13 @@ import * as React from "react"; import type { - InViewEnterHookListener, - IntersectionListenerOptions, + IntersectionChangeEffect, + IntersectionEffectOptions, } from "./index"; import { observe } from "./observe"; /** * React Hooks make it easy to monitor when elements come into and leave view. Call - * the `useOnInViewChanged` hook with your callback and (optional) [options](#options). + * the `useOnInView` hook with your callback and (optional) [options](#options). * It will return a ref callback that you can assign to the DOM element you want to monitor. * When the element enters or leaves the viewport, your callback will be triggered. * @@ -17,10 +17,10 @@ import { observe } from "./observe"; * @example * ```jsx * import React from 'react'; - * import { useOnInViewChanged } from 'react-intersection-observer'; + * import { useOnInView } from 'react-intersection-observer'; * * const Component = () => { - * const inViewRef = useOnInViewChanged((entry) => { + * const inViewRef = useOnInView((entry) => { * console.log(`Element is in view`, entry?.target); * // Optional: cleanup function: * return () => { @@ -38,29 +38,29 @@ import { observe } from "./observe"; * }; * ``` */ -export const useOnInViewChanged = ( - onGetsIntoView: InViewEnterHookListener, +export const useOnInView = ( + onIntersectionChange: IntersectionChangeEffect, { threshold, - delay, - trackVisibility, - rootMargin, root, + rootMargin, + trackVisibility, + delay, triggerOnce, skip, - initialInView, - }: IntersectionListenerOptions = {}, + trigger, + }: IntersectionEffectOptions = {}, ) => { - // Store the onGetsIntoView in a ref to avoid triggering recreation - const onGetsIntoViewRef = React.useRef(onGetsIntoView); + // Store the onIntersectionChange in a ref to avoid triggering recreation + const onIntersectionChangeRef = React.useRef(onIntersectionChange); // https://react.dev/reference/react/useRef#caveats // > Do not write or read ref.current during rendering, except for initialization. This makes your component’s behavior unpredictable. // // With useInsertionEffect, ref.current can be updated after a successful render // from https://github.com/sanity-io/use-effect-event/blob/main/src/useEffectEvent.ts React.useInsertionEffect(() => { - onGetsIntoViewRef.current = onGetsIntoView; - }, [onGetsIntoView]); + onIntersectionChangeRef.current = onIntersectionChange; + }, [onIntersectionChange]); return React.useCallback( (element: TElement | undefined | null) => { @@ -70,43 +70,43 @@ export const useOnInViewChanged = ( let callbackCleanup: | undefined - | ReturnType>; - let didTriggerOnce = false; + | ReturnType>; - // If initialInView is true, we have to call the callback immediately - // to get a cleanup function for the out of view event - if (initialInView) { - callbackCleanup = onGetsIntoViewRef.current(undefined); - } + // enter: intersectionsStateTrigger = true + // leave: intersectionsStateTrigger = false + const intersectionsStateTrigger = trigger !== "leave"; const destroyInviewObserver = observe( element, (inView, entry) => { - // Call cleanup when going out of view - if (!inView) { - if (triggerOnce && didTriggerOnce) { - destroyInviewObserver?.(); + // Call cleanup when going out of view (if trigger is "enter") + // Call cleanup when going in view (if trigger is "leave") + if (inView !== intersectionsStateTrigger) { + if (callbackCleanup) { + callbackCleanup(entry); + callbackCleanup = undefined; + // If the callbackCleanup was called and triggerOnce is true + // the observer can be destroyed immediately after the callback is called + if (triggerOnce) { + destroyInviewObserver(); + } } - callbackCleanup?.(entry); - callbackCleanup = undefined; - return; - } - - // Call callback with inView state, entry, and element - callbackCleanup = onGetsIntoViewRef.current(entry); + } else { + // Call the callback when the element is in view (if trigger is "enter") + // Call the callback when the element is out of view (if trigger is "leave") + callbackCleanup = onIntersectionChangeRef.current(entry); - didTriggerOnce = true; - - // if the cleanup is not waiting for the element to go out of view - // and triggerOnce is true, we can destroy the observer - if (triggerOnce && !callbackCleanup) { - destroyInviewObserver?.(); + // if there is no cleanup function returned from the callback + // and triggerOnce is true, the observer can be destroyed immediately + if (triggerOnce && !callbackCleanup) { + destroyInviewObserver(); + } } }, { + threshold, root, rootMargin, - threshold, // @ts-ignore trackVisibility, // @ts-ignore @@ -121,14 +121,15 @@ export const useOnInViewChanged = ( }, // We break the rule here, because we aren't including the actual `threshold` variable [ - root, - rootMargin, // Convert threshold array to string for stable dependency Array.isArray(threshold) ? threshold.toString() : threshold, + root, + rootMargin, trackVisibility, delay, triggerOnce, skip, + trigger, ], ); }; From 2b5e7f03a9a81393c14a0fbf9b3d89f88d853451 Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Wed, 5 Mar 2025 19:32:33 +0100 Subject: [PATCH 10/16] flip if else --- src/useOnInView.tsx | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/useOnInView.tsx b/src/useOnInView.tsx index 4f4491ce..24d226a4 100644 --- a/src/useOnInView.tsx +++ b/src/useOnInView.tsx @@ -72,16 +72,27 @@ export const useOnInView = ( | undefined | ReturnType>; - // enter: intersectionsStateTrigger = true - // leave: intersectionsStateTrigger = false + // trigger "enter": intersectionsStateTrigger = true + // trigger "leave": intersectionsStateTrigger = false const intersectionsStateTrigger = trigger !== "leave"; const destroyInviewObserver = observe( element, (inView, entry) => { + // Call the callback when the element is in view (if trigger is "enter") + // Call the callback when the element is out of view (if trigger is "leave") + if (inView === intersectionsStateTrigger) { + callbackCleanup = onIntersectionChangeRef.current(entry); + + // if there is no cleanup function returned from the callback + // and triggerOnce is true, the observer can be destroyed immediately + if (triggerOnce && !callbackCleanup) { + destroyInviewObserver(); + } + } // Call cleanup when going out of view (if trigger is "enter") // Call cleanup when going in view (if trigger is "leave") - if (inView !== intersectionsStateTrigger) { + else { if (callbackCleanup) { callbackCleanup(entry); callbackCleanup = undefined; @@ -91,16 +102,6 @@ export const useOnInView = ( destroyInviewObserver(); } } - } else { - // Call the callback when the element is in view (if trigger is "enter") - // Call the callback when the element is out of view (if trigger is "leave") - callbackCleanup = onIntersectionChangeRef.current(entry); - - // if there is no cleanup function returned from the callback - // and triggerOnce is true, the observer can be destroyed immediately - if (triggerOnce && !callbackCleanup) { - destroyInviewObserver(); - } } }, { From 33d4babc9ea56909476972fe1ec4cdb72199c9c1 Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Wed, 5 Mar 2025 21:07:24 +0100 Subject: [PATCH 11/16] add tests with mulitple thresholds --- README.md | 2 +- src/__tests__/useOnInView.test.tsx | 126 ++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9e10d3b7..ea376dd2 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ The `useOnInView` hook provides a more direct alternative to `useInView`. It tak Key differences from `useInView`: - **No re-renders** - This hook doesn't update any state, making it ideal for performance-critical scenarios -- **Direct element access** - Your callback receives the actual DOM element and the IntersectionObserverEntry +- **Direct element access** - Your callback receives the actual IntersectionObserverEntry with the `target` element - **Optional cleanup** - Return a function from your callback to run when the element leaves the viewport - **Similar options** - Accepts all the same [options](#options) as `useInView` except `onChange` and `initialInView` diff --git a/src/__tests__/useOnInView.test.tsx b/src/__tests__/useOnInView.test.tsx index 8d740a82..0a120684 100644 --- a/src/__tests__/useOnInView.test.tsx +++ b/src/__tests__/useOnInView.test.tsx @@ -87,6 +87,44 @@ const OnInViewChangedComponentWithoutClenaup = ({ ); }; +const ThresholdTriggerComponent = ({ + options, +}: { + options?: IntersectionEffectOptions; +}) => { + const [triggerCount, setTriggerCount] = React.useState(0); + const [lastRatio, setLastRatio] = React.useState(null); + const [triggeredThresholds, setTriggeredThresholds] = React.useState< + number[] + >([]); + + const inViewRef = useOnInView((entry) => { + setTriggerCount((prev) => prev + 1); + setLastRatio(entry.intersectionRatio); + + // Add this ratio to our list of triggered thresholds + setTriggeredThresholds((prev) => [...prev, entry.intersectionRatio]); + + return (exitEntry) => { + if (exitEntry) { + setLastRatio(exitEntry.intersectionRatio); + } + }; + }, options); + + return ( +
+ Tracking thresholds +
+ ); +}; + test("should create a hook with useOnInView", () => { const { getByTestId } = render(); const wrapper = getByTestId("wrapper"); @@ -172,7 +210,7 @@ test("should call callback with trigger: leave and triggerOnce is true", () => { const wrapper = getByTestId("wrapper"); mockAllIsIntersecting(true); - // initialInView should have triggered the callback once + // the callback should not be called as it is triggered on leave expect(wrapper.getAttribute("data-call-count")).toBe("0"); mockAllIsIntersecting(false); @@ -338,7 +376,7 @@ test("should pass the element to the callback", () => { const ElementTestComponent = () => { const inViewRef = useOnInView((entry) => { - capturedElement = entry?.target; + capturedElement = entry.target; return undefined; }); @@ -351,3 +389,87 @@ test("should pass the element to the callback", () => { expect(capturedElement).toBe(element); }); + +test("should track which threshold triggered the visibility change", () => { + // Using multiple specific thresholds + const { getByTestId } = render( + , + ); + const element = getByTestId("threshold-trigger"); + + // Initially not in view + expect(element.getAttribute("data-trigger-count")).toBe("0"); + + // Trigger at exactly the first threshold (0.25) + mockAllIsIntersecting(0.25); + expect(element.getAttribute("data-trigger-count")).toBe("1"); + expect(element.getAttribute("data-last-ratio")).toBe("0.25"); + + // Go out of view + mockAllIsIntersecting(0); + + // Trigger at exactly the second threshold (0.5) + mockAllIsIntersecting(0.5); + expect(element.getAttribute("data-trigger-count")).toBe("2"); + expect(element.getAttribute("data-last-ratio")).toBe("0.50"); + + // Go out of view + mockAllIsIntersecting(0); + + // Trigger at exactly the third threshold (0.75) + mockAllIsIntersecting(0.75); + expect(element.getAttribute("data-trigger-count")).toBe("3"); + expect(element.getAttribute("data-last-ratio")).toBe("0.75"); + + // Check all triggered thresholds were recorded + const triggeredThresholds = JSON.parse( + element.getAttribute("data-triggered-thresholds") || "[]", + ); + expect(triggeredThresholds).toContain(0.25); + expect(triggeredThresholds).toContain(0.5); + expect(triggeredThresholds).toContain(0.75); +}); + +test("should track thresholds when crossing multiple in a single update", () => { + // Using multiple specific thresholds + const { getByTestId } = render( + , + ); + const element = getByTestId("threshold-trigger"); + + // Initially not in view + expect(element.getAttribute("data-trigger-count")).toBe("0"); + + // Jump straight to 0.7 (crosses 0.2, 0.4, 0.6 thresholds) + // The IntersectionObserver will still only call the callback once + // with the highest threshold that was crossed + mockAllIsIntersecting(0.7); + expect(element.getAttribute("data-trigger-count")).toBe("1"); + expect(element.getAttribute("data-last-ratio")).toBe("0.60"); + + // Go out of view + mockAllIsIntersecting(0); + + // Jump to full visibility + mockAllIsIntersecting(1.0); + expect(element.getAttribute("data-trigger-count")).toBe("2"); + expect(element.getAttribute("data-last-ratio")).toBe("0.80"); +}); + +test("should track thresholds when trigger is set to leave", () => { + // Using multiple specific thresholds with trigger: leave + const { getByTestId } = render( + , + ); + const element = getByTestId("threshold-trigger"); + + // Make element 30% visible - above first threshold, should call cleanup + mockAllIsIntersecting(0); + expect(element.getAttribute("data-trigger-count")).toBe("1"); + expect(element.getAttribute("data-last-ratio")).toBe("0.00"); +}); From 8b5e64e58bd0519e1db38987300cd320b077c29a Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Wed, 5 Mar 2025 21:18:17 +0100 Subject: [PATCH 12/16] fix cleanup on multiple thresholds --- src/__tests__/useOnInView.test.tsx | 13 ++++++++++++- src/useOnInView.tsx | 25 +++++++++++-------------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/__tests__/useOnInView.test.tsx b/src/__tests__/useOnInView.test.tsx index 0a120684..53ece7c2 100644 --- a/src/__tests__/useOnInView.test.tsx +++ b/src/__tests__/useOnInView.test.tsx @@ -93,6 +93,7 @@ const ThresholdTriggerComponent = ({ options?: IntersectionEffectOptions; }) => { const [triggerCount, setTriggerCount] = React.useState(0); + const [cleanupCount, setCleanupCount] = React.useState(0); const [lastRatio, setLastRatio] = React.useState(null); const [triggeredThresholds, setTriggeredThresholds] = React.useState< number[] @@ -106,6 +107,7 @@ const ThresholdTriggerComponent = ({ setTriggeredThresholds((prev) => [...prev, entry.intersectionRatio]); return (exitEntry) => { + setCleanupCount((prev) => prev + 1); if (exitEntry) { setLastRatio(exitEntry.intersectionRatio); } @@ -117,6 +119,7 @@ const ThresholdTriggerComponent = ({ data-testid="threshold-trigger" ref={inViewRef} data-trigger-count={triggerCount} + data-cleanup-count={cleanupCount} data-last-ratio={lastRatio !== null ? lastRatio.toFixed(2) : "null"} data-triggered-thresholds={JSON.stringify(triggeredThresholds)} > @@ -445,14 +448,22 @@ test("should track thresholds when crossing multiple in a single update", () => // with the highest threshold that was crossed mockAllIsIntersecting(0.7); expect(element.getAttribute("data-trigger-count")).toBe("1"); + expect(element.getAttribute("data-cleanup-count")).toBe("0"); expect(element.getAttribute("data-last-ratio")).toBe("0.60"); // Go out of view mockAllIsIntersecting(0); + expect(element.getAttribute("data-cleanup-count")).toBe("1"); // Jump to full visibility - mockAllIsIntersecting(1.0); + mockAllIsIntersecting(0.5); expect(element.getAttribute("data-trigger-count")).toBe("2"); + expect(element.getAttribute("data-last-ratio")).toBe("0.40"); + + // Jump to full visibility + mockAllIsIntersecting(1.0); + expect(element.getAttribute("data-trigger-count")).toBe("3"); + expect(element.getAttribute("data-cleanup-count")).toBe("2"); expect(element.getAttribute("data-last-ratio")).toBe("0.80"); }); diff --git a/src/useOnInView.tsx b/src/useOnInView.tsx index 24d226a4..6f1a8a45 100644 --- a/src/useOnInView.tsx +++ b/src/useOnInView.tsx @@ -79,30 +79,27 @@ export const useOnInView = ( const destroyInviewObserver = observe( element, (inView, entry) => { + // Cleanup the previous callback if it exists + if (callbackCleanup) { + callbackCleanup(entry); + callbackCleanup = undefined; + // If the callbackCleanup was called and triggerOnce is true + // the observer can be destroyed immediately after the callback is called + if (triggerOnce) { + destroyInviewObserver(); + return; + } + } // Call the callback when the element is in view (if trigger is "enter") // Call the callback when the element is out of view (if trigger is "leave") if (inView === intersectionsStateTrigger) { callbackCleanup = onIntersectionChangeRef.current(entry); - // if there is no cleanup function returned from the callback // and triggerOnce is true, the observer can be destroyed immediately if (triggerOnce && !callbackCleanup) { destroyInviewObserver(); } } - // Call cleanup when going out of view (if trigger is "enter") - // Call cleanup when going in view (if trigger is "leave") - else { - if (callbackCleanup) { - callbackCleanup(entry); - callbackCleanup = undefined; - // If the callbackCleanup was called and triggerOnce is true - // the observer can be destroyed immediately after the callback is called - if (triggerOnce) { - destroyInviewObserver(); - } - } - } }, { threshold, From d67bde9d00edc16897a6b4af72298e28f5382af8 Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Wed, 5 Mar 2025 21:20:03 +0100 Subject: [PATCH 13/16] improve comments --- src/__tests__/useOnInView.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/useOnInView.test.tsx b/src/__tests__/useOnInView.test.tsx index 53ece7c2..e9ad3e6c 100644 --- a/src/__tests__/useOnInView.test.tsx +++ b/src/__tests__/useOnInView.test.tsx @@ -455,12 +455,12 @@ test("should track thresholds when crossing multiple in a single update", () => mockAllIsIntersecting(0); expect(element.getAttribute("data-cleanup-count")).toBe("1"); - // Jump to full visibility + // Change to 0.5 (crosses 0.2, 0.4 thresholds) mockAllIsIntersecting(0.5); expect(element.getAttribute("data-trigger-count")).toBe("2"); expect(element.getAttribute("data-last-ratio")).toBe("0.40"); - // Jump to full visibility + // Jump to full visibility - should cleanup the 0.5 callback mockAllIsIntersecting(1.0); expect(element.getAttribute("data-trigger-count")).toBe("3"); expect(element.getAttribute("data-cleanup-count")).toBe("2"); From 6cff96f3977f26ac5880b5eb903218b5b2534818 Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Thu, 6 Mar 2025 11:00:40 +0100 Subject: [PATCH 14/16] improve comments --- src/useInView.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/useInView.tsx b/src/useInView.tsx index 3d3b29f8..60b21af3 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -62,10 +62,16 @@ export function useInView( const refCallback = useOnInView( // Combined callback - updates state, calls onChange, and returns cleanup if needed (entry) => { + const { onChange } = latestOptions.current; + // The callback is triggered when the element enters or leaves the viewport + // depending on trigger (which is defined by initialInView) + // If initialInView is false we wait for the element to enter the viewport + // in that case we set inView to true + // If initialInView is true we wait for the element to leave the viewport + // in that case we set inView to false const inView = !initialInView; setState({ inView, entry }); - const { onChange } = latestOptions.current; // Call the external onChange if provided // entry is undefined only if this is triggered by initialInView if (onChange) { From 194997ef0eda1182abaca9119adde7807e9455ab Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Fri, 7 Mar 2025 10:18:51 +0100 Subject: [PATCH 15/16] allow to stop listening manually --- src/__tests__/useOnInView.test.tsx | 124 +++++++++++++++++++++++++++++ src/index.tsx | 1 + src/useOnInView.tsx | 5 +- 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/__tests__/useOnInView.test.tsx b/src/__tests__/useOnInView.test.tsx index e9ad3e6c..6f767720 100644 --- a/src/__tests__/useOnInView.test.tsx +++ b/src/__tests__/useOnInView.test.tsx @@ -484,3 +484,127 @@ test("should track thresholds when trigger is set to leave", () => { expect(element.getAttribute("data-trigger-count")).toBe("1"); expect(element.getAttribute("data-last-ratio")).toBe("0.00"); }); + +test("should allow destroying the observer after custom condition is met", () => { + // Component that stops observing after a specific number of views + const DestroyAfterCountComponent = ({ maxViewCount = 2 }) => { + const [viewCount, setViewCount] = React.useState(0); + const [inView, setInView] = React.useState(false); + const [observerDestroyed, setObserverDestroyed] = React.useState(false); + + const inViewRef = useOnInView((entry, destroyObserver) => { + setInView(entry.isIntersecting); + + // Increment view count when element comes into view + if (entry.isIntersecting) { + const newCount = viewCount + 1; + setViewCount(newCount); + + // If we've reached the max view count, destroy the observer + if (newCount >= maxViewCount) { + destroyObserver(); + setObserverDestroyed(true); + } + } + + return () => { + setInView(false); + }; + }); + + return ( +
+ Destroy after {maxViewCount} views +
+ ); + }; + + const { getByTestId } = render(); + const wrapper = getByTestId("destroy-test"); + const instance = intersectionMockInstance(wrapper); + + // Initially not in view + expect(wrapper.getAttribute("data-view-count")).toBe("0"); + expect(wrapper.getAttribute("data-observer-destroyed")).toBe("false"); + + // First view + mockAllIsIntersecting(true); + expect(wrapper.getAttribute("data-inview")).toBe("true"); + expect(wrapper.getAttribute("data-view-count")).toBe("1"); + expect(wrapper.getAttribute("data-observer-destroyed")).toBe("false"); + + // Back out of view + mockAllIsIntersecting(false); + expect(wrapper.getAttribute("data-inview")).toBe("false"); + + // Second view - should hit max count and destroy observer + mockAllIsIntersecting(true); + expect(wrapper.getAttribute("data-inview")).toBe("true"); + expect(wrapper.getAttribute("data-view-count")).toBe("2"); + expect(wrapper.getAttribute("data-observer-destroyed")).toBe("true"); + + // Verify unobserve was called when destroying the observer + expect(instance.unobserve).toHaveBeenCalledWith(wrapper); + + // Additional intersection changes should have no effect since observer is destroyed + mockAllIsIntersecting(false); + mockAllIsIntersecting(true); + expect(wrapper.getAttribute("data-view-count")).toBe("2"); // Count should not increase +}); + +test("should allow destroying the observer immediately on first visibility", () => { + // This is useful for one-time animations or effects that should only run once + const DestroyImmediatelyComponent = () => { + const [hasBeenVisible, setHasBeenVisible] = React.useState(false); + const [observerDestroyed, setObserverDestroyed] = React.useState(false); + + const inViewRef = useOnInView((entry, destroyObserver) => { + if (entry.isIntersecting) { + setHasBeenVisible(true); + destroyObserver(); + setObserverDestroyed(true); + } + + return undefined; + }); + + return ( +
+ Destroy immediately +
+ ); + }; + + const { getByTestId } = render(); + const wrapper = getByTestId("destroy-immediate"); + + // Initially not visible + expect(wrapper.getAttribute("data-has-been-visible")).toBe("false"); + expect(wrapper.getAttribute("data-observer-destroyed")).toBe("false"); + + // Trigger visibility + mockAllIsIntersecting(true); + + // Should have been marked as visible and destroyed + expect(wrapper.getAttribute("data-has-been-visible")).toBe("true"); + expect(wrapper.getAttribute("data-observer-destroyed")).toBe("true"); + + // Additional intersection changes should have no effect + mockAllIsIntersecting(false); + mockAllIsIntersecting(true); + + // State should remain the same + expect(wrapper.getAttribute("data-has-been-visible")).toBe("true"); + expect(wrapper.getAttribute("data-observer-destroyed")).toBe("true"); +}); diff --git a/src/index.tsx b/src/index.tsx index f9dc153e..8e6398f9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -97,6 +97,7 @@ export type InViewHookResponse = [ */ export type IntersectionChangeEffect = ( entry: IntersectionObserverEntry & { target: TElement }, + destroyObserver: () => void, ) => // biome-ignore lint/suspicious/noConfusingVoidType: Allow no return statement | void | undefined diff --git a/src/useOnInView.tsx b/src/useOnInView.tsx index 6f1a8a45..c80d2066 100644 --- a/src/useOnInView.tsx +++ b/src/useOnInView.tsx @@ -93,7 +93,10 @@ export const useOnInView = ( // Call the callback when the element is in view (if trigger is "enter") // Call the callback when the element is out of view (if trigger is "leave") if (inView === intersectionsStateTrigger) { - callbackCleanup = onIntersectionChangeRef.current(entry); + callbackCleanup = onIntersectionChangeRef.current( + entry, + destroyInviewObserver, + ); // if there is no cleanup function returned from the callback // and triggerOnce is true, the observer can be destroyed immediately if (triggerOnce && !callbackCleanup) { From ff3e88c1585c2a2132560e4f1bbb3cafdc0a336b Mon Sep 17 00:00:00 2001 From: Jan Nicklas Date: Wed, 9 Apr 2025 16:01:05 +0200 Subject: [PATCH 16/16] fix trigger once --- src/observe.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/observe.ts b/src/observe.ts index a1b48efb..a27142a4 100644 --- a/src/observe.ts +++ b/src/observe.ts @@ -71,9 +71,16 @@ function createObserver(options: IntersectionObserverInit) { entry.isVisible = inView; } - elements.get(entry.target)?.forEach((callback) => { - callback(inView, entry); - }); + elements + .get(entry.target) + // slice creates a shallow copy of the array + // otherwise an `unobserve` call from a callback + // would modifiy `elements` and therefore some + // callbacks in the forEach loop would be skipped + ?.slice() + .forEach((callback) => { + callback(inView, entry); + }); }); }, options);