react-spectrum
fix: Memory leak suspense safe useId
#7757
Merged

fix: Memory leak suspense safe useId #7757

snowystinger merged 6 commits into main from demo-suspense-safe-useid-usage
snowystinger
snowystinger190 days ago (edited 190 days ago)

Closes #7738

This PR makes useId safe to use with Suspense. As stated in the issue, using useId inside a Suspense boundary in concurrent mode can result in a memory leak. This is because we write to a Map during render so that we can track values during the render phase.
Because we do it during render though, and mount/unmount are never called on the suspended components, we never have the opportunity to clean up.

React does eventually clean up Suspended components depending on waves hands criteria.

We can take advantage of that by adding an object we know will be stable for the entire lifetime of that component to a FinalizationRegistry. When that component is cleaned up and the gc runs, we will get a callback such that we can remove the key from the Map.

One thing to note in the docs for FinalizationRegistry, is there is a caveat, this function may never be called. However, I believe this applies to the life time of the entire application. I think it doesn't mean that the memory can be reclaimed without calling it. Since we don't care when the values are cleaned up, I think this is an ok caveat.

I've checked our other components. Nowhere else appears to have this behaviour. We do have a couple minor memory leaks with our Formatter caches, however, those should be negligible and we'd need to write our own GC to handle those. I've left them alone for now.

In the future, we should use WeakMap whenever possible for these situations.

For now, to avoid a breaking change, we have to use the FinalizationRegistry.

Tests are omitted because getting node to GC on demand is very fiddly. I've left a story in storybook under useId.

โœ… 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:

๐Ÿงข Your Project:

snowystinger [WIP]: demo useId suspense usage
e5fd0013
snowystinger clean up useids memory
9c4d2d20
snowystinger snowystinger changed the title Suspense safe useId fix: Memory leak suspense safe useId 190 days ago
snowystinger add comment to code
3d35f840
liaoyinglong
liaoyinglong190 days ago (edited 190 days ago)

Test Findings:
In my testing, memory still isn't being properly released. The cached useCallback appears to retain fiber references, preventing garbage collection and consequently keeping cleanupRef in memory indefinitely.

Working Solution:
Changing to this structure successfully triggers GC:

let idRefsMap: Map<string, { current: string | null }[]> = new Map();

cache useCallback

1.mp4

cache ref

2.mp4
snowystinger
snowystinger188 days ago

Thanks for double checking that, I'll update that soon.

snowystinger Update to get rid of the useCallback which keeps a strong reference
5b29e542
snowystinger Merge branch 'main' into demo-suspense-safe-useid-usage
12e3d85c
snowystinger
snowystinger commented on 2025-02-13
Conversation is marked as resolved
Show resolved
packages/@react-aria/utils/stories/useId.stories.tsx
70 render: () => <TestUseId />,
71 parameters: {
72 description: {
73
data: 'This story demonstrates garbage collection cleanup of useId hook. Depends on the browser when it happens. Easiest to see by rendering, clicking the toggle button twice, then waiting for the GC.'
snowystinger188 days ago
Suggested change
data: 'This story demonstrates garbage collection cleanup of useId hook. Depends on the browser when it happens. Easiest to see by rendering, clicking the toggle button twice, then waiting for the GC.'
data: 'This story demonstrates garbage collection cleanup of useId hook. Depends on the browser when it happens. Easiest to see by rendering, clicking the toggle button twice, then waiting for the GC or, if you are in chrome, you can force GC to run with developer tools in the memory tab.'
snowystinger Update packages/@react-aria/utils/stories/useId.stories.tsx
01eab56b
snowystinger
snowystinger188 days ago

Compared to your videos, looks like this is behaving correctly now. Thanks again for checking me and tracking down the issue, apologies for not picking up on your comment earlier.

devongovett devongovett added release
devongovett
devongovett approved these changes on 2025-02-14
snowystinger snowystinger enabled auto-merge 187 days ago
reidbarber
reidbarber approved these changes on 2025-02-14
packages/@react-aria/utils/src/useId.ts
26// This allows us to clean up the idsUpdaterMap when the id is no longer used.
27// Map is a strong reference, so unused ids wouldn't be cleaned up otherwise.
28// This can happen in suspended components where mount/unmount is not called.
29
let registry = new FinalizationRegistry<string>((heldValue) => {
reidbarber186 days ago

TIL

snowystinger snowystinger merged 3fee0f45 into main 186 days ago
snowystinger snowystinger deleted the demo-suspense-safe-useid-usage branch 186 days ago
tobimori
tobimori166 days ago๐Ÿ‘ 1

Unfortunately breaks SSR on certain serverless environments, see #7882.

Login to write a write a comment.

Login via GitHub

Assignees
No one assigned
Labels
Milestone