From f2ec2787b238201c55a1c07b076a23263dfd31bb Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 13:59:20 +0200 Subject: [PATCH 01/17] refactor: extract callback ref observer core --- src/useIntersectionObserverRef.ts | 127 ++++++++++++++++++++++++++++++ src/useOnInView.tsx | 101 +++--------------------- 2 files changed, 136 insertions(+), 92 deletions(-) create mode 100644 src/useIntersectionObserverRef.ts diff --git a/src/useIntersectionObserverRef.ts b/src/useIntersectionObserverRef.ts new file mode 100644 index 00000000..8a11a5ed --- /dev/null +++ b/src/useIntersectionObserverRef.ts @@ -0,0 +1,127 @@ +import * as React from "react"; +import type { IntersectionObserverInitWithOptions } from "./index"; +import { observe } from "./observe"; + +const useSyncEffect = + Reflect.get(React, "useInsertionEffect") || React.useLayoutEffect; + +const canUseRefCleanup = React.version.startsWith("19."); + +type ObserverCallback = ( + inView: boolean, + entry: IntersectionObserverEntry & { target: TElement }, + previousInView: boolean | undefined, +) => void; + +type ObserverRefOptions = IntersectionObserverInitWithOptions & { + fallbackInView?: boolean; + skip?: boolean; + triggerOnce?: boolean; +}; + +export function useIntersectionObserverRef( + onIntersectionChange: ObserverCallback, + { + threshold, + root, + rootMargin, + scrollMargin, + trackVisibility, + delay, + fallbackInView, + skip, + triggerOnce, + }: ObserverRefOptions, +) { + const onIntersectionChangeRef = React.useRef(onIntersectionChange); + const observerStateRef = React.useRef< + [ + TElement | null, + (() => void) | undefined, + ((element?: TElement | null) => void) | null, + ] + >([null, undefined, null]); + + 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[2] !== setRef) { + return; + } + + if (element === observerState[0]) { + observerState[2] = setRef; + return canUseRefCleanup ? observerState[1] : undefined; + } + + const cleanup = observerState[1]; + observerState[1] = undefined; + cleanup?.(); + + if (!element || skip) { + observerState[0] = null; + observerState[2] = element ? setRef : null; + return; + } + + observerState[0] = element; + observerState[2] = setRef; + + let destroyObserver: (() => void) | undefined; + let previousInView: boolean | undefined; + + function stopObserving() { + destroyObserver?.(); + + if (observerState[1] === stopObserving) { + observerState[0] = null; + observerState[1] = undefined; + } + } + + observerState[1] = 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[1] !== stopObserving) destroyObserver(); + + return canUseRefCleanup ? observerState[1] : 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..e3774d3f 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 @@ -58,90 +48,17 @@ export const useOnInView = ( 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; + 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, - ], + }, ); }; From 29d550d4c9e0284257c90f0f8adb2ae3d6ada967 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 14:00:39 +0200 Subject: [PATCH 02/17] refactor: move useInView to callback ref observation --- src/useInView.tsx | 134 ++++++++++++++++++++-------------------------- 1 file changed, 57 insertions(+), 77 deletions(-) diff --git a/src/useInView.tsx b/src/useInView.tsx index c39871e1..0158e89a 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import type { IntersectionOptions, InViewHookResponse } from "./index"; -import { observe } from "./observe"; +import { useIntersectionObserverRef } from "./useIntersectionObserverRef"; type State = { inView: boolean; @@ -46,103 +46,83 @@ 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; - - // biome-ignore lint/correctness/useExhaustiveDependencies: threshold is not correctly detected as a dependency - React.useEffect( - () => { + const observerRef = useIntersectionObserverRef( + (inView, entry) => { if (lastInViewRef.current === undefined) { lastInViewRef.current = initialInView; } - // Ensure we have node ref, and that we shouldn't skip observing - if (skip || !ref) return; + 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 }); + onChange?.(inView, entry); + }, + { + threshold, + root, + rootMargin, + scrollMargin, + trackVisibility, + delay, + fallbackInView, + skip, + triggerOnce, + }, + ); - let unobserve: (() => void) | undefined; - unobserve = observe( - ref, - (inView, entry) => { - const previousInView = lastInViewRef.current; - lastInViewRef.current = inView; + const refState = React.useRef< + [boolean, boolean, ((node?: Element | null) => void) | null] + >([false, false, null]); - // Ignore the very first `false` notification so consumers only hear about actual state changes. - if (previousInView === undefined && !inView) { - return; - } + const setRef = React.useCallback( + function setRef(node?: Element | null) { + const refStateValue = refState.current; + if (!node && refStateValue[2] !== setRef) return; - setState({ - inView, - entry, - }); - if (callback.current) callback.current(inView, entry); + if (node) { + refStateValue[0] = !skip; + refStateValue[1] = false; + refStateValue[2] = setRef; + } else { + refStateValue[1] = refStateValue[0]; + refStateValue[0] = false; + refStateValue[2] = null; + } - 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, - ); + const cleanup = observerRef(node); + if (!cleanup) return; return () => { - if (unobserve) { - unobserve(); + cleanup(); + if (refStateValue[2] === setRef) { + refStateValue[1] = refStateValue[0]; + refStateValue[0] = false; + refStateValue[2] = null; } }; }, - // 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, - root, - rootMargin, - scrollMargin, - triggerOnce, - skip, - trackVisibility, - fallbackInView, - delay, - ], + [observerRef, skip], ); - 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, - }); + React.useLayoutEffect(() => { + if (!refState.current[1]) return; + refState.current[1] = false; + if (triggerOnce || skip) return; + + setState({ inView: !!initialInView, entry: undefined }); lastInViewRef.current = initialInView; - } + }); const result = [setRef, state.inView, state.entry] as InViewHookResponse; From 1abe6b3cfcd61bcaa3c5badae8d16ea5029cc49f Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 14:01:01 +0200 Subject: [PATCH 03/17] test: verify callback ref render improvement --- src/__tests__/useInView.test.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/__tests__/useInView.test.tsx b/src/__tests__/useInView.test.tsx index a911e8cd..5b07f8fb 100644 --- a/src/__tests__/useInView.test.tsx +++ b/src/__tests__/useInView.test.tsx @@ -542,7 +542,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 +552,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); }); From 50cbd67767808bc894f394af0b2a31acad6eea0e Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 14:05:23 +0200 Subject: [PATCH 04/17] refactor: simplify useInView detach tracking --- src/useInView.tsx | 35 ++++++++--------------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/src/useInView.tsx b/src/useInView.tsx index 0158e89a..fcb00d59 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -54,10 +54,7 @@ export function useInView({ const observerRef = useIntersectionObserverRef( (inView, entry) => { - if (lastInViewRef.current === undefined) { - lastInViewRef.current = initialInView; - } - const previousInView = lastInViewRef.current; + const previousInView = lastInViewRef.current ?? initialInView; lastInViewRef.current = inView; // Ignore the very first `false` notification so consumers only hear about actual state changes. @@ -81,43 +78,27 @@ export function useInView({ }, ); - const refState = React.useRef< - [boolean, boolean, ((node?: Element | null) => void) | null] - >([false, false, null]); + const refState = React.useRef(null); const setRef = React.useCallback( function setRef(node?: Element | null) { - const refStateValue = refState.current; - if (!node && refStateValue[2] !== setRef) return; - - if (node) { - refStateValue[0] = !skip; - refStateValue[1] = false; - refStateValue[2] = setRef; - } else { - refStateValue[1] = refStateValue[0]; - refStateValue[0] = false; - refStateValue[2] = null; - } + if (node) refState.current = node; + else if (refState.current) refState.current = false; const cleanup = observerRef(node); if (!cleanup) return; return () => { cleanup(); - if (refStateValue[2] === setRef) { - refStateValue[1] = refStateValue[0]; - refStateValue[0] = false; - refStateValue[2] = null; - } + if (refState.current === node) refState.current = false; }; }, - [observerRef, skip], + [observerRef], ); React.useLayoutEffect(() => { - if (!refState.current[1]) return; - refState.current[1] = false; + if (refState.current !== false) return; + refState.current = null; if (triggerOnce || skip) return; setState({ inView: !!initialInView, entry: undefined }); From 02eccae5b2f1f8861eb59fb5d69e09a942cbc551 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 14:05:41 +0200 Subject: [PATCH 05/17] chore: adjust useInView size limit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 53ead3b6..fa42e59e 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "path": "dist/index.mjs", "name": "useInView", "import": "{ useInView }", - "limit": "1.3 kB" + "limit": "1.32 kB" }, { "path": "dist/index.mjs", From 4d92fa56e9dffb68ef7c59558d8bcfbb0dcf164c Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 14:08:10 +0200 Subject: [PATCH 06/17] fix: preserve useOnInView declaration spelling --- src/useOnInView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/useOnInView.tsx b/src/useOnInView.tsx index e3774d3f..ef6e2773 100644 --- a/src/useOnInView.tsx +++ b/src/useOnInView.tsx @@ -67,5 +67,5 @@ export const useOnInView = ( triggerOnce, skip, }, - ); + ) as (element: TElement | undefined | null) => (() => void) | undefined; }; From 7f27ff0ebd288c72d41dd77f8abd4d4c8ad425be Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 14:14:19 +0200 Subject: [PATCH 07/17] refactor: colocate ref cleanup support detection --- src/__tests__/useOnInView.test.tsx | 2 +- src/refCleanupSupport.ts | 3 --- src/useIntersectionObserverRef.ts | 6 +++++- 3 files changed, 6 insertions(+), 5 deletions(-) delete mode 100644 src/refCleanupSupport.ts 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/useIntersectionObserverRef.ts b/src/useIntersectionObserverRef.ts index 8a11a5ed..7f1a98d2 100644 --- a/src/useIntersectionObserverRef.ts +++ b/src/useIntersectionObserverRef.ts @@ -5,7 +5,11 @@ import { observe } from "./observe"; const useSyncEffect = Reflect.get(React, "useInsertionEffect") || React.useLayoutEffect; -const canUseRefCleanup = React.version.startsWith("19."); +export function supportsRefCleanup(version: string | undefined) { + return version?.startsWith("19.") || false; +} + +const canUseRefCleanup = supportsRefCleanup(React.version); type ObserverCallback = ( inView: boolean, From 9770c7c020b0a8bdc400fa18adb1a8b8f03606c9 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 14:39:50 +0200 Subject: [PATCH 08/17] fix: keep initialInView non-reactive --- src/__tests__/useInView.test.tsx | 10 ++++++++++ src/useInView.tsx | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/__tests__/useInView.test.tsx b/src/__tests__/useInView.test.tsx index 5b07f8fb..d66f59fd 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); diff --git a/src/useInView.tsx b/src/useInView.tsx index fcb00d59..ffa9c78c 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -54,7 +54,7 @@ export function useInView({ const observerRef = useIntersectionObserverRef( (inView, entry) => { - const previousInView = lastInViewRef.current ?? initialInView; + const previousInView = lastInViewRef.current; lastInViewRef.current = inView; // Ignore the very first `false` notification so consumers only hear about actual state changes. From 19d68e4b208239bb25ebfe874b2478d8ea9dbfca Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 14:43:12 +0200 Subject: [PATCH 09/17] fix: avoid hook SSR diagnostics --- fixtures/react-compat/ssr.mjs | 24 ++++++++++++++++++++++++ package.json | 2 +- scripts/compat/packed.mts | 3 ++- src/useInView.tsx | 7 +++++-- src/useIntersectionObserverRef.ts | 5 ++++- 5 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 fixtures/react-compat/ssr.mjs diff --git a/fixtures/react-compat/ssr.mjs b/fixtures/react-compat/ssr.mjs new file mode 100644 index 00000000..9d38685e --- /dev/null +++ b/fixtures/react-compat/ssr.mjs @@ -0,0 +1,24 @@ +import { createRequire } from "node:module"; +import { createElement } from "react"; +import { useInView } from "react-intersection-observer"; + +const { renderToString } = createRequire(import.meta.url)("react-dom/server"); + +const errors = []; +const originalError = console.error; +console.error = (...arguments_) => errors.push(arguments_.join(" ")); + +try { + function CompatibilityFixture() { + const { ref, inView } = useInView(); + return createElement("div", { ref }, String(inView)); + } + + renderToString(createElement(CompatibilityFixture)); +} finally { + console.error = originalError; +} + +if (errors.length > 0) { + throw new Error(`SSR produced console errors:\n${errors.join("\n")}`); +} diff --git a/package.json b/package.json index fa42e59e..9322ee77 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,7 @@ "path": "dist/index.mjs", "name": "useOnInView", "import": "{ useOnInView }", - "limit": "1.1 kB" + "limit": "1.12 kB" }, { "path": "dist/index.mjs", diff --git a/scripts/compat/packed.mts b/scripts/compat/packed.mts index c02e6c31..a1666a22 100644 --- a/scripts/compat/packed.mts +++ b/scripts/compat/packed.mts @@ -82,8 +82,9 @@ try { await exec("pnpm", ["install"], { cwd: packageDirectory }); await exec("pnpm", ["build"], { cwd: packageDirectory }); + await exec("node", ["ssr.mjs"], { cwd: packageDirectory }); - console.log(`React ${major} packed build passed`); + console.log(`React ${major} packed build and SSR passed`); } finally { await rm(temporaryRoot, { recursive: true, force: true }); } diff --git a/src/useInView.tsx b/src/useInView.tsx index ffa9c78c..134b325c 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -1,6 +1,9 @@ import * as React from "react"; import type { IntersectionOptions, InViewHookResponse } from "./index"; -import { useIntersectionObserverRef } from "./useIntersectionObserverRef"; +import { + useIntersectionObserverRef, + useIsomorphicLayoutEffect, +} from "./useIntersectionObserverRef"; type State = { inView: boolean; @@ -96,7 +99,7 @@ export function useInView({ [observerRef], ); - React.useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { if (refState.current !== false) return; refState.current = null; if (triggerOnce || skip) return; diff --git a/src/useIntersectionObserverRef.ts b/src/useIntersectionObserverRef.ts index 7f1a98d2..1335adb2 100644 --- a/src/useIntersectionObserverRef.ts +++ b/src/useIntersectionObserverRef.ts @@ -2,8 +2,11 @@ import * as React from "react"; import type { IntersectionObserverInitWithOptions } from "./index"; import { observe } from "./observe"; +export const useIsomorphicLayoutEffect = + typeof window === "undefined" ? React.useEffect : React.useLayoutEffect; + const useSyncEffect = - Reflect.get(React, "useInsertionEffect") || React.useLayoutEffect; + Reflect.get(React, "useInsertionEffect") || useIsomorphicLayoutEffect; export function supportsRefCleanup(version: string | undefined) { return version?.startsWith("19.") || false; From 4a3a11a2013e54e026a859b881d7262da8c50d6f Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 14:51:38 +0200 Subject: [PATCH 10/17] test: use TypeScript for SSR compatibility fixture --- fixtures/react-compat/{ssr.mjs => ssr.mts} | 5 +++-- scripts/compat/packed.mts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) rename fixtures/react-compat/{ssr.mjs => ssr.mts} (83%) diff --git a/fixtures/react-compat/ssr.mjs b/fixtures/react-compat/ssr.mts similarity index 83% rename from fixtures/react-compat/ssr.mjs rename to fixtures/react-compat/ssr.mts index 9d38685e..61d80b1f 100644 --- a/fixtures/react-compat/ssr.mjs +++ b/fixtures/react-compat/ssr.mts @@ -4,9 +4,10 @@ import { useInView } from "react-intersection-observer"; const { renderToString } = createRequire(import.meta.url)("react-dom/server"); -const errors = []; +const errors: string[] = []; const originalError = console.error; -console.error = (...arguments_) => errors.push(arguments_.join(" ")); +console.error = (...arguments_: unknown[]) => + errors.push(arguments_.map(String).join(" ")); try { function CompatibilityFixture() { diff --git a/scripts/compat/packed.mts b/scripts/compat/packed.mts index a1666a22..de73f774 100644 --- a/scripts/compat/packed.mts +++ b/scripts/compat/packed.mts @@ -82,7 +82,7 @@ try { await exec("pnpm", ["install"], { cwd: packageDirectory }); await exec("pnpm", ["build"], { cwd: packageDirectory }); - await exec("node", ["ssr.mjs"], { cwd: packageDirectory }); + await exec("node", ["ssr.mts"], { cwd: packageDirectory }); console.log(`React ${major} packed build and SSR passed`); } finally { From ebc097e04cd9d13564ce46a848c26d6fcfe6826b Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 14:56:16 +0200 Subject: [PATCH 11/17] fix: typecheck packed SSR fixture --- fixtures/react-compat/ssr.mts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/fixtures/react-compat/ssr.mts b/fixtures/react-compat/ssr.mts index 61d80b1f..2566d9a8 100644 --- a/fixtures/react-compat/ssr.mts +++ b/fixtures/react-compat/ssr.mts @@ -1,8 +1,10 @@ import { createRequire } from "node:module"; import { createElement } from "react"; -import { useInView } from "react-intersection-observer"; const { renderToString } = createRequire(import.meta.url)("react-dom/server"); +// The package is installed only after this fixture is copied to the packed consumer. +const packageName = "react-intersection-observer"; +const { useInView } = (await import(packageName)) as typeof import("../../src"); const errors: string[] = []; const originalError = console.error; From 2ff2f38701b2d26a3e4e808e946122c6063a89c6 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 15:06:25 +0200 Subject: [PATCH 12/17] refactor: declare useOnInView return type --- src/useOnInView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/useOnInView.tsx b/src/useOnInView.tsx index ef6e2773..84b19d33 100644 --- a/src/useOnInView.tsx +++ b/src/useOnInView.tsx @@ -47,7 +47,7 @@ export const useOnInView = ( triggerOnce, skip, }: IntersectionEffectOptions = {}, -) => { +): ((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. @@ -67,5 +67,5 @@ export const useOnInView = ( triggerOnce, skip, }, - ) as (element: TElement | undefined | null) => (() => void) | undefined; + ); }; From 55ead98e499b2d320d3e4d823f5cacfd2c3cc476 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 21:00:15 +0200 Subject: [PATCH 13/17] test: add node SSR project --- src/__tests__/useInView.ssr.test.ts | 21 +++++++++++++++++++ vitest.config.ts | 31 ++++++++++++++++++++++------- 2 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/useInView.ssr.test.ts diff --git a/src/__tests__/useInView.ssr.test.ts b/src/__tests__/useInView.ssr.test.ts new file mode 100644 index 00000000..49d0c1bb --- /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))).toBe( + "
false
", + ); + expect(consoleError).not.toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } +}); 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/**"], From 0a8a9d2abcd2efd9a49c7d7283858da75b7d86df Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 21:00:34 +0200 Subject: [PATCH 14/17] test: move SSR coverage into Vitest --- fixtures/react-compat/ssr.mts | 27 --------------------------- scripts/compat/packed.mts | 3 +-- 2 files changed, 1 insertion(+), 29 deletions(-) delete mode 100644 fixtures/react-compat/ssr.mts diff --git a/fixtures/react-compat/ssr.mts b/fixtures/react-compat/ssr.mts deleted file mode 100644 index 2566d9a8..00000000 --- a/fixtures/react-compat/ssr.mts +++ /dev/null @@ -1,27 +0,0 @@ -import { createRequire } from "node:module"; -import { createElement } from "react"; - -const { renderToString } = createRequire(import.meta.url)("react-dom/server"); -// The package is installed only after this fixture is copied to the packed consumer. -const packageName = "react-intersection-observer"; -const { useInView } = (await import(packageName)) as typeof import("../../src"); - -const errors: string[] = []; -const originalError = console.error; -console.error = (...arguments_: unknown[]) => - errors.push(arguments_.map(String).join(" ")); - -try { - function CompatibilityFixture() { - const { ref, inView } = useInView(); - return createElement("div", { ref }, String(inView)); - } - - renderToString(createElement(CompatibilityFixture)); -} finally { - console.error = originalError; -} - -if (errors.length > 0) { - throw new Error(`SSR produced console errors:\n${errors.join("\n")}`); -} diff --git a/scripts/compat/packed.mts b/scripts/compat/packed.mts index de73f774..c02e6c31 100644 --- a/scripts/compat/packed.mts +++ b/scripts/compat/packed.mts @@ -82,9 +82,8 @@ try { await exec("pnpm", ["install"], { cwd: packageDirectory }); await exec("pnpm", ["build"], { cwd: packageDirectory }); - await exec("node", ["ssr.mts"], { cwd: packageDirectory }); - console.log(`React ${major} packed build and SSR passed`); + console.log(`React ${major} packed build passed`); } finally { await rm(temporaryRoot, { recursive: true, force: true }); } From 4074dc6e1a7f025c3d96370e7779b0f69641f139 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Tue, 14 Jul 2026 21:11:14 +0200 Subject: [PATCH 15/17] test: accept React 17 SSR root marker --- src/__tests__/useInView.ssr.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/useInView.ssr.test.ts b/src/__tests__/useInView.ssr.test.ts index 49d0c1bb..ebb03c9c 100644 --- a/src/__tests__/useInView.ssr.test.ts +++ b/src/__tests__/useInView.ssr.test.ts @@ -11,8 +11,8 @@ test("useInView renders on the server without diagnostics", () => { return createElement("div", { ref }, String(inView)); } - expect(renderToString(createElement(CompatibilityFixture))).toBe( - "
false
", + expect(renderToString(createElement(CompatibilityFixture))).toContain( + ">false", ); expect(consoleError).not.toHaveBeenCalled(); } finally { From 021dde178d80e684eedfa1e01e68b2f22702b76a Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 15 Jul 2026 11:49:38 +0200 Subject: [PATCH 16/17] fix: preserve React 17 fallback callbacks --- package.json | 2 +- src/__tests__/useInView.test.tsx | 31 +++++++++++++++ src/useInView.tsx | 28 ++++++++++--- src/useIntersectionObserverRef.ts | 66 +++++++++++++++++++------------ 4 files changed, 95 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index 9322ee77..d385895f 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "path": "dist/index.mjs", "name": "useInView", "import": "{ useInView }", - "limit": "1.32 kB" + "limit": "1.36 kB" }, { "path": "dist/index.mjs", diff --git a/src/__tests__/useInView.test.tsx b/src/__tests__/useInView.test.tsx index d66f59fd..27280b2e 100644 --- a/src/__tests__/useInView.test.tsx +++ b/src/__tests__/useInView.test.tsx @@ -481,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 diff --git a/src/useInView.tsx b/src/useInView.tsx index 134b325c..fbdacfc6 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -10,6 +10,11 @@ type State = { 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 @@ -81,27 +86,38 @@ export function useInView({ }, ); - const refState = React.useRef(null); + const refState = React.useRef({ + node: null, + reset: false, + }); const setRef = React.useCallback( function setRef(node?: Element | null) { - if (node) refState.current = node; - else if (refState.current) refState.current = false; + 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) refState.current = false; + if (refState.current.node === node) { + refState.current.node = null; + refState.current.reset = true; + } }; }, [observerRef], ); useIsomorphicLayoutEffect(() => { - if (refState.current !== false) return; - refState.current = null; + if (!refState.current.reset) return; + refState.current.reset = false; if (triggerOnce || skip) return; setState({ inView: !!initialInView, entry: undefined }); diff --git a/src/useIntersectionObserverRef.ts b/src/useIntersectionObserverRef.ts index 1335adb2..d8657d71 100644 --- a/src/useIntersectionObserverRef.ts +++ b/src/useIntersectionObserverRef.ts @@ -5,8 +5,10 @@ import { observe } from "./observe"; export const useIsomorphicLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect; -const useSyncEffect = - Reflect.get(React, "useInsertionEffect") || useIsomorphicLayoutEffect; +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; @@ -26,6 +28,16 @@ type ObserverRefOptions = IntersectionObserverInitWithOptions & { 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, { @@ -41,13 +53,17 @@ export function useIntersectionObserverRef( }: ObserverRefOptions, ) { const onIntersectionChangeRef = React.useRef(onIntersectionChange); - const observerStateRef = React.useRef< - [ - TElement | null, - (() => void) | undefined, - ((element?: TElement | null) => void) | null, - ] - >([null, undefined, null]); + 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; @@ -58,27 +74,27 @@ export function useIntersectionObserverRef( function setRef(element: TElement | undefined | null) { const observerState = observerStateRef.current; - if (!element && observerState[2] !== setRef) { + if (!element && observerState.owner !== setRef) { return; } - if (element === observerState[0]) { - observerState[2] = setRef; - return canUseRefCleanup ? observerState[1] : undefined; + if (element === observerState.node) { + observerState.owner = setRef; + return canUseRefCleanup ? observerState.stop : undefined; } - const cleanup = observerState[1]; - observerState[1] = undefined; + const cleanup = observerState.stop; + observerState.stop = undefined; cleanup?.(); if (!element || skip) { - observerState[0] = null; - observerState[2] = element ? setRef : null; + observerState.node = null; + observerState.owner = element ? setRef : null; return; } - observerState[0] = element; - observerState[2] = setRef; + observerState.node = element; + observerState.owner = setRef; let destroyObserver: (() => void) | undefined; let previousInView: boolean | undefined; @@ -86,13 +102,13 @@ export function useIntersectionObserverRef( function stopObserving() { destroyObserver?.(); - if (observerState[1] === stopObserving) { - observerState[0] = null; - observerState[1] = undefined; + if (observerState.stop === stopObserving) { + observerState.node = null; + observerState.stop = undefined; } } - observerState[1] = stopObserving; + observerState.stop = stopObserving; destroyObserver = observe( element, (inView, entry) => { @@ -115,9 +131,9 @@ export function useIntersectionObserverRef( fallbackInView, ); - if (observerState[1] !== stopObserving) destroyObserver(); + if (observerState.stop !== stopObserving) destroyObserver(); - return canUseRefCleanup ? observerState[1] : undefined; + return canUseRefCleanup ? observerState.stop : undefined; }, [ Array.isArray(threshold) ? threshold.toString() : threshold, From 284b09fa5fe176eff002b079a87708921f4e72a0 Mon Sep 17 00:00:00 2001 From: Daniel Schmidt Date: Wed, 15 Jul 2026 12:22:22 +0200 Subject: [PATCH 17/17] refactor: keep reset timing in useInView --- src/useInView.tsx | 8 ++++---- src/useIntersectionObserverRef.ts | 3 --- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/useInView.tsx b/src/useInView.tsx index fbdacfc6..b9790965 100644 --- a/src/useInView.tsx +++ b/src/useInView.tsx @@ -1,9 +1,9 @@ import * as React from "react"; import type { IntersectionOptions, InViewHookResponse } from "./index"; -import { - useIntersectionObserverRef, - useIsomorphicLayoutEffect, -} from "./useIntersectionObserverRef"; +import { useIntersectionObserverRef } from "./useIntersectionObserverRef"; + +const useIsomorphicLayoutEffect = + typeof window === "undefined" ? React.useEffect : React.useLayoutEffect; type State = { inView: boolean; diff --git a/src/useIntersectionObserverRef.ts b/src/useIntersectionObserverRef.ts index d8657d71..5cad2511 100644 --- a/src/useIntersectionObserverRef.ts +++ b/src/useIntersectionObserverRef.ts @@ -2,9 +2,6 @@ import * as React from "react"; import type { IntersectionObserverInitWithOptions } from "./index"; import { observe } from "./observe"; -export const useIsomorphicLayoutEffect = - typeof window === "undefined" ? React.useEffect : React.useLayoutEffect; - const useInsertionEffect = Reflect.get(React, "useInsertionEffect") as | typeof React.useEffect | undefined;