From 9f78d6c993fcbed01bae8bb97b9e6b097cc1a4ba Mon Sep 17 00:00:00 2001 From: Donghyun Lee Date: Sat, 17 Jan 2026 20:22:34 +0900 Subject: [PATCH 1/7] refactor: improve 'useIntersectionObserver' hook --- .../useIntersectionObserver.spec.tsx | 144 +++++++++--------- .../useIntersectionObserver.ts | 23 +-- 2 files changed, 83 insertions(+), 84 deletions(-) diff --git a/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx b/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx index 7f6791c..955badd 100644 --- a/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx +++ b/src/hooks/useIntersectionObserver/useIntersectionObserver.spec.tsx @@ -1,4 +1,4 @@ -import { type RefObject, useEffect } from 'react'; +import { useEffect } from 'react'; import { act, render, screen } from '@testing-library/react'; import { useIntersectionObserver } from './useIntersectionObserver'; @@ -11,9 +11,9 @@ class MockIntersectionObserver { this.callback = callback; } - observe = (element: Element) => { + observe = jest.fn((element: Element) => { this.elements.push(element); - }; + }); unobserve = jest.fn((element: Element) => { this.elements = this.elements.filter(el => el !== element); @@ -52,122 +52,107 @@ Object.defineProperty(window, 'IntersectionObserver', { value: mockIntersectionObserver, }); -describe('useIntersectionObserver hook', () => { +describe('useIntersectionObserver', () => { beforeEach(() => { mockIntersectionObserver.mockClear(); }); const TestComponent = ({ options, - onIntersect, + onChange, }: { options?: Parameters[0]; - onIntersect?: (isIntersecting: boolean) => void; + onChange?: (isIntersecting: boolean) => void; }) => { const [ref, isIntersecting] = useIntersectionObserver(options); useEffect(() => { - onIntersect?.(isIntersecting); - }, [isIntersecting, onIntersect]); + onChange?.(isIntersecting); + }, [isIntersecting, onChange]); - return
} data-testid="target-element" />; + return
; }; - it('should handle null ref without crashing', () => { - const NullRefComponent = () => { - useIntersectionObserver(); - return null; - }; + it('returns initial isIntersecting as false', () => { + let value: boolean | undefined; - act(() => { - render(); - }); + render( (value = v)} />); + + expect(value).toBe(false); }); - it('should return a ref and initial isIntersecting state as false', () => { - let initialIsIntersecting: boolean | undefined; + it('sets isIntersecting to true when intersection occurs', () => { + let value = false; - act(() => { - render( (initialIsIntersecting = val)} />); - }); + render( (value = v)} />); - expect(initialIsIntersecting).toBe(false); + const target = screen.getByTestId('target'); + const observer = mockIntersectionObserver.mock.results[0].value as MockIntersectionObserver; + + observer.trigger([{ isIntersecting: true, target }]); + + expect(value).toBe(true); }); - it('should set isIntersecting to true when the element intersects', () => { - let intersected = false; + it('toggles isIntersecting when repeat is true', () => { + let value = false; - act(() => { - render( (intersected = val)} />); - }); + render( (value = v)} />); + + const target = screen.getByTestId('target'); + const observer = mockIntersectionObserver.mock.results[0].value as MockIntersectionObserver; - const targetElement = screen.getByTestId('target-element'); - const observerInstance = mockIntersectionObserver.mock.results[0].value; + observer.trigger([{ isIntersecting: true, target }]); + expect(value).toBe(true); - observerInstance.trigger([{ isIntersecting: true, target: targetElement }]); - expect(intersected).toBe(true); + observer.trigger([{ isIntersecting: false, target }]); + expect(value).toBe(false); }); - it('should toggle isIntersecting when repeat is true', () => { - let intersected = false; + it('does not update state when isIntersecting value does not change', () => { + const onChange = jest.fn(); - act(() => { - render( (intersected = val)} />); - }); + render(); - const targetElement = screen.getByTestId('target-element'); - const observerInstance = mockIntersectionObserver.mock.results[0].value; + const target = screen.getByTestId('target'); + const observer = mockIntersectionObserver.mock.results[0].value as MockIntersectionObserver; - observerInstance.trigger([{ isIntersecting: true, target: targetElement }]); - expect(intersected).toBe(true); + observer.trigger([{ isIntersecting: false, target }]); + expect(onChange).toHaveBeenCalledTimes(1); - observerInstance.trigger([{ isIntersecting: false, target: targetElement }]); - expect(intersected).toBe(false); + observer.trigger([{ isIntersecting: true, target }]); + expect(onChange).toHaveBeenCalledTimes(2); + + observer.trigger([{ isIntersecting: true, target }]); + expect(onChange).toHaveBeenCalledTimes(2); }); - it('should not toggle back when repeat is false', () => { - let intersected = false; + it('unobserves after first intersection when repeat is false', () => { + let value = false; - act(() => { - render( (intersected = val)} />); - }); + render( (value = v)} />); - const targetElement = screen.getByTestId('target-element'); - const observerInstance = mockIntersectionObserver.mock.results[0].value; + const target = screen.getByTestId('target'); + const observer = mockIntersectionObserver.mock.results[0].value as MockIntersectionObserver; - observerInstance.trigger([{ isIntersecting: true, target: targetElement }]); - expect(intersected).toBe(true); + observer.trigger([{ isIntersecting: true, target }]); - observerInstance.trigger([{ isIntersecting: false, target: targetElement }]); - expect(intersected).toBe(false); + expect(value).toBe(true); + expect(observer.unobserve).toHaveBeenCalledWith(target); }); - it('should disconnect the observer on unmount', () => { - let unmount: () => void; + it('disconnects observer on unmount', () => { + const { unmount } = render(); - act(() => { - ({ unmount } = render()); - }); + const observer = mockIntersectionObserver.mock.results[0].value as MockIntersectionObserver; - const observerInstance = mockIntersectionObserver.mock.results[0].value; + unmount(); - act(() => { - unmount(); - }); - - expect(observerInstance.disconnect).toHaveBeenCalled(); + expect(observer.disconnect).toHaveBeenCalled(); }); - it('should pass correct options to IntersectionObserver', () => { - const options = { - threshold: 0.5, - rootMargin: '10px', - repeat: true, - }; - - act(() => { - render(); - }); + it('passes correct options to IntersectionObserver', () => { + render(); expect(mockIntersectionObserver).toHaveBeenCalledWith(expect.any(Function), { threshold: 0.5, @@ -175,4 +160,13 @@ describe('useIntersectionObserver hook', () => { rootMargin: '10px', }); }); + + it('does nothing if ref is never attached', () => { + const NoRefComponent = () => { + useIntersectionObserver(); + return null; + }; + + expect(() => render()).not.toThrow(); + }); }); diff --git a/src/hooks/useIntersectionObserver/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver/useIntersectionObserver.ts index a7d86f5..ae2a9ab 100644 --- a/src/hooks/useIntersectionObserver/useIntersectionObserver.ts +++ b/src/hooks/useIntersectionObserver/useIntersectionObserver.ts @@ -1,4 +1,4 @@ -import { type RefObject, useEffect, useRef, useState } from 'react'; +import { type RefCallback, useCallback, useEffect, useState } from 'react'; type UseIntersectionObserverProps = IntersectionObserverInit & { repeat?: boolean; @@ -17,28 +17,33 @@ type UseIntersectionObserverProps = IntersectionObserverInit & { * @param {string} [options.rootMargin='0%'] - Margin around the root. Can have values similar to the CSS margin property, e.g. "10px 20px 30px 40px" (top, right, bottom, left). * @param {boolean} [options.repeat=true] - If true, the observer will keep observing the element. If false, the observer will unobserve after the first intersection. Defaults to true. * - * @returns {[RefObject, boolean]} A tuple containing: - * - `ref`: A RefObject to be attached to the DOM element you want to observe. + * @returns {[RefCallback, boolean]} A tuple containing: + * - `ref`: A RefCallback to be attached to the DOM element you want to observe. * - `isIntersecting`: A boolean indicating whether the observed element is currently intersecting with its root. */ export const useIntersectionObserver = ( options: UseIntersectionObserverProps = {} -): [RefObject, boolean] => { +): [RefCallback, boolean] => { const { threshold = 0, root = null, rootMargin = '0%', repeat = true } = options; - const ref = useRef(null); + + const [element, setElement] = useState(null); const [isIntersecting, setIsIntersecting] = useState(false); + const ref = useCallback((node: T | null) => { + setElement(node); + }, []); + useEffect(() => { - const element = ref.current; if (!element) return; const observer = new IntersectionObserver( ([entry]) => { const isVisible = entry.isIntersecting; - setIsIntersecting(isVisible); + + setIsIntersecting(prev => (prev === isVisible ? prev : isVisible)); if (isVisible && !repeat) { - observer.unobserve(element); + observer.unobserve(entry.target); } }, { threshold, root, rootMargin } @@ -49,7 +54,7 @@ export const useIntersectionObserver = ( return () => { observer.disconnect(); }; - }, [threshold, root, rootMargin, repeat]); + }, [element, threshold, root, rootMargin, repeat]); return [ref, isIntersecting]; }; From 7e764769244a4b43f02de8682cb8f5e932feb8a0 Mon Sep 17 00:00:00 2001 From: Donghyun Lee Date: Sat, 17 Jan 2026 20:52:24 +0900 Subject: [PATCH 2/7] refactor: improve 'useAnimateChildren' hook --- .../AnimatedSpan.spec.tsx | 8 +- .../AnimatedSpan.tsx | 6 +- src/hooks/useAnimateChildren/index.ts | 2 + .../useAnimateChildren.spec.tsx | 151 +++++++++ .../useAnimateChildren/useAnimateChildren.tsx | 138 ++++++++ src/hooks/useAnimatedChildren/index.ts | 2 - .../useAnimatedChildren.spec.tsx | 308 ------------------ .../useAnimatedChildren.tsx | 115 ------- .../useTextMotionAnimation.spec.tsx | 12 +- .../useTextMotionAnimation.ts | 6 +- 10 files changed, 307 insertions(+), 441 deletions(-) rename src/hooks/{useAnimatedChildren => useAnimateChildren}/AnimatedSpan.spec.tsx (93%) rename src/hooks/{useAnimatedChildren => useAnimateChildren}/AnimatedSpan.tsx (94%) create mode 100644 src/hooks/useAnimateChildren/index.ts create mode 100644 src/hooks/useAnimateChildren/useAnimateChildren.spec.tsx create mode 100644 src/hooks/useAnimateChildren/useAnimateChildren.tsx delete mode 100644 src/hooks/useAnimatedChildren/index.ts delete mode 100644 src/hooks/useAnimatedChildren/useAnimatedChildren.spec.tsx delete mode 100644 src/hooks/useAnimatedChildren/useAnimatedChildren.tsx diff --git a/src/hooks/useAnimatedChildren/AnimatedSpan.spec.tsx b/src/hooks/useAnimateChildren/AnimatedSpan.spec.tsx similarity index 93% rename from src/hooks/useAnimatedChildren/AnimatedSpan.spec.tsx rename to src/hooks/useAnimateChildren/AnimatedSpan.spec.tsx index ba65816..f5a5180 100644 --- a/src/hooks/useAnimatedChildren/AnimatedSpan.spec.tsx +++ b/src/hooks/useAnimateChildren/AnimatedSpan.spec.tsx @@ -31,11 +31,11 @@ describe('AnimatedSpan component', () => { expect(span).toHaveStyle({ animation: 'fade-in 1s ease-out 0.5s both' }); }); - it('renders
when text is newline character', () => { - const { container } = renderSpan('\n'); + // it('renders
when text is newline character', () => { + // const { container } = renderSpan('\n'); - expect(container.querySelector('br')).toBeInTheDocument(); - }); + // expect(container.querySelector('br')).toBeInTheDocument(); + // }); it('renders empty span when text is empty string', () => { const { span } = renderSpan(''); diff --git a/src/hooks/useAnimatedChildren/AnimatedSpan.tsx b/src/hooks/useAnimateChildren/AnimatedSpan.tsx similarity index 94% rename from src/hooks/useAnimatedChildren/AnimatedSpan.tsx rename to src/hooks/useAnimateChildren/AnimatedSpan.tsx index 098b1a2..c7f0402 100644 --- a/src/hooks/useAnimatedChildren/AnimatedSpan.tsx +++ b/src/hooks/useAnimateChildren/AnimatedSpan.tsx @@ -21,9 +21,9 @@ type Props = { export const AnimatedSpan: FC = ({ text, style, onAnimationEnd }) => { const handleAnimationEnd = useAnimationEndCallback(onAnimationEnd); - if (text === '\n') { - return
; - } + // if (text === '\n') { + // return
; + // } return (