From 6f4adf207872117128415579ca0b6908e19152b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= <25958801+nwidynski@users.noreply.github.com> Date: Thu, 24 Jul 2025 22:39:00 +0200 Subject: [PATCH 1/2] Feat: Add support for `cleanupCallback` to `useId` --- packages/@react-aria/utils/src/useId.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/utils/src/useId.ts b/packages/@react-aria/utils/src/useId.ts index cbe8bfaee0d..aae792800de 100644 --- a/packages/@react-aria/utils/src/useId.ts +++ b/packages/@react-aria/utils/src/useId.ts @@ -26,10 +26,11 @@ export let idsUpdaterMap: Map = new Map(); // This allows us to clean up the idsUpdaterMap when the id is no longer used. // Map is a strong reference, so unused ids wouldn't be cleaned up otherwise. // This can happen in suspended components where mount/unmount is not called. -let registry; +let registry: FinalizationRegistry<[string, (id: string) => void]> | undefined; if (typeof FinalizationRegistry !== 'undefined') { - registry = new FinalizationRegistry((heldValue) => { - idsUpdaterMap.delete(heldValue); + registry = new FinalizationRegistry(([id, cleanupCallback]) => { + idsUpdaterMap.delete(id); + cleanupCallback(id); }); } @@ -37,15 +38,16 @@ if (typeof FinalizationRegistry !== 'undefined') { * If a default is not provided, generate an id. * @param defaultId - Default component id. */ -export function useId(defaultId?: string): string { +export function useId(defaultId?: string, cleanupCallback?: (id: string) => void): string { let [value, setValue] = useState(defaultId); let nextId = useRef(null); let res = useSSRSafeId(value); let cleanupRef = useRef(null); + let cleanup = useCallback((id: string) => cleanupCallback?.(id), [cleanupCallback]); if (registry) { - registry.register(cleanupRef, res); + registry.register(cleanupRef, [res, cleanup]); } if (canUseDOM) { @@ -66,8 +68,9 @@ export function useId(defaultId?: string): string { registry.unregister(cleanupRef); } idsUpdaterMap.delete(r); + cleanup(r); }; - }, [res]); + }, [res, cleanup]); // This cannot cause an infinite loop because the ref is always cleaned up. // eslint-disable-next-line From b07924cc192666dd061b7b5e50798c9007f6c04e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolas=20Schr=C3=B6ter?= <25958801+nwidynski@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:53:27 +0100 Subject: [PATCH 2/2] chore: review Co-authored-by: Robert Snow --- packages/@react-aria/utils/src/useId.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@react-aria/utils/src/useId.ts b/packages/@react-aria/utils/src/useId.ts index aae792800de..3fb4764e4aa 100644 --- a/packages/@react-aria/utils/src/useId.ts +++ b/packages/@react-aria/utils/src/useId.ts @@ -37,6 +37,7 @@ if (typeof FinalizationRegistry !== 'undefined') { /** * If a default is not provided, generate an id. * @param defaultId - Default component id. + * @param cleanupCallback - A callback for when the id is no longer in use. Useful because id's don't always go out of use due to unmounting as it may have originated from a parent component. */ export function useId(defaultId?: string, cleanupCallback?: (id: string) => void): string { let [value, setValue] = useState(defaultId);