Skip to content

Conversation

cherniavskii
Copy link
Member

@cherniavskii cherniavskii commented Jul 3, 2025

I noticed this issue while fixing failing tests in #18348
In strict mode, when a grid cell unmounts and remounts in a different location in the DOM, useGridSelector doesn't subscribe to the store and displays an outdated state.

Minimal reproduction example (no data grid used): https://stackblitz.com/edit/v3km56jq?file=src%2FDemo.jsx
Fix: uncomment lines 85-87

@cherniavskii cherniavskii added type: bug It doesn't behave as expected. scope: data grid Changes related to the data grid. dx Related to developers' experience. labels Jul 3, 2025
@mui-bot
Copy link

mui-bot commented Jul 3, 2025

Deploy preview: https://deploy-preview-18676--material-ui-x.netlify.app/

Bundle size report

Total Size Change: 🔺+180B(0.00%) - Total Gzip Change: 🔺+13B(0.00%)
Files: 122 total (0 added, 0 removed, 6 changed)

Show details for 100 more bundles (22 more not shown)

@mui/x-data-gridparsed: 🔺+30B(+0.01%) gzip: 🔺+3B(0.00%)
@mui/x-data-grid-premiumparsed: 🔺+30B(+0.01%) gzip: 🔺+1B(0.00%)
@mui/x-data-grid-premium/DataGridPremiumparsed: 🔺+30B(+0.01%) gzip: 🔺+2B(0.00%)
@mui/x-data-grid-proparsed: 🔺+30B(+0.01%) gzip: 🔺+1B(0.00%)
@mui/x-data-grid-pro/DataGridProparsed: 🔺+30B(+0.01%) gzip: 🔺+3B(0.00%)
@mui/x-data-grid/DataGridparsed: 🔺+30B(+0.01%) gzip: 🔺+3B(0.00%)
@mui/x-chartsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts-proparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts-pro/BarChartProparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts-pro/ChartContainerProparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts-pro/ChartDataProviderProparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts-pro/ChartsToolbarProparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts-pro/ChartZoomSliderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts-pro/FunnelChartparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts-pro/Heatmapparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts-pro/LineChartProparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts-pro/PieChartProparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts-pro/RadarChartProparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts-pro/ScatterChartProparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/BarChartparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartContainerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartDataProviderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsAxisparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsAxisHighlightparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsClipPathparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsGridparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsLabelparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsLegendparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsLocalizationProviderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsOverlayparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsReferenceLineparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsSurfaceparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsTextparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsTooltipparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsXAxisparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ChartsYAxisparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/Gaugeparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/LineChartparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/PieChartparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/RadarChartparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/ScatterChartparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/SparkLineChartparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-charts/Toolbarparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickersparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-proparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/AdapterDateFnsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/AdapterDateFnsJalaliparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/AdapterDayjsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/AdapterLuxonparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/AdapterMomentparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/AdapterMomentHijriparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/AdapterMomentJalaaliparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/DateRangeCalendarparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/DateRangePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/DateRangePickerDayparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/DateRangePickerDay2parsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/DateTimeRangePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/DesktopDateRangePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/DesktopDateTimeRangePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/DesktopTimeRangePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/LocalizationProviderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/MobileDateRangePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/MobileDateTimeRangePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/MobileTimeRangePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/MultiInputDateRangeFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/MultiInputDateTimeRangeFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/MultiInputTimeRangeFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/PickersRangeCalendarHeaderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/SingleInputDateRangeFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/SingleInputDateTimeRangeFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/SingleInputTimeRangeFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/StaticDateRangePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers-pro/TimeRangePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterDateFnsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterDateFnsBaseparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterDateFnsJalaliparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterDayjsparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterLuxonparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterMomentparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterMomentHijriparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/AdapterMomentJalaaliparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DateCalendarparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DateFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DatePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DateTimeFieldparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DateTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DayCalendarSkeletonparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DesktopDatePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DesktopDateTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DesktopTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/DigitalClockparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/LocalizationProviderparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/MobileDatePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/MobileDateTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/MobileTimePickerparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/MonthCalendarparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/MultiSectionDigitalClockparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/PickerDay2parsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/PickersActionBarparsed: 0B(0.00%) gzip: 0B(0.00%)
@mui/x-date-pickers/PickersCalendarHeaderparsed: 0B(0.00%) gzip: 0B(0.00%)

Details of bundle changes

Generated by 🚫 dangerJS against bb938e3

@cherniavskii cherniavskii marked this pull request as ready for review July 3, 2025 15:58
@cherniavskii cherniavskii requested a review from romgrk July 3, 2025 15:58
@cherniavskii
Copy link
Member Author

FYI @lauri865

Copy link
Contributor

@romgrk romgrk left a comment

Choose a reason for hiding this comment

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

I always feel like strict mode causes more bugs than it fixes.

@lauri865
Copy link
Contributor

lauri865 commented Jul 3, 2025

Ugh, how did you discover this or what started triggered the symptoms in the first place?

I think there might be an alternative fix to this as well based on quick testing. Will post something shortly.

@cherniavskii
Copy link
Member Author

@lauri865

how did you discover this or what started triggered the symptoms in the first place?

This failing test. After deleting a row in pivot mode, the row index has changed, but the aggregated cell kept showing the old aggregated value because it wasn't subscribed to the state store anymore 🙃

@cherniavskii
Copy link
Member Author

I always feel like strict mode causes more bugs than it fixes.

Yeah, having different behaviors in dev/prod makes life harder for sure!

@lauri865
Copy link
Contributor

lauri865 commented Jul 3, 2025

