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
Provide a general summary of the issue here
useIdin@react-aria/utilscallsregistry.register(cleanupRef, res)unconditionally in the render body on every render. SinceFinalizationRegistry.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 callsuseId(e.g.useMenuTrigger) inherits this leak.🤔 Expected Behavior?
FinalizationRegistry.register()should only be called once per component mount (or whenreschanges), 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 ofuseId.ts. WhilecleanupRefis the same ref object across renders (viauseRef),FinalizationRegistry.register()adds a new entry each time regardless. Theregistry.unregister(cleanupRef)in the layout effect cleanup only runs on unmount or whenreschanges — not between re-renders — so entries accumulate unboundedly.Additionally,
registry.register(cleanupRef, res)is called with only 2 arguments (no unregister token), butregistry.unregister(cleanupRef)attempts to usecleanupRefas 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 sounregister()works correctly:🔦 Context
No response
🖥️ Steps to Reproduce
Minimal reproduction component:
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