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();
+ }
+});