diff --git a/package.json b/package.json
index 53ead3b6..d385895f 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/__tests__/useInView.ssr.test.ts b/src/__tests__/useInView.ssr.test.ts
new file mode 100644
index 00000000..ebb03c9c
--- /dev/null
+++ b/src/__tests__/useInView.ssr.test.ts
@@ -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",
+ );
+ expect(consoleError).not.toHaveBeenCalled();
+ } finally {
+ consoleError.mockRestore();
+ }
+});
diff --git a/src/__tests__/useInView.test.tsx b/src/__tests__/useInView.test.tsx
index a911e8cd..27280b2e 100644
--- a/src/__tests__/useInView.test.tsx
+++ b/src/__tests__/useInView.test.tsx
@@ -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();
+
+ rerender();
+ mockAllIsIntersecting(false);
+
+ expect(onChange).not.toHaveBeenCalled();
+});
+
test("should trigger a hook leaving view", () => {
const { getByText } = render();
mockAllIsIntersecting(true);
@@ -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(
+ ,
+ );
+ firstOnChange.mockClear();
+
+ rerender(
+ ,
+ );
+
+ expect(firstOnChange).not.toHaveBeenCalled();
+ expect(secondOnChange).toHaveBeenCalledOnce();
+});
+
test("should handle defaultFallbackInView if unsupported", () => {
destroyIntersectionMocking();
// @ts-expect-error
@@ -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();
@@ -552,9 +593,8 @@ test("baseline: mounting useInView commits once more after storing the target",
,
);
- // 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);
});
diff --git a/src/__tests__/useOnInView.test.tsx b/src/__tests__/useOnInView.test.tsx
index 44433d5d..80aacf46 100644
--- a/src/__tests__/useOnInView.test.tsx
+++ b/src/__tests__/useOnInView.test.tsx
@@ -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 = ({
diff --git a/src/refCleanupSupport.ts b/src/refCleanupSupport.ts
deleted file mode 100644
index 6629cc7f..00000000
--- a/src/refCleanupSupport.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export function supportsRefCleanup(version: string | undefined) {
- return version?.startsWith("19.") ?? false;
-}
diff --git a/src/useInView.tsx b/src/useInView.tsx
index c39871e1..b9790965 100644
--- a/src/useInView.tsx
+++ b/src/useInView.tsx
@@ -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
@@ -46,103 +54,75 @@ export function useInView({
fallbackInView,
onChange,
}: IntersectionOptions = {}): InViewHookResponse {
- const [ref, setRef] = React.useState(null);
- const callback = React.useRef(onChange);
const lastInViewRef = React.useRef(initialInView);
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;
+ const observerRef = useIntersectionObserverRef(
+ (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(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({
+ 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;
diff --git a/src/useIntersectionObserverRef.ts b/src/useIntersectionObserverRef.ts
new file mode 100644
index 00000000..5cad2511
--- /dev/null
+++ b/src/useIntersectionObserverRef.ts
@@ -0,0 +1,147 @@
+import * as React from "react";
+import type { IntersectionObserverInitWithOptions } from "./index";
+import { observe } from "./observe";
+
+const useInsertionEffect = Reflect.get(React, "useInsertionEffect") as
+ | typeof React.useEffect
+ | undefined;
+const useSyncEffect = useInsertionEffect ?? React.useEffect;
+
+export function supportsRefCleanup(version: string | undefined) {
+ return version?.startsWith("19.") || false;
+}
+
+const canUseRefCleanup = supportsRefCleanup(React.version);
+
+type ObserverCallback = (
+ inView: boolean,
+ entry: IntersectionObserverEntry & { target: TElement },
+ previousInView: boolean | undefined,
+) => void;
+
+type ObserverRefOptions = IntersectionObserverInitWithOptions & {
+ fallbackInView?: boolean;
+ skip?: boolean;
+ triggerOnce?: boolean;
+};
+
+type ObserverRefCallback = (
+ element: TElement | undefined | null,
+) => (() => void) | undefined;
+
+type ObserverState = {
+ node: TElement | null;
+ stop: (() => void) | undefined;
+ owner: ObserverRefCallback | null;
+};
+
+export function useIntersectionObserverRef(
+ onIntersectionChange: ObserverCallback,
+ {
+ threshold,
+ root,
+ rootMargin,
+ scrollMargin,
+ trackVisibility,
+ delay,
+ fallbackInView,
+ skip,
+ triggerOnce,
+ }: ObserverRefOptions,
+) {
+ const onIntersectionChangeRef = React.useRef(onIntersectionChange);
+ const observerStateRef = React.useRef>({
+ node: null,
+ stop: undefined,
+ owner: null,
+ });
+
+ // React 17 has no effect that runs before callback refs attach. Keep its
+ // synchronous fallback behavior compatible by publishing during render.
+ if (!useInsertionEffect) {
+ onIntersectionChangeRef.current = onIntersectionChange;
+ }
+
+ useSyncEffect(() => {
+ onIntersectionChangeRef.current = onIntersectionChange;
+ }, [onIntersectionChange]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Threshold arrays are normalized inside the callback
+ return React.useCallback(
+ function setRef(element: TElement | undefined | null) {
+ const observerState = observerStateRef.current;
+
+ if (!element && observerState.owner !== setRef) {
+ return;
+ }
+
+ if (element === observerState.node) {
+ observerState.owner = setRef;
+ return canUseRefCleanup ? observerState.stop : undefined;
+ }
+
+ const cleanup = observerState.stop;
+ observerState.stop = undefined;
+ cleanup?.();
+
+ if (!element || skip) {
+ observerState.node = null;
+ observerState.owner = element ? setRef : null;
+ return;
+ }
+
+ observerState.node = element;
+ observerState.owner = setRef;
+
+ let destroyObserver: (() => void) | undefined;
+ let previousInView: boolean | undefined;
+
+ function stopObserving() {
+ destroyObserver?.();
+
+ if (observerState.stop === stopObserving) {
+ observerState.node = null;
+ observerState.stop = undefined;
+ }
+ }
+
+ observerState.stop = stopObserving;
+ destroyObserver = observe(
+ element,
+ (inView, entry) => {
+ onIntersectionChangeRef.current(
+ inView,
+ entry as IntersectionObserverEntry & { target: TElement },
+ previousInView,
+ );
+ previousInView = inView;
+ if (triggerOnce && inView) stopObserving();
+ },
+ {
+ threshold,
+ root,
+ rootMargin,
+ scrollMargin,
+ trackVisibility,
+ delay,
+ },
+ fallbackInView,
+ );
+
+ if (observerState.stop !== stopObserving) destroyObserver();
+
+ return canUseRefCleanup ? observerState.stop : undefined;
+ },
+ [
+ Array.isArray(threshold) ? threshold.toString() : threshold,
+ root,
+ rootMargin,
+ scrollMargin,
+ trackVisibility,
+ delay,
+ fallbackInView,
+ skip,
+ triggerOnce,
+ ],
+ );
+}
diff --git a/src/useOnInView.tsx b/src/useOnInView.tsx
index 758c9f37..84b19d33 100644
--- a/src/useOnInView.tsx
+++ b/src/useOnInView.tsx
@@ -1,18 +1,8 @@
-import * as React from "react";
import type {
IntersectionChangeEffect,
IntersectionEffectOptions,
} from "./index";
-import { observe } from "./observe";
-import { supportsRefCleanup } from "./refCleanupSupport";
-
-const useSyncEffect = ((Reflect.get(React, "useInsertionEffect") as
- | typeof React.useEffect
- | undefined) ??
- React.useLayoutEffect ??
- React.useEffect) as typeof React.useEffect;
-
-const canUseRefCleanup = supportsRefCleanup(React.version);
+import { useIntersectionObserverRef } from "./useIntersectionObserverRef";
/**
* React Hooks make it easy to monitor when elements come into and leave view. Call
@@ -57,91 +47,18 @@ export const useOnInView = (
triggerOnce,
skip,
}: IntersectionEffectOptions = {},
-) => {
- const onIntersectionChangeRef = React.useRef(onIntersectionChange);
- const observedElementRef = React.useRef(null);
- const observerCleanupRef = React.useRef<(() => void) | undefined>(undefined);
- const lastInViewRef = React.useRef(undefined);
-
- useSyncEffect(() => {
- onIntersectionChangeRef.current = onIntersectionChange;
- }, [onIntersectionChange]);
-
- // biome-ignore lint/correctness/useExhaustiveDependencies: Threshold arrays are normalized inside the callback
- return React.useCallback(
- (element: TElement | undefined | null) => {
- // React 17 and 18 call callback refs with `null` instead of invoking a
- // returned cleanup, so eagerly tear down whenever the target changes.
- const cleanupExisting = () => {
- if (observerCleanupRef.current) {
- const cleanup = observerCleanupRef.current;
- observerCleanupRef.current = undefined;
- cleanup();
- }
- };
-
- if (element === observedElementRef.current) {
- return canUseRefCleanup ? observerCleanupRef.current : undefined;
- }
-
- if (!element || skip) {
- cleanupExisting();
- observedElementRef.current = null;
- lastInViewRef.current = undefined;
+): ((element: TElement | undefined | null) => (() => void) | undefined) => {
+ return useIntersectionObserverRef(
+ (inView, entry, previousInView) => {
+ // Ignore the very first `false` notification so consumers only hear about actual state changes.
+ if (previousInView === undefined && !inView) {
return;
}
- cleanupExisting();
-
- observedElementRef.current = element;
- let destroyed = false;
-
- const destroyObserver = observe(
- element,
- (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;
- }
-
- onIntersectionChangeRef.current(
- inView,
- entry as IntersectionObserverEntry & { target: TElement },
- );
- if (triggerOnce && inView) {
- stopObserving();
- }
- },
- {
- threshold,
- root,
- rootMargin,
- scrollMargin,
- trackVisibility,
- delay,
- } as IntersectionObserverInit,
- );
-
- function stopObserving() {
- // Centralized teardown so both manual destroys and React ref updates share
- // the same cleanup path (needed for React versions that never call the ref with `null`).
- if (destroyed) return;
- destroyed = true;
- destroyObserver();
- observedElementRef.current = null;
- observerCleanupRef.current = undefined;
- lastInViewRef.current = undefined;
- }
-
- observerCleanupRef.current = stopObserving;
-
- return canUseRefCleanup ? observerCleanupRef.current : undefined;
+ onIntersectionChange(inView, entry);
},
- [
- Array.isArray(threshold) ? threshold.toString() : threshold,
+ {
+ threshold,
root,
rootMargin,
scrollMargin,
@@ -149,6 +66,6 @@ export const useOnInView = (
delay,
triggerOnce,
skip,
- ],
+ },
);
};
diff --git a/vitest.config.ts b/vitest.config.ts
index 0dc2ad10..84592d5e 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -6,14 +6,31 @@ export default defineConfig({
include: ["@vitest/coverage-istanbul", "react", "react-dom/test-utils"],
},
test: {
- environment: "node",
globals: true,
- browser: {
- enabled: true,
- provider: playwright(),
- headless: true,
- instances: [{ browser: "chromium" }],
- },
+ projects: [
+ {
+ extends: true,
+ test: {
+ name: "node",
+ environment: "node",
+ include: ["src/**/*.ssr.test.ts"],
+ },
+ },
+ {
+ extends: true,
+ test: {
+ name: "browser",
+ include: ["src/**/*.test.{ts,tsx}"],
+ exclude: ["src/**/*.ssr.test.ts"],
+ browser: {
+ enabled: true,
+ provider: playwright(),
+ headless: true,
+ instances: [{ browser: "chromium" }],
+ },
+ },
+ },
+ ],
coverage: {
provider: "istanbul",
include: ["src/**"],