Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions src/__tests__/useInView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<div
data-testid="lifecycle-a"
data-inview={inView.toString()}
ref={target === "a" ? ref : undefined}
/>
<div
data-testid="lifecycle-b"
data-inview={inView.toString()}
ref={target === "b" ? ref : undefined}
/>
</>
);
};

const LazyHookComponent = ({ options }: { options?: IntersectionOptions }) => {
const [isLoading, setIsLoading] = React.useState(true);

Expand Down Expand Up @@ -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(
<React.StrictMode>
<UseInViewLifecycleProbe />
</React.StrictMode>,
);

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
Expand Down Expand Up @@ -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(
<React.Profiler id="useInView" onRender={onCommit}>
<UseInViewLifecycleProbe onRender={onRender} />
</React.Profiler>,
);

// 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(
<UseInViewLifecycleProbe options={{ threshold: [0.25, 0.75] }} />,
);
const target = screen.getByTestId("lifecycle-a");
const observer = intersectionMockInstance(target);

rerender(<UseInViewLifecycleProbe options={{ threshold: [0.25, 0.75] }} />);

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(
<UseInViewLifecycleProbe options={{ threshold: 0.25 }} />,
);
const target = screen.getByTestId("lifecycle-a");
const firstObserver = intersectionMockInstance(target);

rerender(<UseInViewLifecycleProbe options={{ threshold: 0.75 }} />);
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(<UseInViewLifecycleProbe target="a" />);
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(<UseInViewLifecycleProbe target="b" />);
const secondObserver = intersectionMockInstance(targetB);
expect(targetB).toHaveAttribute("data-inview", "true");
expect(firstObserver.unobserve).toHaveBeenCalledTimes(1);
expect(secondObserver.observe).toHaveBeenCalledWith(targetB);

mockIsIntersecting(targetB, true);
rerender(<UseInViewLifecycleProbe target={null} />);
expect(targetA).toHaveAttribute("data-inview", "false");
expect(targetB).toHaveAttribute("data-inview", "false");
expect(secondObserver.unobserve).toHaveBeenCalledTimes(1);

rerender(<UseInViewLifecycleProbe target="a" />);
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(
<UseInViewLifecycleProbe onChange={firstOnChange} />,
);
const target = screen.getByTestId("lifecycle-a");
const observer = intersectionMockInstance(target);

mockIsIntersecting(target, true);
rerender(<UseInViewLifecycleProbe onChange={secondOnChange} />);
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(
<UseInViewLifecycleProbe options={{ skip: false }} />,
);
const target = screen.getByTestId("lifecycle-a");
const firstObserver = intersectionMockInstance(target);

mockIsIntersecting(target, true);
rerender(<UseInViewLifecycleProbe options={{ skip: true }} />);
expect(target).toHaveAttribute("data-inview", "true");
expect(firstObserver.unobserve).toHaveBeenCalledTimes(1);
expect(firstObserver.disconnect).toHaveBeenCalledTimes(1);

rerender(<UseInViewLifecycleProbe options={{ skip: false }} />);
const secondObserver = intersectionMockInstance(target);
expect(target).toHaveAttribute("data-inview", "true");
expect(secondObserver.observe).toHaveBeenCalledTimes(1);

rerender(<UseInViewLifecycleProbe options={{ skip: true }} target={null} />);
expect(target).toHaveAttribute("data-inview", "true");
expect(secondObserver.unobserve).toHaveBeenCalledTimes(1);
expect(secondObserver.disconnect).toHaveBeenCalledTimes(1);
});
Loading
Loading