Skip to content

fix(useId): prevent FinalizationRegistry entry leak in useId#9853

Open
reidbarber wants to merge 5 commits into
mainfrom
fix-useid-leak
Open

fix(useId): prevent FinalizationRegistry entry leak in useId#9853
reidbarber wants to merge 5 commits into
mainfrom
fix-useid-leak

Conversation

@reidbarber
Copy link
Copy Markdown
Member

Closes #9852

Fixes useId so FinalizationRegistry entries do not accumulate on every re-render. Previously, they would until the component was unmounted.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

Unit test should cover.

🧢 Your Project:

@reidbarber reidbarber changed the title Fix useid leak fix(useId): avoid FinalizationRegistry entry leak in useId Mar 27, 2026
@reidbarber reidbarber changed the title fix(useId): avoid FinalizationRegistry entry leak in useId fix(useId): prevent FinalizationRegistry entry leak in useId Mar 27, 2026
Comment thread packages/react-aria/test/utils/useId.test.jsx
Comment thread packages/react-aria/src/utils/useId.ts
@rspbot
Copy link
Copy Markdown

rspbot commented Mar 27, 2026

snowystinger
snowystinger previously approved these changes Mar 28, 2026
}
registry.register(cleanupRef, res, cleanupRef);
registeredIds.set(cleanupRef, res);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it work to do this in an effect instead of during render? Then it would be easier to do cleanup

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we need the map in mergeIds during render?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need the map, but not necessarily the finalization registry. or are you saying in a case where a component rendered, registered an id in the map, but never actually mounted?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't that scenario be rather common in React 19+, since a hidden Activity subtree may never reveal?

@rspbot
Copy link
Copy Markdown

rspbot commented May 18, 2026

@rspbot
Copy link
Copy Markdown

rspbot commented May 18, 2026

## API Changes

react-aria-components

/react-aria-components:TreeRenderProps

 TreeRenderProps {
   allowsDragging: boolean
-  isDropTarget: boolean
   isEmpty: boolean
   isFocusVisible: boolean
   isFocused: boolean
   selectionMode: SelectionMode
 }

