Repository: floating-ui/floating-ui (TypeScript)

Issue:
[React 19] cleanup function support in `useMergeRefs` hook
React 19 is now stable.

One of the changes in react 19 was that ref callbacks can now return a cleanup function[^1]. This does not work with the [`useMergeRefs` hook](https://github.com/floating-ui/floating-ui/blob/master/packages/react/src/hooks/useMergeRefs.ts) yet.

Here is an example implementation that does support them: https://github.com/vercel/next.js/blob/canary/packages/next/src/client/use-merged-ref.ts

[^1]: see https://react.dev/blog/2024/12/05/react-19#cleanup-functions-for-refs


Discussion:

Patch:
diff --git a/.changeset/tiny-bags-brake.md b/.changeset/tiny-bags-brake.md
new file mode 100644
index 0000000000..c1d69e3e89
--- /dev/null
+++ b/.changeset/tiny-bags-brake.md
@@ -0,0 +1,5 @@
+---
+"@floating-ui/react": patch
+---
+
+fix(useMergeRefs): support React 19 ref cleanup
diff --git a/packages/react/src/hooks/useMergeRefs.ts b/packages/react/src/hooks/useMergeRefs.ts
index 15ee3e6b6b..e5ce7f79f6 100644
--- a/packages/react/src/hooks/useMergeRefs.ts
+++ b/packages/react/src/hooks/useMergeRefs.ts
@@ -6,20 +6,53 @@ import * as React from 'react';
  */
 export function useMergeRefs<Instance>(
   refs: Array<React.Ref<Instance> | undefined>,
-): React.RefCallback<Instance> | null {
+): null | React.RefCallback<Instance> {
+  const cleanupRef = React.useRef<void | (() => void)>(undefined);
+
+  const refEffect = React.useCallback((instance: Instance | null) => {
+    const cleanups = refs.map((ref) => {
+      if (ref == null) {
+        return;
+      }
+
+      if (typeof ref === 'function') {
+        const refCallback = ref;
+        const refCleanup: void | (() => void) = refCallback(instance);
+        return typeof refCleanup === 'function'
+          ? refCleanup
+          : () => {
+              refCallback(null);
+            };
+      }
+
+      (ref as React.MutableRefObject<Instance | null>).current = instance;
+      return () => {
+        (ref as React.MutableRefObject<Instance | null>).current = null;
+      };
+    });
+
+    return () => {
+      cleanups.forEach((refCleanup) => refCleanup?.());
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, refs);
+
   return React.useMemo(() => {
     if (refs.every((ref) => ref == null)) {
       return null;
     }
 
     return (value) => {
-      refs.forEach((ref) => {
-        if (typeof ref === 'function') {
-          ref(value);
-        } else if (ref != null) {
-          (ref as React.MutableRefObject<Instance | null>).current = value;
-        }
-      });
+      if (cleanupRef.current) {
+        cleanupRef.current();
+        (cleanupRef as React.MutableRefObject<void | (() => void)>).current =
+          undefined;
+      }
+
+      if (value != null) {
+        (cleanupRef as React.MutableRefObject<void | (() => void)>).current =
+          refEffect(value);
+      }
     };
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, refs);


Test patch:
diff --git a/packages/react/test/unit/useMergeRefs.test.tsx b/packages/react/test/unit/useMergeRefs.test.tsx
index d9d0f1bbb4..0bc4919da9 100644
--- a/packages/react/test/unit/useMergeRefs.test.tsx
+++ b/packages/react/test/unit/useMergeRefs.test.tsx
@@ -63,3 +63,54 @@ test('conditional refs', () => {
   expect(callbackSpy2.mock.calls.length).toBe(1);
   expect(callbackSpy3.mock.calls[0][0]?.id).toBe('test');
 });
+
+test('calls clean up function if it exists', () => {
+  const cleanUp = vi.fn();
+  const setup = vi.fn();
+  const setup2 = vi.fn();
+  const nullHandler = vi.fn();
+
+  function onRefChangeWithCleanup(ref: HTMLDivElement | null) {
+    if (ref) {
+      setup(ref.id);
+    } else {
+      nullHandler();
+    }
+    return cleanUp;
+  }
+
+  function onRefChangeWithoutCleanup(ref: HTMLDivElement | null) {
+    if (ref) {
+      setup2(ref.id);
+    } else {
+      nullHandler();
+    }
+  }
+
+  function App() {
+    const ref = useMergeRefs([
+      onRefChangeWithCleanup,
+      onRefChangeWithoutCleanup,
+    ]);
+    return <div id="test" ref={ref} />;
+  }
+
+  const {unmount} = render(<App />);
+
+  expect(setup).toHaveBeenCalledWith('test');
+  expect(setup).toHaveBeenCalledTimes(1);
+  expect(cleanUp).toHaveBeenCalledTimes(0);
+
+  expect(setup2).toHaveBeenCalledWith('test');
+  expect(setup2).toHaveBeenCalledTimes(1);
+
+  unmount();
+
+  expect(setup).toHaveBeenCalledTimes(1);
+  expect(cleanUp).toHaveBeenCalledTimes(1);
+
+  // Setup was not called again
+  expect(setup2).toHaveBeenCalledTimes(1);
+  // Null handler hit because no cleanup is returned
+  expect(nullHandler).toHaveBeenCalledTimes(1);
+});