[popups] Improve performance by batching ref access#3797
[popups] Improve performance by batching ref access#3797michaldudak wants to merge 13 commits intomui:masterfrom
Conversation
commit: |
Bundle size report
Check out the code infra dashboard for more information about this PR. |
✅ Deploy Preview for base-ui ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
@greptileai review this |
Greptile OverviewGreptile SummaryThis PR optimizes performance by batching hook calls through two new utility hooks: Key changes:
Performance improvements:
Confidence Score: 5/5
Important Files Changed
|
| export function useStableCallbacks<T extends Record<string, Callback | undefined>>( | ||
| callbacksObj: T, | ||
| ): StableCallbacks<T> { | ||
| const stable = useRefWithInit(createStableCallbacks<T>, Object.keys(callbacksObj)).current; |
There was a problem hiding this comment.
style: Keys are captured only on first render. If callbacksObj gets new keys in later renders, no trampolines will be created for them. Consider adding dev-mode validation to warn if keys change between renders.
| const stable = useRefWithInit(createStableCallbacks<T>, Object.keys(callbacksObj)).current; | |
| const stable = useRefWithInit(createStableCallbacks<T>, Object.keys(callbacksObj)).current; | |
| if (process.env.NODE_ENV !== 'production') { | |
| const currentKeys = Object.keys(callbacksObj); | |
| if (currentKeys.length !== stable.trampolines && | |
| !currentKeys.every(k => k in stable.trampolines)) { | |
| console.warn('Base UI: useStableCallbacks keys changed between renders'); | |
| } | |
| } |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/utils/src/useStableCallbacks.ts
Line: 49:49
Comment:
**style:** Keys are captured only on first render. If `callbacksObj` gets new keys in later renders, no trampolines will be created for them. Consider adding dev-mode validation to warn if keys change between renders.
```suggestion
const stable = useRefWithInit(createStableCallbacks<T>, Object.keys(callbacksObj)).current;
if (process.env.NODE_ENV !== 'production') {
const currentKeys = Object.keys(callbacksObj);
if (currentKeys.length !== stable.trampolines &&
!currentKeys.every(k => k in stable.trampolines)) {
console.warn('Base UI: useStableCallbacks keys changed between renders');
}
}
```
<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
In case you're interested, I'm pretty sure it would be possible to re-use the same fastComponent() logic from #3445 to reduce the cost of multiple useStableCallback() calls to 2 hooks (down from O(n)), just like we did for useStore(). The advantage is that the ergonomy of the API doesn't change.
This PR's API is probably slightly more efficient though, because there is a single object declared once, whereas with multiple calls we'd need to accumulate the stable callbacks in an array incrementally, so a bit more bookkeeping.
There was a problem hiding this comment.
This solution is also more straightforward and IMO easier to understand, as it's just another hook call, so I prefer it this way.
There was a problem hiding this comment.
But it does requires code to be re-organized in a way where the callbacks are declared all at once, and it wouldn't work if the hook was modularized into multiple variants (e.g. see the useHover... hooks). The strength of the fast hooks approach is that it doesn't require the code using it to be aware of it or be organized in a specific way. It also works for all the other interaction hooks at once (e.g. useHover...) without requiring more refactoring.
There was a problem hiding this comment.
I'm OK with requiring callbacks to be declared together via useStableCallbacks. It’s more explicit and doesn't introduce more "magic".
I’d rather keep this PR’s scope focused and avoid introducing fastComponent here unless we decide to standardize on that approach across the board.
|
Is it feasible to also combine |
I created a |
|
This adds significant weight to the bundle while improving the performance only marginally. It's not worth it. I'll explore the fasthooks implementation instead. |
Created the
useStableCallbackshook to batch individualuseStableCallbackcalls.Used the new hook in
useDismiss. After the change, components usinguseDismisshave 34 fewer hooks (for example, DialogRoot, 150 -> 116).Performance benchmark results
Edge 144 @ M3 Pro, no throttling, all components (Dialog, Menu, Popover, Tooltip)
Contained triggers
https://deploy-preview-3797--base-ui.netlify.app/experiments/perf/contained-triggers
this PR: 150.5ms
master: 167ms
Detached triggers
https://deploy-preview-3797--base-ui.netlify.app/experiments/perf/detached-triggers
this PR: 72.3ms
master: 75ms