Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tangy-items-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vapor-ui/core': patch
---

Change useMutationObserver hook to use only ref
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { NavigationMenu as BaseNavigationMenu } from '@base-ui-components/react'
import { ChevronDownOutlineIcon } from '@vapor-ui/icons';
import clsx from 'clsx';

import { useMutationObserver } from '~/hooks/use-mutation-observer';
import { useMutationObserverRef } from '~/hooks/use-mutation-observer-ref';
import { createContext } from '~/libs/create-context';
import { createSlot } from '~/libs/create-slot';
import { vars } from '~/styles/themes.css';
Expand Down Expand Up @@ -296,7 +296,7 @@ export const NavigationMenuPopupPrimitive = forwardRef<
if (initialAlign) setAlign(initialAlign);
}, []);

const arrowRef = useMutationObserver<HTMLDivElement>({
const arrowRef = useMutationObserverRef<HTMLDivElement>({
callback: (mutations) => {
mutations.forEach((mutation) => {
const { attributeName, target: mutationTarget } = mutation;
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/components/popover/popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
import { Popover as BasePopover } from '@base-ui-components/react/popover';
import clsx from 'clsx';

import { useMutationObserver } from '~/hooks/use-mutation-observer';
import { useMutationObserverRef } from '~/hooks/use-mutation-observer-ref';
import { createSlot } from '~/libs/create-slot';
import { vars } from '~/styles/themes.css';
import { composeRefs } from '~/utils/compose-refs';
Expand Down Expand Up @@ -113,7 +113,7 @@ export const PopoverPopupPrimitive = forwardRef<HTMLDivElement, PopoverPopupPrim
if (initialAlign) setAlign(initialAlign);
}, []);

const arrowRef = useMutationObserver<HTMLDivElement>({
const arrowRef = useMutationObserverRef<HTMLDivElement>({
callback: (mutations) => {
mutations.forEach((mutation) => {
const { attributeName, target: mutationTarget } = mutation;
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/components/tooltip/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
import { Tooltip as BaseTooltip } from '@base-ui-components/react/tooltip';
import clsx from 'clsx';

import { useMutationObserver } from '~/hooks/use-mutation-observer';
import { useMutationObserverRef } from '~/hooks/use-mutation-observer-ref';
import { createSlot } from '~/libs/create-slot';
import { vars } from '~/styles/themes.css';
import { composeRefs } from '~/utils/compose-refs';
Expand Down Expand Up @@ -111,7 +111,7 @@ export const TooltipPopupPrimitive = forwardRef<HTMLDivElement, TooltipPopupPrim
if (initialAlign) setAlign(initialAlign);
}, []);

const arrowRef = useMutationObserver<HTMLDivElement>({
const arrowRef = useMutationObserverRef<HTMLDivElement>({
callback: (mutations) => {
mutations.forEach((mutation) => {
const { attributeName, target: mutationTarget } = mutation;
Expand Down
201 changes: 201 additions & 0 deletions packages/core/src/hooks/use-mutation-observer-ref.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { render, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

import { useMutationObserverRef } from './use-mutation-observer-ref';

describe('useMutationObserverRef', () => {
it('should observe mutations on the attached element', async () => {
const callback = vi.fn();
let testRef = null as HTMLDivElement | null;

function TestComponent() {
const ref = useMutationObserverRef<HTMLDivElement>({
callback,
options: { attributes: true, attributeFilter: ['data-test'] },
});

return (
<div
ref={(node) => {
testRef = node;
ref(node);
}}
data-test="initial"
>
Test Content
</div>
);
}

render(<TestComponent />);

// Wait for the component to mount and observer to be set up
await waitFor(() => expect(testRef).not.toBeNull());

// Trigger a mutation
testRef!.setAttribute('data-test', 'changed');

// Wait for the callback to be called
await waitFor(() => {
expect(callback).toHaveBeenCalled();
});

expect(callback.mock.calls[0][0]).toHaveLength(1);
expect(callback.mock.calls[0][0][0].type).toBe('attributes');
expect(callback.mock.calls[0][0][0].attributeName).toBe('data-test');
});

it('should disconnect observer when ref is set to null', async () => {
const callback = vi.fn();

function TestComponent({ show }: { show: boolean }) {
const ref = useMutationObserverRef<HTMLDivElement>({
callback,
options: { attributes: true },
});

if (!show) return null;

return (
<div ref={ref} data-test="value">
Test Content
</div>
);
}

const { rerender } = render(<TestComponent show={true} />);

// Unmount the component
rerender(<TestComponent show={false} />);

// The observer should be disconnected, so no further mutations should be observed
await waitFor(() => {
expect(callback).not.toHaveBeenCalled();
});
});

it('should update to the latest callback on each render', async () => {
const firstCallback = vi.fn();
const secondCallback = vi.fn();
let testRef = null as HTMLDivElement | null;

function TestComponent({ callback }: { callback: (mutations: MutationRecord[]) => void }) {
const ref = useMutationObserverRef<HTMLDivElement>({
callback,
options: { attributes: true, attributeFilter: ['data-test'] },
});

return (
<div
ref={(node) => {
testRef = node;
ref(node);
}}
data-test="initial"
>
Test Content
</div>
);
}

const { rerender } = render(<TestComponent callback={firstCallback} />);

await waitFor(() => expect(testRef).not.toBeNull());

// Trigger first mutation
testRef!.setAttribute('data-test', 'changed1');

await waitFor(() => {
expect(firstCallback).toHaveBeenCalled();
});

// Clear the first callback and switch to second
firstCallback.mockClear();
rerender(<TestComponent callback={secondCallback} />);

// Trigger second mutation
testRef!.setAttribute('data-test', 'changed2');

await waitFor(() => {
expect(secondCallback).toHaveBeenCalled();
});

// First callback should not have been called again
expect(firstCallback).not.toHaveBeenCalled();
});

it('should observe child list mutations when configured', async () => {
const callback = vi.fn();
let testRef = null as HTMLDivElement | null;

function TestComponent() {
const ref = useMutationObserverRef<HTMLDivElement>({
callback,
options: { childList: true },
});

return (
<div
ref={(node) => {
testRef = node;
ref(node);
}}
>
<span>Initial Child</span>
</div>
);
}

render(<TestComponent />);

await waitFor(() => expect(testRef).not.toBeNull());

// Add a new child
const newChild = document.createElement('span');
newChild.textContent = 'New Child';
testRef!.appendChild(newChild);

await waitFor(() => {
expect(callback).toHaveBeenCalled();
});

expect(callback.mock.calls[0][0][0].type).toBe('childList');
});

it('should return cleanup function that disconnects observer (React 19+ support)', async () => {
const callback = vi.fn();
let cleanupFn: (() => void) | undefined;

function TestComponent() {
const ref = useMutationObserverRef<HTMLDivElement>({
callback,
options: { attributes: true },
});

return (
<div
ref={(node) => {
const cleanup = ref(node);
// Store cleanup function if returned (React 19+)
if (typeof cleanup === 'function') {
cleanupFn = cleanup;
}
}}
data-test="initial"
>
Test Content
</div>
);
}

const { unmount } = render(<TestComponent />);

// If cleanup function was returned, it should work
if (cleanupFn) {
cleanupFn();
expect(callback).not.toHaveBeenCalled();
}

unmount();
});
});
Comment on lines +165 to +201
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

should return cleanup function that disconnects observer (React 19+ support) 테스트 케이스가 정리 함수의 동작을 올바르게 검증하지 못하고 있습니다.

현재 테스트는 cleanupFn()을 호출한 직후 callback이 호출되지 않았는지만 확인합니다. 하지만 이 시점에는 어떠한 DOM 변경도 발생하지 않았기 때문에 callback은 원래 호출되지 않는 것이 정상입니다.

정리 함수가 observer를 제대로 disconnect하는지 확인하려면, cleanupFn() 호출 이후에 DOM 변경을 발생시키고, 그 결과로 callback이 호출되지 않는 것을 확인해야 합니다.

아래와 같이 테스트를 수정하여 정리 함수의 동작을 더 정확하게 검증하는 것을 제안합니다.

    it('should return cleanup function that disconnects observer (React 19+ support)', async () => {
        const callback = vi.fn();
        let cleanupFn: (() => void) | undefined;
        let testRef: HTMLDivElement | null = null;

        function TestComponent() {
            const ref = useMutationObserverRef<HTMLDivElement>({
                callback,
                options: { attributes: true, attributeFilter: ['data-test'] },
            });

            return (
                <div
                    ref={(node) => {
                        testRef = node;
                        const cleanup = ref(node);
                        // React 19+에서는 정리 함수가 반환될 수 있습니다.
                        if (typeof cleanup === 'function') {
                            cleanupFn = cleanup;
                        }
                    }}
                    data-test="initial"
                >
                    Test Content
                </div>
            );
        }

        const { unmount } = render(<TestComponent />);
        await waitFor(() => expect(testRef).not.toBeNull());

        // 정리 함수가 반환되었다면 (React 19+), 호출하여 observer 연결을 끊습니다.
        if (cleanupFn) {
            cleanupFn();

            // 정리 함수 호출 후에 DOM 변경을 트리거합니다.
            testRef?.setAttribute('data-test', 'changed-after-cleanup');

            // 비동기 콜백이 실행될 수 있는 시간을 잠시 줍니다.
            await new Promise((resolve) => setTimeout(resolve, 50));

            // observer 연결이 끊겼으므로 콜백은 호출되지 않아야 합니다.
            expect(callback).not.toHaveBeenCalled();
        }

        unmount();
    });

67 changes: 67 additions & 0 deletions packages/core/src/hooks/use-mutation-observer-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useCallback, useRef } from 'react';

interface Params {
callback: (mutations: MutationRecord[]) => void;
options?: MutationObserverInit;
}

/**
* A hook that returns a callback ref which sets up a MutationObserver
* when attached to a DOM element.
*
* This implementation uses callback refs instead of useEffect to avoid
* issues with stale dependencies and unnecessary observer recreation.
* It stores the callback and options in refs to maintain stable references
* across renders, ensuring compatibility with React 17+.
*
* For React 19+, the callback ref returns a cleanup function that will
* automatically disconnect the observer when the ref changes or unmounts.
*
* @see https://tkdodo.eu/blog/ref-callbacks-react-19-and-the-compiler
*
* @example
* const ref = useMutationObserverRef({
* callback: (mutations) => console.log(mutations),
* options: { attributes: true }
* });
*
* return <div ref={ref}>Content</div>;
*/
export const useMutationObserverRef = <T extends HTMLElement>({ callback, options }: Params) => {
const observerRef = useRef<MutationObserver | null>(null);
const callbackRef = useRef(callback);
const optionsRef = useRef(options);

// Keep refs up to date with latest values
callbackRef.current = callback;
optionsRef.current = options;

const refCallback = useCallback((node: T | null) => {
// Cleanup previous observer if it exists
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}

// Set up new observer if node is present
if (node) {
// Use the refs to get the latest callback and options
const observer = new MutationObserver((mutations) => {
callbackRef.current(mutations);
});
observer.observe(node, optionsRef.current);
observerRef.current = observer;

// Return cleanup function for React 19+
// For React 17-18, this return value is ignored
return () => {
observer.disconnect();
if (observerRef.current === observer) {
observerRef.current = null;
}
};
}
}, []); // Empty dependency array - stable across all renders

return refCallback;
};
22 changes: 0 additions & 22 deletions packages/core/src/hooks/use-mutation-observer.ts

This file was deleted.

Loading