diff --git a/packages/react-aria/src/utils/useId.ts b/packages/react-aria/src/utils/useId.ts index b482902d1fa..7d910deafdd 100644 --- a/packages/react-aria/src/utils/useId.ts +++ b/packages/react-aria/src/utils/useId.ts @@ -30,6 +30,7 @@ if (typeof FinalizationRegistry !== 'undefined') { idsUpdaterMap.delete(heldValue); }); } +let registeredIds = new WeakMap(); /** * If a default is not provided, generate an id. @@ -43,8 +44,13 @@ export function useId(defaultId?: string): string { let res = useSSRSafeId(value); let cleanupRef = useRef(null); - if (registry) { - registry.register(cleanupRef, res); + let registeredId = registeredIds.get(cleanupRef); + if (registry && registeredId !== res) { + if (registeredId != null) { + registry.unregister(cleanupRef); + } + registry.register(cleanupRef, res, cleanupRef); + registeredIds.set(cleanupRef, res); } if (canUseDOM) { @@ -63,6 +69,7 @@ export function useId(defaultId?: string): string { // when it is though, also remove it from the finalization registry. if (registry) { registry.unregister(cleanupRef); + registeredIds.delete(cleanupRef); } idsUpdaterMap.delete(r); }; diff --git a/packages/react-aria/test/utils/useId.test.jsx b/packages/react-aria/test/utils/useId.test.jsx new file mode 100644 index 00000000000..d36bddf8608 --- /dev/null +++ b/packages/react-aria/test/utils/useId.test.jsx @@ -0,0 +1,158 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +describe('useId', function () { + let OriginalFinalizationRegistry = global.FinalizationRegistry; + + afterEach(() => { + global.FinalizationRegistry = OriginalFinalizationRegistry; + jest.resetModules(); + }); + + it('registers once per mounted id and uses an unregister token', function () { + let register = jest.fn(); + let unregister = jest.fn(); + + global.FinalizationRegistry = jest.fn(function () { + this.register = register; + this.unregister = unregister; + }); + + jest.isolateModules(() => { + let React = require('react'); + let ReactDOM = require('react-dom'); + let {act} = require('react-dom/test-utils'); + let {useId} = require('../../src/utils/useId'); + let isReact18OrHigher = parseInt(React.version, 10) >= 18; + + function Test({tick}) { + let id = useId(); + return React.createElement('div', {'data-id': id}, tick); + } + + let container = document.createElement('div'); + document.body.appendChild(container); + + // Use createRoot for React 18+ and ReactDOM.render for older + let renderElement; + let unmount; + if (isReact18OrHigher) { + let {createRoot} = require('react-dom/client'); + global.IS_REACT_ACT_ENVIRONMENT = true; + let root = createRoot(container); + renderElement = el => root.render(el); + unmount = () => root.unmount(); + } else { + renderElement = el => ReactDOM.render(el, container); + unmount = () => ReactDOM.unmountComponentAtNode(container); + } + + act(() => { + renderElement(React.createElement(Test, {tick: 0})); + }); + + act(() => { + renderElement(React.createElement(Test, {tick: 1})); + }); + + act(() => { + renderElement(React.createElement(Test, {tick: 2})); + }); + + // Re-rendering the same mounted hook should not add more registry entries. + expect(register).toHaveBeenCalledTimes(1); + // The held value should be the generated id string, and the unregister token should match + // the target object so useId can remove the same registration during cleanup. + expect(register.mock.calls[0][1]).toEqual(expect.any(String)); + expect(register.mock.calls[0][2]).toBe(register.mock.calls[0][0]); + + act(() => { + unmount(); + }); + + document.body.removeChild(container); + + // Unmount should remove the specific registration created for this hook instance. + expect(unregister).toHaveBeenCalledTimes(1); + expect(unregister).toHaveBeenCalledWith(register.mock.calls[0][2]); + }); + }); + + it('unregisters the previous id and re-registers when the id changes', function () { + let register = jest.fn(); + let unregister = jest.fn(); + + global.FinalizationRegistry = jest.fn(function () { + this.register = register; + this.unregister = unregister; + }); + + jest.isolateModules(() => { + let React = require('react'); + let ReactDOM = require('react-dom'); + let {act} = require('react-dom/test-utils'); + let {useId, mergeIds} = require('../../src/utils/useId'); + let isReact18OrHigher = parseInt(React.version, 10) >= 18; + + function Test({tick}) { + let id = useId(); + return React.createElement('div', {'data-id': id}, tick); + } + + let container = document.createElement('div'); + document.body.appendChild(container); + + let renderElement; + let unmount; + if (isReact18OrHigher) { + let {createRoot} = require('react-dom/client'); + global.IS_REACT_ACT_ENVIRONMENT = true; + let root = createRoot(container); + renderElement = el => root.render(el); + unmount = () => root.unmount(); + } else { + renderElement = el => ReactDOM.render(el, container); + unmount = () => ReactDOM.unmountComponentAtNode(container); + } + + act(() => { + renderElement(React.createElement(Test, {tick: 0})); + }); + + expect(register).toHaveBeenCalledTimes(1); + let firstToken = register.mock.calls[0][2]; + // The held value passed to FinalizationRegistry.register is the id string. + let firstId = register.mock.calls[0][1]; + + // mergeIds sets the internal nextId ref and the next render's useEffect flushes that + // through setValue, producing a new res and exercising the id-change branch. + act(() => { + mergeIds(firstId, 'changed-id'); + renderElement(React.createElement(Test, {tick: 1})); + }); + + // The previous registration should be unregistered with the original token, and the new id + // should be registered once. + expect(unregister).toHaveBeenCalledWith(firstToken); + expect(register).toHaveBeenCalledTimes(2); + expect(register.mock.calls[1][1]).toBe('changed-id'); + // Re-using the same target object means the WeakMap key is stable across re-registrations. + expect(register.mock.calls[1][0]).toBe(register.mock.calls[0][0]); + + act(() => { + unmount(); + }); + + document.body.removeChild(container); + }); + }); +});