/react-aria-components:TreeEmptyStateRenderProps

 TreeEmptyStateRenderProps {
   allowsDragging: boolean
-  isDropTarget: boolean
   isFocusVisible: boolean
   isFocused: boolean
   selectionMode: SelectionMode
   state: TreeState<unknown>

@react-aria/grid

/@react-aria/grid:GridKeyboardDelegate

 GridKeyboardDelegate <C extends IGridCollection<T>, T> {
   collection: IGridCollection<T>
   constructor: (GridKeyboardDelegateOptions<IGridCollection<T>>) => void
   getFirstKey: (Key, boolean) => Key | null
-  getKeyAbove: (Key, {
-    includeDisabled?: boolean
-}) => Key | null
-  getKeyBelow: (Key, {
-    includeDisabled?: boolean
-}) => Key | null
+  getKeyAbove: (Key) => Key | null
+  getKeyBelow: (Key) => Key | null
   getKeyForSearch: (string, Key) => Key | null
   getKeyLeftOf: (Key) => Key | null
   getKeyPageAbove: (Key) => Key | null
   getKeyPageBelow: (Key) => Key | null
   getLastKey: (Key, boolean) => Key | null
 }

@react-aria/selection

/@react-aria/selection:ListKeyboardDelegate

 ListKeyboardDelegate <T> {
   constructor: (Array<any>) => void
   getFirstKey: () => Key | null
-  getKeyAbove: (Key, {
-    includeDisabled?: boolean
-}) => Key | null
-  getKeyBelow: (Key, {
-    includeDisabled?: boolean
-}) => Key | null
+  getKeyAbove: (Key) => Key | null
+  getKeyBelow: (Key) => Key | null
   getKeyForSearch: (string, Key) => Key | null
-  getKeyLeftOf: (Key, {
-    includeDisabled?: boolean
-}) => Key | null
+  getKeyLeftOf: (Key) => Key | null
   getKeyPageAbove: (Key) => Key | null
   getKeyPageBelow: (Key) => Key | null
-  getKeyRightOf: (Key, {
-    includeDisabled?: boolean
-}) => Key | null
+  getKeyRightOf: (Key) => Key | null
   getLastKey: () => Key | null
-  getNextKey: (Key, {
-    includeDisabled?: boolean
-}) => Key | null
-  getPreviousKey: (Key, {
-    includeDisabled?: boolean
-}) => Key | null
+  getNextKey: (Key) => Key | null
+  getPreviousKey: (Key) => Key | null
 }

@react-spectrum/s2

/@react-spectrum/s2:ListView

 ListView <T extends {}> {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children: ReactNode | ({}) => ReactNode
   defaultSelectedKeys?: 'all' | Iterable<Key>
   dependencies?: ReadonlyArray<any>
   disabledBehavior?: DisabledBehavior = 'all'
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   disallowTypeAhead?: boolean = false
-  dragAndDropHooks?: DragAndDropHooks<NoInfer<T>>
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   hideLinkOutIcon?: boolean
   id?: string
   isQuiet?: boolean
   loadingState?: LoadingState
   onAction?: (Key) => void
   onLoadMore?: () => void
   onSelectionChange?: (Selection) => void
   overflowMode?: 'wrap' | 'truncate' = 'truncate'
   renderActionBar?: ('all' | Set<Key>) => ReactElement
   renderEmptyState?: (GridListRenderProps) => ReactNode
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
   selectionStyle?: 'highlight' | 'checkbox' = 'checkbox'
   shouldSelectOnPressUp?: boolean
   slot?: string | null
   styles?: StylesPropWithHeight
 }

/@react-spectrum/s2:DragPreview

-DragPreview {
-  children?: ReactNode
-  items: Array<DragItem>
-  overflowMode?: 'wrap' | 'truncate'
-}

/@react-spectrum/s2:TableView

 TableView {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children?: ReactNode
   defaultExpandedKeys?: Iterable<Key>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   density?: 'compact' | 'spacious' | 'regular' = 'regular'
   disabledBehavior?: DisabledBehavior = 'all'
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
-  dragAndDropHooks?: DragAndDropHooks
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   expandedKeys?: Iterable<Key>
   id?: string
   isQuiet?: boolean
   onAction?: (Key) => void
   onExpandedChange?: (Set<Key>) => any
   onLoadMore?: () => any
   onResize?: (Map<Key, ColumnSize>) => void
   onResizeEnd?: (Map<Key, ColumnSize>) => void
   onResizeStart?: (Map<Key, ColumnSize>) => void
   onSelectionChange?: (Selection) => void
   onSortChange?: (SortDescriptor) => any
   overflowMode?: 'wrap' | 'truncate' = 'truncate'
   renderActionBar?: ('all' | Set<Key>) => ReactElement
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
   shouldSelectOnPressUp?: boolean
   slot?: string | null
   sortDescriptor?: SortDescriptor
   styles?: StylesPropWithHeight
   treeColumn?: Key
 }

/@react-spectrum/s2:TreeView

 TreeView <T extends {}> {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children?: ReactNode | (T) => ReactNode
   defaultExpandedKeys?: Iterable<Key>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   dependencies?: ReadonlyArray<any>
   disabledBehavior?: DisabledBehavior = 'all'
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
-  dragAndDropHooks?: DragAndDropHooks<NoInfer<T>>
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   expandedKeys?: Iterable<Key>
   id?: string
   items?: Iterable<T>
   onExpandedChange?: (Set<Key>) => any
   onSelectionChange?: (Selection) => void
   renderActionBar?: ('all' | Set<Key>) => ReactElement
   renderEmptyState?: (TreeEmptyStateRenderProps) => ReactNode
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
   selectionStyle?: 'highlight' | 'checkbox' = 'checkbox'
   shouldSelectOnPressUp?: boolean
   slot?: string | null
   styles?: StylesPropWithHeight
 }

/@react-spectrum/s2:DIRECTORY_DRAG_TYPE

-DIRECTORY_DRAG_TYPE {
-  UNTYPED
-}

/@react-spectrum/s2:isDirectoryDropItem

-isDirectoryDropItem {
-  dropItem: DropItem
-  returnVal: undefined
-}

/@react-spectrum/s2:isFileDropItem

-isFileDropItem {
-  dropItem: DropItem
-  returnVal: undefined
-}

/@react-spectrum/s2:isTextDropItem

-isTextDropItem {
-  dropItem: DropItem
-  returnVal: undefined
-}

/@react-spectrum/s2:useDragAndDrop

-useDragAndDrop <T = {}> {
-  options: DragAndDropOptions<T>
-  returnVal: undefined
-}

/@react-spectrum/s2:ListViewProps

 ListViewProps <T> {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children: ReactNode | (T) => ReactNode
   defaultSelectedKeys?: 'all' | Iterable<Key>
   dependencies?: ReadonlyArray<any>
   disabledBehavior?: DisabledBehavior = 'all'
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   disallowTypeAhead?: boolean = false
-  dragAndDropHooks?: DragAndDropHooks<NoInfer<T>>
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   hideLinkOutIcon?: boolean
   id?: string
   isQuiet?: boolean
   loadingState?: LoadingState
   onAction?: (Key) => void
   onLoadMore?: () => void
   onSelectionChange?: (Selection) => void
   overflowMode?: 'wrap' | 'truncate' = 'truncate'
   renderActionBar?: ('all' | Set<Key>) => ReactElement
   renderEmptyState?: (GridListRenderProps) => ReactNode
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
   selectionStyle?: 'highlight' | 'checkbox' = 'checkbox'
   shouldSelectOnPressUp?: boolean
   slot?: string | null
   styles?: StylesPropWithHeight
 }

/@react-spectrum/s2:DragPreviewProps

-DragPreviewProps {
-  children?: ReactNode
-  items: Array<DragItem>
-  overflowMode?: 'wrap' | 'truncate'
-}

/@react-spectrum/s2:TableViewProps

 TableViewProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children?: ReactNode
   defaultExpandedKeys?: Iterable<Key>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   density?: 'compact' | 'spacious' | 'regular' = 'regular'
   disabledBehavior?: DisabledBehavior = 'all'
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
-  dragAndDropHooks?: DragAndDropHooks
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   expandedKeys?: Iterable<Key>
   id?: string
   isQuiet?: boolean
   onAction?: (Key) => void
   onExpandedChange?: (Set<Key>) => any
   onLoadMore?: () => any
   onResize?: (Map<Key, ColumnSize>) => void
   onResizeEnd?: (Map<Key, ColumnSize>) => void
   onResizeStart?: (Map<Key, ColumnSize>) => void
   onSelectionChange?: (Selection) => void
   onSortChange?: (SortDescriptor) => any
   overflowMode?: 'wrap' | 'truncate' = 'truncate'
   renderActionBar?: ('all' | Set<Key>) => ReactElement
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
   shouldSelectOnPressUp?: boolean
   slot?: string | null
   sortDescriptor?: SortDescriptor
   styles?: StylesPropWithHeight
   treeColumn?: Key
 }

