diff --git a/.changeset/light-jobs-grin.md b/.changeset/light-jobs-grin.md new file mode 100644 index 00000000..baf741cf --- /dev/null +++ b/.changeset/light-jobs-grin.md @@ -0,0 +1,5 @@ +--- +'react-simplikit': patch +--- + +`useIntersectionObserver` no longer recreates the underlying `IntersectionObserver` on every render when an inline `options` object is passed. It now recreates it only when `root`, `rootMargin`, or `threshold` actually change. diff --git a/packages/react-simplikit/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.ts b/packages/react-simplikit/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.ts index e065bd72..ab9ca6f7 100644 --- a/packages/react-simplikit/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.ts +++ b/packages/react-simplikit/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.ts @@ -99,4 +99,49 @@ describe('useIntersectionObserver', () => { expect(mockObserve).not.toHaveBeenCalled(); }); + + it('should recreate the observer when threshold, rootMargin, or root changes', async () => { + const root = document.createElement('div'); + const { result, rerender } = await renderHookSSR( + (props: IntersectionObserverInit) => useIntersectionObserver(vi.fn(), props), + { initialProps: { root: null, rootMargin: '0px', threshold: 0.5 } as IntersectionObserverInit } + ); + const mockElement = document.createElement('div'); + + await act(async () => { + result.current(mockElement); + }); + expect(IntersectionObserverSpy).toHaveBeenCalledTimes(1); + + rerender({ root: null, rootMargin: '0px', threshold: [0, 0.5] }); + expect(IntersectionObserverSpy).toHaveBeenCalledTimes(2); + + rerender({ root: null, rootMargin: '10px', threshold: [0, 0.5] }); + expect(IntersectionObserverSpy).toHaveBeenCalledTimes(3); + + rerender({ root, rootMargin: '10px', threshold: [0, 0.5] }); + expect(IntersectionObserverSpy).toHaveBeenCalledTimes(4); + }); + + it('should not recreate the observer when a new threshold array has the same values in the same order', async () => { + const { result, rerender } = await renderHookSSR( + (props: IntersectionObserverInit) => useIntersectionObserver(vi.fn(), props), + { initialProps: { threshold: [0, 0.25, 0.5] } as IntersectionObserverInit } + ); + const mockElement = document.createElement('div'); + + await act(async () => { + result.current(mockElement); + }); + expect(IntersectionObserverSpy).toHaveBeenCalledTimes(1); + + // A brand-new array instance with identical values must not be treated as a change. + rerender({ threshold: [0, 0.25, 0.5] }); + expect(IntersectionObserverSpy).toHaveBeenCalledTimes(1); + expect(mockUnobserve).not.toHaveBeenCalled(); + + // Same values, different order: a real change, so it should recreate. + rerender({ threshold: [0.5, 0.25, 0] }); + expect(IntersectionObserverSpy).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/react-simplikit/src/hooks/useIntersectionObserver/useIntersectionObserver.ts b/packages/react-simplikit/src/hooks/useIntersectionObserver/useIntersectionObserver.ts index 9042ea55..f4342bcf 100644 --- a/packages/react-simplikit/src/hooks/useIntersectionObserver/useIntersectionObserver.ts +++ b/packages/react-simplikit/src/hooks/useIntersectionObserver/useIntersectionObserver.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { usePreservedCallback } from '../usePreservedCallback/index.ts'; +import { usePreservedReference } from '../usePreservedReference/index.ts'; import { useRefEffect } from '../useRefEffect/index.ts'; /** @@ -40,6 +41,7 @@ export function useIntersectionObserver( options: IntersectionObserverInit ): (element: Element | null) => void { const preservedCallback = usePreservedCallback(callback); + const preservedOptions = usePreservedReference(options, areIntersectionOptionsEqual); const observer = useMemo(() => { if (typeof IntersectionObserver === 'undefined') { @@ -48,8 +50,8 @@ export function useIntersectionObserver( return new IntersectionObserver(([entry]) => { preservedCallback(entry); - }, options); - }, [preservedCallback, options]); + }, preservedOptions); + }, [preservedCallback, preservedOptions]); return useRefEffect( element => { @@ -59,6 +61,16 @@ export function useIntersectionObserver( observer?.unobserve(element); }; }, - [preservedCallback, options] + [preservedCallback, preservedOptions] + ); +} + +// `root` is a DOM node: `JSON.stringify` would collapse any node to `"{}"`, so it must be compared by reference. +// `threshold` can be an inline array (e.g. `threshold: [0, 0.5]`) that gets a new reference every render even +// though the values are unchanged, so it needs `JSON.stringify` instead of `===`. `rootMargin` is a plain +// string, so `===` already compares it by value. +function areIntersectionOptionsEqual(a: IntersectionObserverInit, b: IntersectionObserverInit): boolean { + return ( + a.root === b.root && a.rootMargin === b.rootMargin && JSON.stringify(a.threshold) === JSON.stringify(b.threshold) ); }