So, if I understand what's happening correctly, it's that getSnapShot is actually called, but too aggressively in StrictMode, before the component is even unmounted, and even before the existing subscription is removed. Seems like a React bug?

And why does StrictMode cause a remount of a component to begin with in this instance?!

Anyways, the fix seems appropriate, since it only ever affects StrictMode.

Side note: It may make StrictMode slower, since calling callback causes a re-render; and if I recall correctly, these updates are not batched. So, with many selectors, it may or may not slow things down a bit in dev / testing. Alternative would be to just call subscribe instead of callback, however I'm not 100% sure if that would leave an opening to any other edge cases to this edge case :)))

@cherniavskii
Copy link
Member Author

cherniavskii commented Jul 3, 2025

So, if I understand what's happening correctly, it's that getSnapShot is actually called, but too aggressively in StrictMode,

Maybe it's called too aggressively, but that's not the issue.
The issue is that getSnapshot is not called after component unmounts and mounts at a different location in the DOM.
In the green rectangle, you can see that useSyncExternalStore's subscribe is called, but there's no getSnapshot call after that. And since our subscription is being done in getSnapshot, we end up with a component that's not subscribed to state changes.

And why does StrictMode cause a remount of a component to begin with in this instance?!

The component moves to a different location in the tree.
If you comment out the <EmptyCell /> between the cells, then the issue is gone.

@lauri865
Copy link
Contributor

lauri865 commented Jul 3, 2025

I understand as much. My point was that the first getSnapshot calls come in out of order. Why does an unmounting component call getSnapshot to begin with?

And the question around StrictMode was on a slightly more abstract level. If the DOM node that the component is responsible for rendering doesn't get re-created, why restart the React component life-cycle to begin with, even in StrictMode? Like, what problem is it trying to solve?

@lauri865
Copy link
Contributor

lauri865 commented Jul 3, 2025

By aggressively, I meant too early in the life-cycle, not too many times, just to avoid any confusion.

@cherniavskii
Copy link
Member Author

My point was that the first getSnapshot calls come in out of order.

The first getSnapshot calls before toggling the order? What's out of order in this case?

Why does an unmounting component call getSnapshot to begin with?

Is this what happens, though? If I uncomment the second <Cell />, I don't see any getSnapshot calls on an unmounting component.

@cherniavskii
Copy link
Member Author

Alternative would be to just call subscribe instead of callback, however I'm not 100% sure if that would leave an opening to any other edge cases to this edge case :)))

Hmm, this seems to work well.
I'll give it a try.

@cherniavskii
Copy link
Member Author

By aggressively, I meant too early in the life-cycle, not too many times

This seems to be an expected behavior: https://stackoverflow.com/a/76475288

@cherniavskii
Copy link
Member Author

Alternative would be to just call subscribe instead of callback

This made me think – shouldn't we remove the subscribe call from getSnapshot? Wouldn't that be more idiomatic and closer to the intended purpose of useSyncExternalStore? WDYT?

@lauri865
Copy link
Contributor

lauri865 commented Jul 3, 2025

Alternative would be to just call subscribe instead of callback

This made me think – shouldn't we remove the subscribe call from getSnapshot? Wouldn't that be more idiomatic and closer to the intended purpose of useSyncExternalStore? WDYT?

Unfortunately not. Because that would run after any ref is set, and wouldn't catch state updates from callback refs. In this particular edge case it shouldn't matter, as the node should be available anyways.

I would love to get rid of the whole useSyncExternalStore implementation to begin with, since it's a bit a hack, but I haven't found any other solution that would support StrictMode and React 17. useInsertionEffect would be the best place to add the subscription, but there's no React 17 support.

Any other idea I've tried breaks StrictMode completely (e.g. setting up the subscription in createRefs or the callback function useState and other such ideas). The problem is always the same – unsubscribe gets called after the second component lifecycle has already started.

@lauri865
Copy link
Contributor

lauri865 commented Jul 3, 2025

Performance-wise, if we want to reduce allocations and function calls, it could be possible to add a differentiated export based on whether we're running in production or not (React itself does it internally). In production we don't have to care about StrictMode, and hence something as simple as this could work: https://stackblitz.com/edit/v3km56jq-3mmtkbbj?file=src%2Findex.jsx,src%2FDemo.jsx

Won't affect bundle size (if anything a tiny improvement), but makes it more difficult to maintain dual tracks of course, and probably would need to remove StrictMode from testing to make sure we test production code.

Just wish React would have offered an escape hatch for StrictMode for library maintainers, and not act as such a babysitter.

@lauri865
Copy link
Contributor

lauri865 commented Jul 3, 2025

Actually, I was reminded that double-rendering was only introduced in React 18, so we could have useInsertionEffect with fallback for React 17 and support StrictMode.

Similar to this: https://github.com/emotion-js/emotion/blob/49229553967b6050c92d9602eb577bdc48167e91/packages/use-insertion-effect-with-fallbacks/src/index.ts#L14

Roughly like this (the logic can be further deduplicated of course):
https://stackblitz.com/edit/v3km56jq-3mmtkbbj?file=src%2Findex.jsx,src%2FDemo.jsx

In this case, testing can remain the same.

@cherniavskii
Copy link
Member Author

we could have useInsertionEffect with fallback for React 17 and support StrictMode.

This feels riskier than what we do ATM, because the use-sync-external-store shim encapsulates hacks for React < 18.
I would rather keep using it for now and reconsider if we hit new issues with the current approach.
But thanks for sparking this idea!

@cherniavskii cherniavskii merged commit 39c867d into mui:master Jul 4, 2025
22 checks passed
@cherniavskii cherniavskii deleted the fix-useGridSelector-in-StrictMode branch July 4, 2025 20:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
dx Related to developers' experience. scope: data grid Changes related to the data grid. type: bug It doesn't behave as expected.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants