Skip to content

Conversation

@chuganzy
Copy link
Contributor

@chuganzy chuganzy commented Sep 7, 2025

This PR fixes a bug in CompositeList where the internal item order was not updated when the list items were reordered in React versions earlier than 19 (this was also the case in React 19 production build) and as a result, arrow key navigation could behave incorrectly.

After doing some tests, it turned out React 19 remounts items accordingly and this is not an issue.

Sample project to reproduce that issue: https://codesandbox.io/p/sandbox/qfsgqw

This PR fixes the issue by making use of MutationObserver if the React version is <19.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Sep 7, 2025

Open in StackBlitz

pnpm add https://pkg.pr.new/mui/base-ui/@base-ui-components/react@2675
pnpm add https://pkg.pr.new/mui/base-ui/@base-ui-components/utils@2675

commit: 9909a8d

@netlify
Copy link

netlify bot commented Sep 7, 2025

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 9909a8d
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/68c18c1e1ddd370008bb29d1
😎 Deploy Preview https://deploy-preview-2675--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@chuganzy chuganzy changed the title [Composites] Improve CompositeList to update the sortedMap on reorder [Composites] Fix CompositeList not updating item order on reordering in React <19 Sep 7, 2025
act(() => item1.focus());
await user.keyboard('{ArrowDown}');
expect(item3).toHaveFocus();
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This fails on the main branch when running the test after doing node scripts/useReactVersion.mjs ^18

@chuganzy chuganzy marked this pull request as ready for review September 7, 2025 15:06
@chuganzy chuganzy marked this pull request as draft September 7, 2025 16:20

const map = useRefWithInit(createMap<Metadata>).current;
const [mapTick, setMapTick] = React.useState(0);
const [mapTick, setMapTick] = React.useState({});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Update to use just empty object instead because there is a (very low) risk of number overflow.

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor

Choose a reason for hiding this comment

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

This should be reverted, there is no realistic risk of number overflow. Number.MAX_SAFE_INTEGER is the upper limit for integers. Even with 1,000 updates per second, it would take 285 years before it causes problems.

Copy link
Contributor Author

@chuganzy chuganzy Sep 27, 2025

Choose a reason for hiding this comment

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

Thanks for the feedback!
You're right that the risk of number overflow is practically negligible. I only meant to highlight that "negligible" isn't quite the same as "impossible", but my main motivation here was consistency with other parts of the codebase that use the object pattern, such as the one used in useForceRerendering.

That said, I don't feel strongly about it, and I'm happy to revert this change if the project prefers to keep the counter-based approach here when I have a chance.
Would you prefer I update this line back, or align the other occurrences instead? Thanks!

Copy link
Contributor

Choose a reason for hiding this comment

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

I would switch this line back to a number. And tbh number overflow is not possible with JS numbers, the issue would be hitting the double precision limit which would render the + 1 operation void. Number overflow would actually not be an issue, it would be a good thing. We could wrap on the int32 range by casting via >>> 0 to get number overflow.

Copy link
Contributor Author

@chuganzy chuganzy Sep 29, 2025

Choose a reason for hiding this comment

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

Sorry, I was not very clear above. I'm aware of that behavior and understand that +1 can become ineffective (void) in JS. My point was that, this could cause Object.is in React to return true, preventing re-renders and memo updates.

That said, as you mentioned, this risk is extremely low, and I'm cool with reverting this change. When we do that, I'd like to leave a comment to prevent the same change from happening again since this change has passed the review, and I'll also need to make sure to mention it in the PR. To clarify, when explaining why the counter approach is used, is it sufficient to mention it for memory efficiency (GC)? Thanks again!

Copy link
Contributor

Choose a reason for hiding this comment

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

We can use setMapTick(t => (t + 1) >>> 0) to wrap around if we're really worried about hitting the precision limit, and yes we can mention memory efficiency.

if (labelsRef && labelsRef.current.length !== sortedMap.size) {
labelsRef.current.length = sortedMap.size;
}
nextIndexRef.current = sortedMap.size;
Copy link
Contributor Author

@chuganzy chuganzy Sep 7, 2025

Choose a reason for hiding this comment

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

Found nextIndexRef only keeps incrementing.

Copy link
Contributor

Choose a reason for hiding this comment

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

@chuganzy I found this line causes a bug in Autocomplete + autoHighlight where the scroll on the page jumps on the second+ open.

Did this actually cause a bug or can it be removed?

Copy link
Contributor Author

@chuganzy chuganzy Sep 23, 2025

Choose a reason for hiding this comment

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

@atomiks My bad.. Yes, this line can be isolated and reverted! 🙏

Copy link
Contributor

Choose a reason for hiding this comment

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

Nevermind, it just uncovered a bug actually. #2815

@chuganzy chuganzy marked this pull request as ready for review September 7, 2025 16:40
@mui-bot
Copy link

mui-bot commented Sep 8, 2025

Bundle size report

Bundle Parsed size Gzip size
@base-ui-components/react 🔺+381B(+0.11%) 🔺+138B(+0.13%)

Details of bundle changes

@mj12albert mj12albert requested a review from romgrk September 8, 2025 02:49
@mj12albert
Copy link
Member

Tested the fix and it works: https://codesandbox.io/p/devbox/2675-forked-68z63n

@atomiks
Copy link
Contributor

atomiks commented Sep 8, 2025

After doing some tests, it turned out React 19 remounts items accordingly and this is not an issue.

Curious if this only happens in React 18 dev mode? See: #2565

We also want to make sure performance doesn't degrade for composite list components if possible:

@chuganzy
Copy link
Contributor Author

chuganzy commented Sep 8, 2025

@atomiks

Curious if this only happens in React 18 dev mode?

Actually, this was also the case in React 19..
React 19 dev mode (StrictMode) is the only one works fine, but production mode failed..

Added d04751a to remove the version check..

Regarding the performance, I will look into it deeper later!

@chuganzy chuganzy changed the title [Composites] Fix CompositeList not updating item order on reordering in React <19 [Composites] Fix CompositeList not updating item order on reordering Sep 8, 2025
@chuganzy
Copy link
Contributor Author

chuganzy commented Sep 8, 2025

Quick summary of performance check:

  • In the "Without manually specified index" cases of Select and Combobox, when elements change (on open / on filter), there is additional O(n) operation to start the MutationObserver. In dev, this takes less than 300μs even with 1,000 items, which is negligible.
  • With this change, even though the MutationObserver fires on open / close, it does not require the element order to be re-calculated in the above cases.

Given the above, in my opinion these results are acceptable, but what do you think?

Copy link
Contributor

@atomiks atomiks left a comment

Choose a reason for hiding this comment

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

Thanks for the contribution! Performance seems unaffected, and the fix works correctly

@atomiks atomiks merged commit 3dc22fe into mui:master Sep 11, 2025
19 checks passed
@chuganzy chuganzy deleted the composite-reorder branch September 11, 2025 14:05
@oliviertassinari oliviertassinari changed the title [Composites] Fix CompositeList not updating item order on reordering [composite] Fix CompositeList not updating item order on reordering Sep 21, 2025
chuganzy added a commit to chuganzy/base-ui that referenced this pull request Oct 1, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants