Skip to content
Open
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,13 @@
"path": "dist/index.mjs",
"name": "useInView",
"import": "{ useInView }",
"limit": "1.3 kB"
"limit": "1.36 kB"
},
{
"path": "dist/index.mjs",
"name": "useOnInView",
"import": "{ useOnInView }",
"limit": "1.1 kB"
"limit": "1.12 kB"
},
{
"path": "dist/index.mjs",
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/useInView.ssr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import { useInView } from "../useInView";

test("useInView renders on the server without diagnostics", () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});

try {
function CompatibilityFixture() {
const { ref, inView } = useInView();
return createElement("div", { ref }, String(inView));
}

expect(renderToString(createElement(CompatibilityFixture))).toContain(
">false</div>",
);
expect(consoleError).not.toHaveBeenCalled();
} finally {
consoleError.mockRestore();
}
});
48 changes: 44 additions & 4 deletions src/__tests__/useInView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,16 @@ test("should create a hook with initialInView", () => {
getByText("false");
});

test("should not react to initialInView changes before the first notification", () => {
const onChange = vi.fn();
const { rerender } = render(<HookComponent options={{ onChange }} />);

rerender(<HookComponent options={{ initialInView: true, onChange }} />);
mockAllIsIntersecting(false);

expect(onChange).not.toHaveBeenCalled();
});

test("should trigger a hook leaving view", () => {
const { getByText } = render(<HookComponent />);
mockAllIsIntersecting(true);
Expand Down Expand Up @@ -471,6 +481,37 @@ test("should handle fallback if unsupported", () => {
);
});

test("should use the latest onChange when fallback reattaches synchronously", () => {
destroyIntersectionMocking();
// @ts-expect-error
window.IntersectionObserver = undefined;
const firstOnChange = vi.fn();
const secondOnChange = vi.fn();
const { rerender } = render(
<HookComponent
options={{
fallbackInView: true,
onChange: firstOnChange,
threshold: 0,
}}
/>,
);
firstOnChange.mockClear();

rerender(
<HookComponent
options={{
fallbackInView: true,
onChange: secondOnChange,
threshold: 1,
}}
/>,
);

expect(firstOnChange).not.toHaveBeenCalled();
expect(secondOnChange).toHaveBeenCalledOnce();
});

test("should handle defaultFallbackInView if unsupported", () => {
destroyIntersectionMocking();
// @ts-expect-error
Expand Down Expand Up @@ -542,7 +583,7 @@ test("should trigger all hooks when using triggerOnce with merged refs", () => {
expect(getByTestId("item-3").getAttribute("data-inview")).toBe("true");
});

test("baseline: mounting useInView commits once more after storing the target", () => {
test("mounting useInView does not cause an attachment rerender", () => {
const onRender = vi.fn();
const onCommit = vi.fn();

Expand All @@ -552,9 +593,8 @@ test("baseline: mounting useInView commits once more after storing the target",
</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(onRender).toHaveBeenCalledTimes(1);
expect(onCommit).toHaveBeenCalledTimes(1);
expect(window.IntersectionObserver).toHaveBeenCalledTimes(1);
});

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/useOnInView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
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 { supportsRefCleanup } from "../useIntersectionObserverRef";
import { useOnInView } from "../useOnInView";

const OnInViewChangedComponent = ({
Expand Down
3 changes: 0 additions & 3 deletions src/refCleanupSupport.ts

This file was deleted.

140 changes: 60 additions & 80 deletions src/useInView.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import * as React from "react";
import type { IntersectionOptions, InViewHookResponse } from "./index";
import { observe } from "./observe";
import { useIntersectionObserverRef } from "./useIntersectionObserverRef";

const useIsomorphicLayoutEffect =
typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;

type State = {
inView: boolean;
entry?: IntersectionObserverEntry;
};

type RefState = {
node: Element | null;
reset: boolean;
};

/**
* React Hooks make it easy to monitor the `inView` state of your components. Call
* the `useInView` hook with the (optional) [options](#options) you need. It will
Expand Down Expand Up @@ -46,103 +54,75 @@ export function useInView({
fallbackInView,
onChange,
}: IntersectionOptions = {}): InViewHookResponse {
const [ref, setRef] = React.useState<Element | null>(null);
const callback = React.useRef<IntersectionOptions["onChange"]>(onChange);
const lastInViewRef = React.useRef<boolean | undefined>(initialInView);
const [state, setState] = React.useState<State>({
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;
const observerRef = useIntersectionObserverRef<Element>(
(inView, entry) => {
const previousInView = lastInViewRef.current;
lastInViewRef.current = inView;

// biome-ignore lint/correctness/useExhaustiveDependencies: threshold is not correctly detected as a dependency
React.useEffect(
() => {
if (lastInViewRef.current === undefined) {
lastInViewRef.current = initialInView;
// Ignore the very first `false` notification so consumers only hear about actual state changes.
if (previousInView === undefined && !inView) {
return;
}
// Ensure we have node ref, and that we shouldn't skip observing
if (skip || !ref) return;

let unobserve: (() => void) | undefined;
unobserve = observe(
ref,
(inView, entry) => {
const previousInView = lastInViewRef.current;
lastInViewRef.current = inView;

// Ignore the very first `false` notification so consumers only hear about actual state changes.
if (previousInView === undefined && !inView) {
return;
}

setState({
inView,
entry,
});
if (callback.current) callback.current(inView, entry);

if (inView && triggerOnce && unobserve) {
// If it should only trigger once, unobserve the element after it's inView
unobserve();
unobserve = undefined;
}
},
{
root,
rootMargin,
scrollMargin,
threshold,
trackVisibility,
delay,
},
fallbackInView,
);

return () => {
if (unobserve) {
unobserve();
}
};
setState({ inView, entry });
onChange?.(inView, entry);
},
// 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,
scrollMargin,
triggerOnce,
skip,
trackVisibility,
fallbackInView,
delay,
],
fallbackInView,
skip,
triggerOnce,
},
);

const entryTarget = state.entry?.target;
const previousEntryTarget = React.useRef<Element | undefined>(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 refState = React.useRef<RefState>({
node: null,
reset: false,
});

const setRef = React.useCallback(
function setRef(node?: Element | null) {
if (node) {
refState.current.node = node;
refState.current.reset = false;
} else if (refState.current.node) {
refState.current.node = null;
refState.current.reset = true;
}

const cleanup = observerRef(node);
if (!cleanup) return;

return () => {
cleanup();
if (refState.current.node === node) {
refState.current.node = null;
refState.current.reset = true;
}
};
},
[observerRef],
);

useIsomorphicLayoutEffect(() => {
if (!refState.current.reset) return;
refState.current.reset = false;
if (triggerOnce || skip) return;

setState({ inView: !!initialInView, entry: undefined });
lastInViewRef.current = initialInView;
}
});

const result = [setRef, state.inView, state.entry] as InViewHookResponse;

Expand Down
Loading
Loading