Skip to content

useId leaks FinalizationRegistry entries on every re-render #9852

@smitev

Description

@smitev

Provide a general summary of the issue here

useId in @react-aria/utils calls registry.register(cleanupRef, res) unconditionally in the render body on every render. Since FinalizationRegistry.register() adds a new entry each time it's called (even with the same target), this creates a new registry entry per render that is never cleaned up until the component unmounts. Any hook that internally calls useId (e.g. useMenuTrigger) inherits this leak.

🤔 Expected Behavior?

FinalizationRegistry.register() should only be called once per component mount (or when res changes), not on every re-render. Re-renders with the same target and held value should be a no-op.

😯 Current Behavior

Every re-render calls registry.register(cleanupRef, res) at line 48 of useId.ts. While cleanupRef is the same ref object across renders (via useRef), FinalizationRegistry.register() adds a new entry each time regardless. The registry.unregister(cleanupRef) in the layout effect cleanup only runs on unmount or when res changes — not between re-renders — so entries accumulate unboundedly.

Additionally, registry.register(cleanupRef, res) is called with only 2 arguments (no unregister token), but registry.unregister(cleanupRef) attempts to use cleanupRef as the unregister token. Per the spec, if no unregister token was provided during registration, unregister() has no effect — meaning even the unmount cleanup may not actually remove entries.

💁 Possible Solution

Guard the register() call with a ref so it only runs once, and pass the unregister token as the third argument so unregister() works correctly:

export function useId(defaultId?: string): string {
  let [value, setValue] = useState(defaultId);
  let nextId = useRef(null);

  let res = useSSRSafeId(value);
  let cleanupRef = useRef(null);
+ let registeredRef = useRef(false);

  if (registry) {
-   registry.register(cleanupRef, res);
+   if (!registeredRef.current) {
+     registry.register(cleanupRef, res, cleanupRef);
+     registeredRef.current = true;
+   }
  }

  // ...

  useLayoutEffect(() => {
    let r = res;
    return () => {
      if (registry) {
        registry.unregister(cleanupRef);
      }
+     registeredRef.current = false;
      idsUpdaterMap.delete(r);
    };
  }, [res]);

🔦 Context

No response

🖥️ Steps to Reproduce

Minimal reproduction component:

import { useMenuTrigger } from "@react-aria/menu";
import { useMenuTriggerState } from "@react-stately/menu";
import { useEffect, useRef, useState } from "react";

const LeakChild = ({ tick }: { tick: number }) => {
  const state = useMenuTriggerState({});
  const ref = useRef(null);
  const { menuTriggerProps } = useMenuTrigger({}, state, ref);
  return (
    <div ref={ref} style={{ display: "none" }} {...menuTriggerProps}>
      {tick}
    </div>
  );
};

const LeakProof = () => {
  const [tick, setTick] = useState(0);
  const [running, setRunning] = useState(true);

  useEffect(() => {
    if (!running) return;
    const id = setInterval(() => setTick((t) => t + 1), 10);
    return () => clearInterval(id);
  }, [running]);

  return (
    <div>
      <button onClick={() => setRunning((r) => !r)}>
        {running ? "Stop" : "Start"} ({tick} renders)
      </button>
      <div style={{ display: "none" }}>
        {Array.from({ length: 200 }, (_, i) => (
          <LeakChild key={i} tick={tick} />
        ))}
      </div>
    </div>
  );
};

Version

@react-aria/utils v3.33.1

What browsers are you seeing the problem on?

Chrome

If other, please specify.

No response

What operating system are you using?

macOS Tahoe 26.4

🧢 Your Company/Team

No response

🕷 Tracking Issue

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions