From cf50542b0c90aa611a9abc408b8af6f32c3ba5a6 Mon Sep 17 00:00:00 2001 From: adamsoderstrom Date: Thu, 26 Feb 2026 17:53:50 +0100 Subject: [PATCH 1/3] test(react-utils): add initial version of `useSticky` test suite --- .changeset/moody-peaches-help.md | 2 + packages/react-utils/src/useSticky.test.ts | 231 +++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 .changeset/moody-peaches-help.md create mode 100644 packages/react-utils/src/useSticky.test.ts diff --git a/.changeset/moody-peaches-help.md b/.changeset/moody-peaches-help.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/moody-peaches-help.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/react-utils/src/useSticky.test.ts b/packages/react-utils/src/useSticky.test.ts new file mode 100644 index 00000000..036c70ae --- /dev/null +++ b/packages/react-utils/src/useSticky.test.ts @@ -0,0 +1,231 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { IntersectionObserverMock } from '../test' +import { useSticky } from './useSticky' + +const createRect = ({ + x = 0, + y = 0, + width = 100, + height = 20, +}: { + x?: number + y?: number + width?: number + height?: number +}) => new DOMRect(x, y, width, height) + +const createStickyElement = ({ + top = 10, + offsetHeight = 20, + rect = createRect({ y: 12, width: 100, height: 20 }), +}: { + top?: number + offsetHeight?: number + rect?: DOMRect +} = {}) => { + const element = document.createElement('div') + element.style.position = 'sticky' + element.style.top = `${top}px` + + Object.defineProperty(element, 'offsetHeight', { + configurable: true, + value: offsetHeight, + }) + + Object.defineProperty(element, 'offsetWidth', { + configurable: true, + value: 100, + }) + + vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() => rect) + document.body.appendChild(element) + + return element +} + +describe('useSticky', () => { + beforeAll(() => { + global.IntersectionObserver = IntersectionObserverMock + }) + + afterEach(() => { + IntersectionObserverMock.instances = [] + vi.clearAllMocks() + document.body.innerHTML = '' + }) + + it('returns initialValue and does not observe when disabled', () => { + const element = document.createElement('div') + document.body.appendChild(element) + const ref = { current: element } + + const { result } = renderHook(() => useSticky(ref, { when: false, initialValue: true })) + + expect(result.current).toBe(true) + expect(IntersectionObserverMock.instances.length).toBe(0) + }) + + it('calculates observer rootMargin from sticky inset and element size', async () => { + const element = createStickyElement({ top: 10, offsetHeight: 20 }) + const ref = { current: element } + + renderHook(() => useSticky(ref)) + + await waitFor(() => { + const observer = IntersectionObserverMock.instances.find( + (instance) => instance.options.rootMargin === '-30px', + ) + expect(observer).toBeDefined() + }) + }) + + it('sets stuck true when first observer intersects and second does not', async () => { + const element = createStickyElement({ + top: 10, + offsetHeight: 20, + rect: createRect({ y: 12, width: 100, height: 20 }), + }) + + const ref = { current: element } + const { result } = renderHook(() => useSticky(ref)) + + await waitFor(() => { + expect( + IntersectionObserverMock.instances.some( + (instance) => instance.options.rootMargin === '-30px', + ), + ).toBe(true) + }) + + const i1 = IntersectionObserverMock.instances.find( + (instance) => instance.options.rootMargin === '-30px', + ) + + expect(i1).toBeDefined() + + act(() => { + i1?.trigger([ + { target: element, isIntersecting: true } as unknown as IntersectionObserverEntry, + ]) + }) + + await waitFor(() => { + expect( + IntersectionObserverMock.instances.some( + (instance) => instance.options.rootMargin === '-31px', + ), + ).toBe(true) + }) + + const i2 = IntersectionObserverMock.instances.find( + (instance) => instance.options.rootMargin === '-31px', + ) + + expect(i2).toBeDefined() + + act(() => { + i2?.trigger([ + { target: element, isIntersecting: false } as unknown as IntersectionObserverEntry, + ]) + }) + + await waitFor(() => { + expect(result.current).toBe(true) + }) + + act(() => { + i2?.trigger([ + { target: element, isIntersecting: true } as unknown as IntersectionObserverEntry, + ]) + }) + + await waitFor(() => { + expect(result.current).toBe(false) + }) + }) + + it('uses custom container as observer root and stickiness boundary', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + + const element = createStickyElement({ + top: 10, + offsetHeight: 20, + rect: createRect({ y: 110, width: 100, height: 20 }), + }) + + container.appendChild(element) + + vi.spyOn(container, 'getBoundingClientRect').mockImplementation(() => + createRect({ y: 100, width: 300, height: 300 }), + ) + + const ref = { current: element } + const containerRef = { current: container } + + const { result } = renderHook(() => useSticky(ref, { container: containerRef })) + + await waitFor(() => { + expect( + IntersectionObserverMock.instances.some( + (instance) => + instance.options.root === container && instance.options.rootMargin === '-30px', + ), + ).toBe(true) + }) + + const i1 = IntersectionObserverMock.instances.find( + (instance) => instance.options.root === container && instance.options.rootMargin === '-30px', + ) + + expect(i1).toBeDefined() + + act(() => { + i1?.trigger([ + { target: element, isIntersecting: true } as unknown as IntersectionObserverEntry, + ]) + }) + + await waitFor(() => { + expect( + IntersectionObserverMock.instances.some( + (instance) => + instance.options.root === container && instance.options.rootMargin === '-31px', + ), + ).toBe(true) + }) + + const i2 = IntersectionObserverMock.instances.find( + (instance) => instance.options.root === container && instance.options.rootMargin === '-31px', + ) + + expect(i2).toBeDefined() + + act(() => { + i2?.trigger([ + { target: element, isIntersecting: false } as unknown as IntersectionObserverEntry, + ]) + }) + + await waitFor(() => { + expect(result.current).toBe(true) + }) + }) + + it('throws when referenced element is not position: sticky', () => { + const element = document.createElement('div') + element.style.position = 'relative' + element.style.top = '10px' + document.body.appendChild(element) + const ref = { current: element } + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { + void 0 + }) + + expect(() => renderHook(() => useSticky(ref))).toThrowError() + + consoleErrorSpy.mockRestore() + }) +}) From fc31cd434b42765e105a1c9fcc0477a1c6b54592 Mon Sep 17 00:00:00 2001 From: adamsoderstrom Date: Thu, 26 Feb 2026 18:31:50 +0100 Subject: [PATCH 2/3] fixup! test(react-utils): add initial version of `useSticky` test suite --- packages/react-utils/src/useSticky.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/react-utils/src/useSticky.test.ts b/packages/react-utils/src/useSticky.test.ts index 036c70ae..98fb6716 100644 --- a/packages/react-utils/src/useSticky.test.ts +++ b/packages/react-utils/src/useSticky.test.ts @@ -191,13 +191,18 @@ describe('useSticky', () => { expect( IntersectionObserverMock.instances.some( (instance) => - instance.options.root === container && instance.options.rootMargin === '-31px', + instance.options.root === container && + instance !== i1 && + instance.options.rootMargin !== '0px', ), ).toBe(true) }) const i2 = IntersectionObserverMock.instances.find( - (instance) => instance.options.root === container && instance.options.rootMargin === '-31px', + (instance) => + instance.options.root === container && + instance !== i1 && + instance.options.rootMargin !== '0px', ) expect(i2).toBeDefined() From 5247d1c15850051aea6473c549265263eb8f47ed Mon Sep 17 00:00:00 2001 From: adamsoderstrom Date: Mon, 2 Mar 2026 08:35:52 +0100 Subject: [PATCH 3/3] fixup! test(react-utils): add initial version of `useSticky` test suite --- packages/react-utils/src/useSticky.test.ts | 82 ++++++++++++---------- 1 file changed, 44 insertions(+), 38 deletions(-) diff --git a/packages/react-utils/src/useSticky.test.ts b/packages/react-utils/src/useSticky.test.ts index 98fb6716..7df1ccc7 100644 --- a/packages/react-utils/src/useSticky.test.ts +++ b/packages/react-utils/src/useSticky.test.ts @@ -67,23 +67,29 @@ describe('useSticky', () => { }) it('calculates observer rootMargin from sticky inset and element size', async () => { - const element = createStickyElement({ top: 10, offsetHeight: 20 }) + const top = 10 + const offsetHeight = 20 + const rootMargin = `-${top + offsetHeight}px` + const element = createStickyElement({ top, offsetHeight }) const ref = { current: element } renderHook(() => useSticky(ref)) await waitFor(() => { const observer = IntersectionObserverMock.instances.find( - (instance) => instance.options.rootMargin === '-30px', + (instance) => instance.options.rootMargin === rootMargin, ) expect(observer).toBeDefined() }) }) it('sets stuck true when first observer intersects and second does not', async () => { + const top = 10 + const offsetHeight = 20 + const rootMargin = `-${top + offsetHeight}px` const element = createStickyElement({ - top: 10, - offsetHeight: 20, + top, + offsetHeight, rect: createRect({ y: 12, width: 100, height: 20 }), }) @@ -93,13 +99,13 @@ describe('useSticky', () => { await waitFor(() => { expect( IntersectionObserverMock.instances.some( - (instance) => instance.options.rootMargin === '-30px', + (instance) => instance.options.rootMargin === rootMargin, ), ).toBe(true) }) const i1 = IntersectionObserverMock.instances.find( - (instance) => instance.options.rootMargin === '-30px', + (instance) => instance.options.rootMargin === rootMargin, ) expect(i1).toBeDefined() @@ -111,23 +117,21 @@ describe('useSticky', () => { }) await waitFor(() => { - expect( - IntersectionObserverMock.instances.some( - (instance) => instance.options.rootMargin === '-31px', - ), - ).toBe(true) + expect(IntersectionObserverMock.instances).not.toHaveLength(0) }) - const i2 = IntersectionObserverMock.instances.find( - (instance) => instance.options.rootMargin === '-31px', + const nonMountInstances = IntersectionObserverMock.instances.filter( + (instance) => instance.options.rootMargin !== rootMargin, ) - expect(i2).toBeDefined() + expect(nonMountInstances).not.toHaveLength(0) act(() => { - i2?.trigger([ - { target: element, isIntersecting: false } as unknown as IntersectionObserverEntry, - ]) + nonMountInstances.forEach((i) => { + i.trigger([ + { target: element, isIntersecting: false } as unknown as IntersectionObserverEntry, + ]) + }) }) await waitFor(() => { @@ -135,9 +139,11 @@ describe('useSticky', () => { }) act(() => { - i2?.trigger([ - { target: element, isIntersecting: true } as unknown as IntersectionObserverEntry, - ]) + nonMountInstances.forEach((i) => { + i.trigger([ + { target: element, isIntersecting: true } as unknown as IntersectionObserverEntry, + ]) + }) }) await waitFor(() => { @@ -149,12 +155,16 @@ describe('useSticky', () => { const container = document.createElement('div') document.body.appendChild(container) + const top = 10 + const offsetHeight = 20 const element = createStickyElement({ - top: 10, - offsetHeight: 20, + top, + offsetHeight, rect: createRect({ y: 110, width: 100, height: 20 }), }) + const rootMargin = `-${top + offsetHeight}px` + container.appendChild(element) vi.spyOn(container, 'getBoundingClientRect').mockImplementation(() => @@ -170,13 +180,14 @@ describe('useSticky', () => { expect( IntersectionObserverMock.instances.some( (instance) => - instance.options.root === container && instance.options.rootMargin === '-30px', + instance.options.root === container && instance.options.rootMargin === rootMargin, ), ).toBe(true) }) const i1 = IntersectionObserverMock.instances.find( - (instance) => instance.options.root === container && instance.options.rootMargin === '-30px', + (instance) => + instance.options.root === container && instance.options.rootMargin === rootMargin, ) expect(i1).toBeDefined() @@ -188,29 +199,24 @@ describe('useSticky', () => { }) await waitFor(() => { - expect( - IntersectionObserverMock.instances.some( - (instance) => - instance.options.root === container && - instance !== i1 && - instance.options.rootMargin !== '0px', - ), - ).toBe(true) + expect(IntersectionObserverMock.instances.length).not.toBe(0) }) - const i2 = IntersectionObserverMock.instances.find( + const nonMountInstances = IntersectionObserverMock.instances.filter( (instance) => instance.options.root === container && instance !== i1 && - instance.options.rootMargin !== '0px', + instance.options.rootMargin !== rootMargin, ) - expect(i2).toBeDefined() + expect(nonMountInstances).not.toHaveLength(0) act(() => { - i2?.trigger([ - { target: element, isIntersecting: false } as unknown as IntersectionObserverEntry, - ]) + nonMountInstances.forEach((i) => { + i.trigger([ + { target: element, isIntersecting: false } as unknown as IntersectionObserverEntry, + ]) + }) }) await waitFor(() => {