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/**"],