diff --git a/README.md b/README.md index 242c9201..ea376dd2 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,62 @@ 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) +### `useOnInView` hook + +```js +const inViewRef = useOnInView( + (enterEntry) => { + // Do something with the element that came into view + console.log('Element is in view', enterEntry?.target); + + // Optionally return a cleanup function + return (exitEntry) => { + console.log('Element moved out of view or unmounted'); + }; + }, + options // Optional IntersectionObserver options +); +``` + +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 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` + +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 { useOnInView } from "react-intersection-observer"; + +const Component = () => { + // Track when element appears without causing re-renders + const trackingRef = useOnInView((entry) => { + // Element is in view - perhaps log an impression + console.log("Element appeared in view", entry.target); + + // Return optional cleanup function + return () => { + console.log("Element left view"); + }; + }, { + /* Optional options */ + threshold: 0.5, + trigger: "enter", + triggerOnce: true, + }); + + return ( +
+

This element is being tracked without re-renders

+
+ ); +}; +``` + ## Testing > [!TIP] 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 15f316b8..27f1770a 100644 --- a/package.json +++ b/package.json @@ -108,8 +108,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.4", 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/__tests__/useOnInView.test.tsx b/src/__tests__/useOnInView.test.tsx new file mode 100644 index 00000000..6f767720 --- /dev/null +++ b/src/__tests__/useOnInView.test.tsx @@ -0,0 +1,610 @@ +import { render } from "@testing-library/react"; +import React, { useCallback } from "react"; +import type { IntersectionEffectOptions } from ".."; +import { intersectionMockInstance, mockAllIsIntersecting } from "../test-utils"; +import { useOnInView } from "../useOnInView"; + +const OnInViewChangedComponent = ({ + options, + unmount, +}: { + options?: IntersectionEffectOptions; + unmount?: boolean; +}) => { + const [inView, setInView] = React.useState(false); + const [callCount, setCallCount] = React.useState(0); + const [cleanupCount, setCleanupCount] = React.useState(0); + + const inViewRef = useOnInView((entry) => { + setInView(entry.isIntersecting); + setCallCount((prev) => prev + 1); + // Return cleanup function + return (cleanupEntry) => { + setCleanupCount((prev) => prev + 1); + if (cleanupEntry) { + setInView(false); + } + }; + }, options); + + return ( +
+ {inView.toString()} +
+ ); +}; + +const LazyOnInViewChangedComponent = ({ + options, +}: { + options?: IntersectionEffectOptions; +}) => { + const [isLoading, setIsLoading] = React.useState(true); + const [inView, setInView] = React.useState(false); + + React.useEffect(() => { + setIsLoading(false); + }, []); + + const inViewRef = useOnInView((entry) => { + setInView(entry ? entry.isIntersecting : false); + return () => setInView(false); + }, options); + + if (isLoading) return
Loading
; + + return ( +
+ {inView.toString()} +
+ ); +}; + +const OnInViewChangedComponentWithoutClenaup = ({ + options, + unmount, +}: { + options?: IntersectionEffectOptions; + unmount?: boolean; +}) => { + const [callCount, setCallCount] = React.useState(0); + const inViewRef = useOnInView(() => { + setCallCount((prev) => prev + 1); + }, options); + + return ( +
+ ); +}; + +const ThresholdTriggerComponent = ({ + options, +}: { + 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[] + >([]); + + 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) => { + setCleanupCount((prev) => prev + 1); + if (exitEntry) { + setLastRatio(exitEntry.intersectionRatio); + } + }; + }, options); + + return ( +
+ Tracking thresholds +
+ ); +}; + +test("should create a hook with useOnInView", () => { + 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 useOnInView", () => { + 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 trigger: leave", () => { + const { getByTestId } = render( + , + ); + const wrapper = getByTestId("wrapper"); + + 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); + // the callback should not be called as it is triggered on leave + 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"); +}); + +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?: IntersectionEffectOptions }) => { + const [inView, setInView] = React.useState(false); + + const inViewRef = useOnInView((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?: IntersectionEffectOptions }) => { + const [inView1, setInView1] = React.useState(false); + const [inView2, setInView2] = React.useState(false); + const [inView3, setInView3] = React.useState(false); + + const ref1 = useOnInView((entry) => { + setInView1(entry ? entry.isIntersecting : false); + return () => setInView1(false); + }, options); + + const ref2 = useOnInView((entry) => { + setInView2(entry ? entry.isIntersecting : false); + return () => setInView2(false); + }, options); + + const ref3 = useOnInView((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 = useOnInView((entry) => { + capturedElement = entry.target; + return undefined; + }); + + return
; + }; + + const { getByTestId } = render(); + const element = getByTestId("element-test"); + mockAllIsIntersecting(true); + + 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-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"); + + // 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 - 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"); + 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"); +}); + +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 cec16ed2..8e6398f9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,13 +3,14 @@ import type * as React from "react"; export { InView } from "./InView"; export { useInView } from "./useInView"; -export { observe, defaultFallbackInView } from "./observe"; +export { useOnInView } from "./useOnInView"; +export { observe } from "./observe"; type Omit = Pick>; -export type ObserverInstanceCallback = ( +export type ObserverInstanceCallback = ( inView: boolean, - entry: IntersectionObserverEntry, + entry: IntersectionObserverEntry & { target: TElement }, ) => void; interface RenderProps { @@ -19,7 +20,7 @@ interface RenderProps { ref: React.RefObject | ((node?: Element | null) => void); } -export interface IntersectionOptions 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). */ @@ -30,16 +31,22 @@ export interface IntersectionOptions 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; - /** 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 */ delay?: number; +} + +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 { @@ -74,11 +81,25 @@ 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; }; + +/** + * 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 IntersectionChangeEffect = ( + entry: IntersectionObserverEntry & { target: TElement }, + destroyObserver: () => void, +) => // 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/observe.ts b/src/observe.ts index 5e0a79d3..a27142a4 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 @@ -83,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); @@ -112,49 +107,32 @@ 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, +export function observe( + element: TElement, + 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); // Register the callback listener for this element - const callbacks = elements.get(element) || []; - if (!elements.has(element)) { - elements.set(element, callbacks); + let callbacks = elements.get(element); + if (!callbacks) { + callbacks = []; + elements.set(element, callbacks as ObserverInstanceCallback[]); + observer.observe(element); } - callbacks.push(callback); - observer.observe(element); + 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 4eda4969..60b21af3 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 { useOnInView } from "./useOnInView"; type State = { inView: boolean; @@ -33,106 +33,90 @@ 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, + root, + rootMargin, + trackVisibility, + delay, + 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; - - // 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; + // 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; - let unobserve: (() => void) | undefined; - unobserve = observe( - ref, - (inView, entry) => { - setState({ - inView, - entry, - }); - if (callback.current) callback.current(inView, entry); + // Create the ref tracking function using useOnInView + 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 }); - 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, - ); + // Call the external onChange if provided + // entry is undefined only if this is triggered by initialInView + if (onChange) { + onChange(inView, entry); + } - return () => { - if (unobserve) { - unobserve(); - } - }; + 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(!inView, entry); + } + // should not reset current state if changing skip + if (!skip) { + setState({ + inView: !inView, + 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, + { + threshold, root, rootMargin, - triggerOnce, - skip, + // @ts-ignore trackVisibility, - fallbackInView, + // @ts-ignore delay, - ], + triggerOnce, + skip, + trigger: initialInView ? "leave" : undefined, + }, ); - 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/useOnInView.tsx b/src/useOnInView.tsx new file mode 100644 index 00000000..c80d2066 --- /dev/null +++ b/src/useOnInView.tsx @@ -0,0 +1,136 @@ +import * as React from "react"; +import type { + IntersectionChangeEffect, + IntersectionEffectOptions, +} from "./index"; +import { observe } from "./observe"; + +/** + * React Hooks make it easy to monitor when elements come into and leave view. Call + * 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. + * + * 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 { useOnInView } from 'react-intersection-observer'; + * + * const Component = () => { + * const inViewRef = useOnInView((entry) => { + * console.log(`Element is in view`, entry?.target); + * // Optional: cleanup function: + * return () => { + * console.log('Element moved out of view or unmounted'); + * }; + * }, { + * threshold: 0, + * }); + * + * return ( + *
+ *

This element is being monitored

+ *
+ * ); + * }; + * ``` + */ +export const useOnInView = ( + onIntersectionChange: IntersectionChangeEffect, + { + threshold, + root, + rootMargin, + trackVisibility, + delay, + triggerOnce, + skip, + trigger, + }: IntersectionEffectOptions = {}, +) => { + // 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(() => { + onIntersectionChangeRef.current = onIntersectionChange; + }, [onIntersectionChange]); + + return React.useCallback( + (element: TElement | undefined | null) => { + if (!element || skip) { + return; + } + + let callbackCleanup: + | undefined + | ReturnType>; + + // trigger "enter": intersectionsStateTrigger = true + // trigger "leave": intersectionsStateTrigger = false + const intersectionsStateTrigger = trigger !== "leave"; + + 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, + 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, + // @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 + [ + // Convert threshold array to string for stable dependency + Array.isArray(threshold) ? threshold.toString() : threshold, + root, + rootMargin, + trackVisibility, + delay, + triggerOnce, + skip, + trigger, + ], + ); +};