diff --git a/src/__tests__/useInView.test.tsx b/src/__tests__/useInView.test.tsx index aa47c4a9..a911e8cd 100644 --- a/src/__tests__/useInView.test.tsx +++ b/src/__tests__/useInView.test.tsx @@ -24,6 +24,41 @@ const HookComponent = ({ ); }; +const observerInstances = () => + vi + .mocked(window.IntersectionObserver) + .mock.results.map((result) => result.value as IntersectionObserver); + +const UseInViewLifecycleProbe = ({ + onChange, + onRender, + options, + target = "a", +}: { + onChange?: IntersectionOptions["onChange"]; + onRender?: () => void; + options?: IntersectionOptions; + target?: "a" | "b" | null; +}) => { + onRender?.(); + const [ref, inView] = useInView({ ...options, onChange }); + + return ( + <> +
+
+ + ); +}; + const LazyHookComponent = ({ options }: { options?: IntersectionOptions }) => { const [isLoading, setIsLoading] = React.useState(true); @@ -387,6 +422,33 @@ test("should set intersection ratio as the largest threshold smaller than trigge screen.getByText(/intersectionRatio: 0.5/); }); +test("useInView Strict Mode leaves no observer or React diagnostic", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const { unmount } = render( + + + , + ); + + unmount(); + + expect(observerInstances().length).toBeGreaterThan(0); + for (const observer of observerInstances()) { + expect(observer.observe).toHaveBeenCalledTimes(1); + expect(observer.unobserve).toHaveBeenCalledTimes(1); + expect(observer.disconnect).toHaveBeenCalledTimes(1); + } + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + warnSpy.mockRestore(); + } +}); + test("should handle fallback if unsupported", () => { destroyIntersectionMocking(); // @ts-expect-error @@ -479,3 +541,133 @@ test("should trigger all hooks when using triggerOnce with merged refs", () => { expect(getByTestId("item-2").getAttribute("data-inview")).toBe("true"); expect(getByTestId("item-3").getAttribute("data-inview")).toBe("true"); }); + +test("baseline: mounting useInView commits once more after storing the target", () => { + const onRender = vi.fn(); + const onCommit = vi.fn(); + + render( + + + , + ); + + // Plan 006 is expected to remove the target-state render and update this baseline. + expect(onRender).toHaveBeenCalledTimes(2); + expect(onCommit).toHaveBeenCalledTimes(2); + expect(window.IntersectionObserver).toHaveBeenCalledTimes(1); +}); + +test("useInView does not churn for equal threshold arrays", () => { + const { rerender } = render( + , + ); + const target = screen.getByTestId("lifecycle-a"); + const observer = intersectionMockInstance(target); + + rerender(); + + expect(window.IntersectionObserver).toHaveBeenCalledTimes(1); + expect(observer.observe).toHaveBeenCalledTimes(1); + expect(observer.unobserve).not.toHaveBeenCalled(); + expect(observer.disconnect).not.toHaveBeenCalled(); +}); + +test("useInView replaces the observer exactly once when options change", () => { + const { rerender } = render( + , + ); + const target = screen.getByTestId("lifecycle-a"); + const firstObserver = intersectionMockInstance(target); + + rerender(); + const secondObserver = intersectionMockInstance(target); + + expect(firstObserver.unobserve).toHaveBeenCalledTimes(1); + expect(firstObserver.unobserve).toHaveBeenCalledWith(target); + expect(firstObserver.disconnect).toHaveBeenCalledTimes(1); + expect(secondObserver).not.toBe(firstObserver); + expect(secondObserver.observe).toHaveBeenCalledTimes(1); + expect(secondObserver.observe).toHaveBeenCalledWith(target); + expect(window.IntersectionObserver).toHaveBeenCalledTimes(2); +}); + +test("useInView resets only while detached across A to B to detached to A", () => { + const { rerender } = render(); + const targetA = screen.getByTestId("lifecycle-a"); + const targetB = screen.getByTestId("lifecycle-b"); + const firstObserver = intersectionMockInstance(targetA); + + mockIsIntersecting(targetA, true); + expect(targetA).toHaveAttribute("data-inview", "true"); + + rerender(); + const secondObserver = intersectionMockInstance(targetB); + expect(targetB).toHaveAttribute("data-inview", "true"); + expect(firstObserver.unobserve).toHaveBeenCalledTimes(1); + expect(secondObserver.observe).toHaveBeenCalledWith(targetB); + + mockIsIntersecting(targetB, true); + rerender(); + expect(targetA).toHaveAttribute("data-inview", "false"); + expect(targetB).toHaveAttribute("data-inview", "false"); + expect(secondObserver.unobserve).toHaveBeenCalledTimes(1); + + rerender(); + const thirdObserver = intersectionMockInstance(targetA); + expect(targetA).toHaveAttribute("data-inview", "false"); + expect(thirdObserver.observe).toHaveBeenCalledTimes(1); + expect(thirdObserver.observe).toHaveBeenCalledWith(targetA); +}); + +test("useInView uses the latest onChange without recreating its observer", () => { + const firstOnChange = vi.fn(); + const secondOnChange = vi.fn(); + const { rerender } = render( + , + ); + const target = screen.getByTestId("lifecycle-a"); + const observer = intersectionMockInstance(target); + + mockIsIntersecting(target, true); + rerender(); + mockIsIntersecting(target, false); + + expect(firstOnChange).toHaveBeenCalledTimes(1); + expect(firstOnChange).toHaveBeenLastCalledWith( + true, + expect.objectContaining({ target }), + ); + expect(secondOnChange).toHaveBeenCalledTimes(1); + expect(secondOnChange).toHaveBeenLastCalledWith( + false, + expect.objectContaining({ target }), + ); + expect(window.IntersectionObserver).toHaveBeenCalledTimes(1); + expect(observer.observe).toHaveBeenCalledTimes(1); + expect(observer.unobserve).not.toHaveBeenCalled(); +}); + +test("useInView skip toggles retain visibility and attach exactly once", () => { + const { rerender } = render( + , + ); + const target = screen.getByTestId("lifecycle-a"); + const firstObserver = intersectionMockInstance(target); + + mockIsIntersecting(target, true); + rerender(); + expect(target).toHaveAttribute("data-inview", "true"); + expect(firstObserver.unobserve).toHaveBeenCalledTimes(1); + expect(firstObserver.disconnect).toHaveBeenCalledTimes(1); + + rerender(); + const secondObserver = intersectionMockInstance(target); + expect(target).toHaveAttribute("data-inview", "true"); + expect(secondObserver.observe).toHaveBeenCalledTimes(1); + + rerender(); + expect(target).toHaveAttribute("data-inview", "true"); + expect(secondObserver.unobserve).toHaveBeenCalledTimes(1); + expect(secondObserver.disconnect).toHaveBeenCalledTimes(1); +}); diff --git a/src/__tests__/useOnInView.test.tsx b/src/__tests__/useOnInView.test.tsx index 5f53c625..44433d5d 100644 --- a/src/__tests__/useOnInView.test.tsx +++ b/src/__tests__/useOnInView.test.tsx @@ -1,6 +1,6 @@ -import { render } from "@testing-library/react"; -import { useCallback, useEffect, useState } from "react"; -import type { IntersectionEffectOptions } from ".."; +import { render, screen } from "@testing-library/react"; +import { Profiler, StrictMode, useCallback, useEffect, useState } from "react"; +import type { IntersectionChangeEffect, IntersectionEffectOptions } from ".."; import { supportsRefCleanup } from "../refCleanupSupport"; import { intersectionMockInstance, mockAllIsIntersecting } from "../test-utils"; import { useOnInView } from "../useOnInView"; @@ -125,6 +125,33 @@ const RefLifecycleComponent = ({ attached }: { attached: boolean }) => { return attached ?
: null; }; +const observerInstances = () => + vi + .mocked(window.IntersectionObserver) + .mock.results.map((result) => result.value as IntersectionObserver); + +const UseOnInViewLifecycleProbe = ({ + onChange, + onRender, + options, + target = "a", +}: { + onChange?: IntersectionChangeEffect; + onRender?: () => void; + options?: IntersectionEffectOptions; + target?: "a" | "b" | null; +}) => { + onRender?.(); + const ref = useOnInView(onChange ?? (() => {}), options); + + return ( + <> +
+
+ + ); +}; + test.each([ ["19.0.0", true], ["19.0.0-rc.1", true], @@ -185,6 +212,10 @@ test("should ignore initial false intersection", () => { mockAllIsIntersecting(true); expect(wrapper.getAttribute("data-call-count")).toBe("1"); + + mockAllIsIntersecting(false); + expect(wrapper.getAttribute("data-inview")).toBe("false"); + expect(wrapper.getAttribute("data-call-count")).toBe("2"); }); test("should call cleanup when element leaves view", () => { @@ -291,6 +322,7 @@ test("should clean up each ref attachment without React diagnostics", () => { rerender(); expect(firstObserver.unobserve).toHaveBeenCalledTimes(1); expect(firstObserver.unobserve).toHaveBeenCalledWith(firstElement); + expect(firstObserver.disconnect).toHaveBeenCalledTimes(1); rerender(); const secondElement = getByTestId("ref-lifecycle"); @@ -299,6 +331,7 @@ test("should clean up each ref attachment without React diagnostics", () => { unmount(); expect(secondObserver.unobserve).toHaveBeenCalledTimes(1); expect(secondObserver.unobserve).toHaveBeenCalledWith(secondElement); + expect(secondObserver.disconnect).toHaveBeenCalledTimes(1); expect(errorSpy).not.toHaveBeenCalled(); expect(warnSpy).not.toHaveBeenCalled(); } finally { @@ -330,11 +363,19 @@ const MergeRefsComponent = ({ }; test("should handle merged refs", () => { - const { rerender, getByTestId } = render(); + const { rerender, getByTestId, unmount } = render(); + const element = getByTestId("inview"); + const observer = intersectionMockInstance(element); mockAllIsIntersecting(true); rerender(); - expect(getByTestId("inview").getAttribute("data-inview")).toBe("true"); + expect(element.getAttribute("data-inview")).toBe("true"); + expect(observer.observe).toHaveBeenCalledTimes(1); + expect(observer.unobserve).not.toHaveBeenCalled(); + + unmount(); + expect(observer.unobserve).toHaveBeenCalledTimes(1); + expect(observer.disconnect).toHaveBeenCalledTimes(1); }); // Test multiple callbacks on the same element @@ -492,3 +533,154 @@ test("should track thresholds when crossing multiple in a single update", () => expect(element.getAttribute("data-cleanup-count")).toBe("1"); expect(element.getAttribute("data-last-ratio")).toBe("0.80"); }); + +test("mounting useOnInView does not cause a hook-owned rerender", () => { + const onRender = vi.fn(); + const onCommit = vi.fn(); + + render( + + + , + ); + + expect(onRender).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(window.IntersectionObserver).toHaveBeenCalledTimes(1); +}); + +test("useOnInView uses the latest callback without re-observing", () => { + const firstOnChange = vi.fn(); + const secondOnChange = vi.fn(); + const { rerender } = render( + , + ); + const target = screen.getByTestId("on-inview-a"); + const observer = intersectionMockInstance(target); + + mockAllIsIntersecting(true); + rerender(); + mockAllIsIntersecting(false); + + expect(firstOnChange).toHaveBeenCalledTimes(1); + expect(firstOnChange).toHaveBeenLastCalledWith( + true, + expect.objectContaining({ target }), + ); + expect(secondOnChange).toHaveBeenCalledTimes(1); + expect(secondOnChange).toHaveBeenLastCalledWith( + false, + expect.objectContaining({ target }), + ); + expect(window.IntersectionObserver).toHaveBeenCalledTimes(1); + expect(observer.observe).toHaveBeenCalledTimes(1); + expect(observer.unobserve).not.toHaveBeenCalled(); +}); + +test("useOnInView only replaces its observer when options change", () => { + const { rerender } = render( + , + ); + const target = screen.getByTestId("on-inview-a"); + const firstObserver = intersectionMockInstance(target); + + rerender(); + expect(window.IntersectionObserver).toHaveBeenCalledTimes(1); + expect(firstObserver.observe).toHaveBeenCalledTimes(1); + expect(firstObserver.unobserve).not.toHaveBeenCalled(); + + rerender(); + const secondObserver = intersectionMockInstance(target); + expect(firstObserver.unobserve).toHaveBeenCalledTimes(1); + expect(firstObserver.disconnect).toHaveBeenCalledTimes(1); + expect(secondObserver).not.toBe(firstObserver); + expect(secondObserver.observe).toHaveBeenCalledTimes(1); + expect(secondObserver.observe).toHaveBeenCalledWith(target); + expect(window.IntersectionObserver).toHaveBeenCalledTimes(2); +}); + +test("useOnInView target replacement cleans each attachment exactly once", () => { + const { rerender, unmount } = render( + , + ); + const targetA = screen.getByTestId("on-inview-a"); + const targetB = screen.getByTestId("on-inview-b"); + const firstObserver = intersectionMockInstance(targetA); + + rerender(); + const secondObserver = intersectionMockInstance(targetB); + expect(firstObserver.unobserve).toHaveBeenCalledTimes(1); + expect(firstObserver.unobserve).toHaveBeenCalledWith(targetA); + expect(firstObserver.disconnect).toHaveBeenCalledTimes(1); + expect(secondObserver.observe).toHaveBeenCalledTimes(1); + expect(secondObserver.observe).toHaveBeenCalledWith(targetB); + + unmount(); + expect(secondObserver.unobserve).toHaveBeenCalledTimes(1); + expect(secondObserver.unobserve).toHaveBeenCalledWith(targetB); + expect(secondObserver.disconnect).toHaveBeenCalledTimes(1); +}); + +test("useOnInView skip cleanup stays idempotent", () => { + const { rerender, unmount } = render( + , + ); + const target = screen.getByTestId("on-inview-a"); + const observer = intersectionMockInstance(target); + + rerender(); + rerender(); + unmount(); + + expect(observer.unobserve).toHaveBeenCalledTimes(1); + expect(observer.unobserve).toHaveBeenCalledWith(target); + expect(observer.disconnect).toHaveBeenCalledTimes(1); + expect(window.IntersectionObserver).toHaveBeenCalledTimes(1); +}); + +test("useOnInView triggerOnce cleanup stays idempotent", () => { + const onChange = vi.fn(); + const { unmount } = render( + , + ); + const target = screen.getByTestId("on-inview-a"); + const observer = intersectionMockInstance(target); + + mockAllIsIntersecting(true); + unmount(); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(observer.unobserve).toHaveBeenCalledTimes(1); + expect(observer.unobserve).toHaveBeenCalledWith(target); + expect(observer.disconnect).toHaveBeenCalledTimes(1); +}); + +test("useOnInView Strict Mode leaves no observer or React diagnostic", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const { unmount } = render( + + + , + ); + + unmount(); + + expect(observerInstances().length).toBeGreaterThan(0); + for (const observer of observerInstances()) { + expect(observer.observe).toHaveBeenCalledTimes(1); + expect(observer.unobserve).toHaveBeenCalledTimes(1); + expect(observer.disconnect).toHaveBeenCalledTimes(1); + } + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + warnSpy.mockRestore(); + } +});