/@react-spectrum/s2:TreeViewProps

 TreeViewProps <T> {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children?: ReactNode | (T) => ReactNode
   defaultExpandedKeys?: Iterable<Key>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   dependencies?: ReadonlyArray<any>
   disabledBehavior?: DisabledBehavior = 'all'
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
-  dragAndDropHooks?: DragAndDropHooks<NoInfer<T>>
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   expandedKeys?: Iterable<Key>
   id?: string
   items?: Iterable<T>
   onExpandedChange?: (Set<Key>) => any
   onSelectionChange?: (Selection) => void
   renderActionBar?: ('all' | Set<Key>) => ReactElement
   renderEmptyState?: (TreeEmptyStateRenderProps) => ReactNode
   selectedKeys?: 'all' | Iterable<Key>
   selectionMode?: SelectionMode
   selectionStyle?: 'highlight' | 'checkbox' = 'checkbox'
   shouldSelectOnPressUp?: boolean
   slot?: string | null
   styles?: StylesPropWithHeight
 }

/@react-spectrum/s2:DragAndDropHooks

-DragAndDropHooks {
-  D: undefined
-}

/@react-spectrum/s2:DragAndDropOptions

-DragAndDropOptions <T = {}> {
-  acceptedDragTypes?: 'all' | Array<string | symbol> = 'all'
-  dropTargetDelegate?: DropTargetDelegate
-  getAllowedDropOperations?: () => Array<DropOperation>
-  getDropOperation?: (DropTarget, DragTypes, Array<DropOperation>) => DropOperation
-  getItems?: (Set<Key>, Array<T>) => Array<DragItem> = () => []
-  isDisabled?: boolean
-  onDragEnd?: (DraggableCollectionEndEvent) => void
-  onDragMove?: (DraggableCollectionMoveEvent) => void
-  onDragStart?: (DraggableCollectionStartEvent) => void
-  onDrop?: (DroppableCollectionDropEvent) => void
-  onDropActivate?: (DroppableCollectionActivateEvent) => void
-  onDropEnter?: (DroppableCollectionEnterEvent) => void
-  onDropExit?: (DroppableCollectionExitEvent) => void
-  onInsert?: (DroppableCollectionInsertDropEvent) => void
-  onItemDrop?: (DroppableCollectionOnItemDropEvent) => void
-  onMove?: (DroppableCollectionReorderEvent) => void
-  onReorder?: (DroppableCollectionReorderEvent) => void
-  onRootDrop?: (DroppableCollectionRootDropEvent) => void
-  renderDragPreview?: (Array<DragItem>) => JSX.Element | {
-    element: JSX.Element
-  x: number
-  y: number
-}
-  renderDropIndicator?: (DropTarget) => JSX.Element
-  shouldAcceptItemDrop?: (ItemDropTarget, DragTypes) => boolean
-}

/@react-spectrum/s2:DirectoryDropItem

-DirectoryDropItem {
-  getEntries: () => AsyncIterable<FileDropItem | DirectoryDropItem>
-  kind: 'directory'
-  name: string
-}

/@react-spectrum/s2:DraggableCollectionEndEvent

-DraggableCollectionEndEvent {
-  dropOperation: DropOperation
-  isInternal: boolean
-  keys: Set<Key>
-  type: 'dragend'
-  x: number
-  y: number
-}

/@react-spectrum/s2:DraggableCollectionMoveEvent

-DraggableCollectionMoveEvent {
-  keys: Set<Key>
-  type: 'dragmove'
-  x: number
-  y: number
-}

/@react-spectrum/s2:DraggableCollectionStartEvent

-DraggableCollectionStartEvent {
-  keys: Set<Key>
-  type: 'dragstart'
-  x: number
-  y: number
-}

/@react-spectrum/s2:DragPreviewRenderer

-DragPreviewRenderer {
-  D: undefined
-}

/@react-spectrum/s2:DragTypes

-DragTypes {
-  has: (string | symbol) => boolean
-}

/@react-spectrum/s2:DropItem

-DropItem {
-  D: undefined
-}

/@react-spectrum/s2:DropOperation

-DropOperation {
-  D: undefined
-}

/@react-spectrum/s2:DroppableCollectionDropEvent

-DroppableCollectionDropEvent {
-  dropOperation: DropOperation
-  items: Array<DropItem>
-  target: DropTarget
-  type: 'drop'
-  x: number
-  y: number
-}

/@react-spectrum/s2:DroppableCollectionEnterEvent

-DroppableCollectionEnterEvent {
-  target: DropTarget
-  type: 'dropenter'
-  x: number
-  y: number
-}

/@react-spectrum/s2:DroppableCollectionExitEvent

-DroppableCollectionExitEvent {
-  target: DropTarget
-  type: 'dropexit'
-  x: number
-  y: number
-}

/@react-spectrum/s2:DroppableCollectionInsertDropEvent

-DroppableCollectionInsertDropEvent {
-  dropOperation: DropOperation
-  items: Array<DropItem>
-  target: ItemDropTarget
-}

/@react-spectrum/s2:DroppableCollectionMoveEvent

-DroppableCollectionMoveEvent {
-  target: DropTarget
-  type: 'dropmove'
-  x: number
-  y: number
-}

/@react-spectrum/s2:DroppableCollectionOnItemDropEvent

-DroppableCollectionOnItemDropEvent {
-  dropOperation: DropOperation
-  isInternal: boolean
-  items: Array<DropItem>
-  target: ItemDropTarget
-}

/@react-spectrum/s2:DroppableCollectionReorderEvent

-DroppableCollectionReorderEvent {
-  dropOperation: DropOperation
-  keys: Set<Key>
-  target: ItemDropTarget
-}

/@react-spectrum/s2:DroppableCollectionRootDropEvent

-DroppableCollectionRootDropEvent {
-  dropOperation: DropOperation
-  items: Array<DropItem>
-}

/@react-spectrum/s2:DropPosition

-DropPosition {
-  D: undefined
-}

/@react-spectrum/s2:DropTarget

-DropTarget {
-  D: undefined
-}

/@react-spectrum/s2:FileDropItem

-FileDropItem {
-  getFile: () => Promise<File>
-  getText: () => Promise<string>
-  kind: 'file'
-  name: string
-  type: string
-}

/@react-spectrum/s2:ItemDropTarget

-ItemDropTarget {
-  dropPosition: DropPosition
-  key: Key
-  type: 'item'
-}

/@react-spectrum/s2:RootDropTarget

-RootDropTarget {
-  type: 'root'
-}

/@react-spectrum/s2:TextDropItem

-TextDropItem {
-  getText: (string) => Promise<string>
-  kind: 'text'
-  types: Set<string>
-}

@rspbot
Copy link
Copy Markdown

rspbot commented May 18, 2026

Agent Skills Changes

Modified (48)
Install

React Spectrum S2:

npx skills add https://d1pzu54gtk2aed.cloudfront.net/pr/2f698fbe05aaa21799ad56d9822ef4fbe9e9b7ee/

React Aria:

npx skills add https://d5iwopk28bdhl.cloudfront.net/pr/2f698fbe05aaa21799ad56d9822ef4fbe9e9b7ee/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

useId leaks FinalizationRegistry entries on every re-render

5 participants