Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Data Grid] Avoid <GridRoot /> double-render pass on mount in SPA mode #15648

Open
wants to merge 142 commits into
base: master
Choose a base branch
from

Conversation

lauri865
Copy link
Contributor

@lauri865 lauri865 commented Nov 28, 2024

Currently, GridRoot double-renders in all cases on mount to prevent SSR. In SPA-mode this is irrelevant, and can be avoided with the help of useSyncExternalStore, since there's no server snapshot in SPA-mode, the gird will be mounted directly. As a result, the rootRef becomes immediately available on first mount in SPAs, as one would expect.

Adds a new dependency use-sync-external-store to make it backwards compatible with React 17. Charts and treeview have the same dependency.

Changelog

Breaking changes

  • The filteredRowsLookup object of the filter state does not contain true values anymore. If the row is filtered out, the value is false. Otherwise, the row id is not present in the object.
    This change only impacts you if you relied on filteredRowsLookup to get ids of filtered rows. In this case,use gridDataRowIdsSelector selector to get row ids and check filteredRowsLookup for false values:

     const filteredRowsLookup = gridFilteredRowsLookupSelector(apiRef);
    -const filteredRowIds = Object.keys(filteredRowsLookup).filter((rowId) => filteredRowsLookup[rowId] === true);
    +const rowIds = gridDataRowIdsSelector(apiRef);
    +const filteredRowIds = rowIds.filter((rowId) => filteredRowsLookup[rowId] !== false);

@mui-bot
Copy link

mui-bot commented Nov 28, 2024

@lauri865 lauri865 force-pushed the avoid-double-render-pass-in-spas branch from dd1a1e1 to 8da261b Compare November 28, 2024 13:53
@flaviendelangle flaviendelangle added the component: data grid This is the name of the generic UI component, not the React module! label Nov 28, 2024
@flaviendelangle flaviendelangle changed the title [Data Grid] Avoid GridRoot double-render pass on mount in SPA mode [Data Grid] Avoid <GridRoot /> double-render pass on mount in SPA mode Nov 28, 2024
@lauri865
Copy link
Contributor Author

Side-benefit is also probably more robust tests if the Datagrid is mounted on the first pass.

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.

Code LGTM, we can merge once the tests are passing.

@lauri865
Copy link
Contributor Author

lauri865 commented Dec 4, 2024

I have no idea how to fix the last failing test. Either it's a symptom of column headers updated more times now or just a bad test. But I lack understanding why it was previously expected that the warning message will be output exactly twice.

Edit: discovered some areas to improve around mounting / state initialization

@lauri865 lauri865 force-pushed the avoid-double-render-pass-in-spas branch from 12fb3da to c8580d9 Compare December 4, 2024 12:39
@lauri865
Copy link
Contributor Author

lauri865 commented Dec 4, 2024

Also fixes an annoying flicker coming out of loading state. It's probably the root cause of a layout shift I once reported with autoHeight grid coming out of loading state.

Before:
https://github.com/user-attachments/assets/1a68b2de-f2fd-4ef4-a93d-215d044e35b7

After:
https://github.com/user-attachments/assets/cfe55012-092a-429d-a79d-75e243124d10

@lauri865
Copy link
Contributor Author

lauri865 commented Dec 4, 2024

Tests should be passing now. 4 was the right amount of warnings for this approach, 1 extra pass (+1 warning for header/row each = 2+2 = 4) that doesn't even flush to the dom, so I think we're good there.

Curiously, after this optimisation:

const hasFlexColumns = gridVisibleColumnDefinitionsSelector(apiRef).some(
(col) => col.flex && col.flex > 0,
);
if (!hasFlexColumns) {
return;
}
setGridColumnsState(
hydrateColumnsWidth(
gridColumnsStateSelector(apiRef.current.state),
apiRef.current.getRootDimensions(),
),
);

This test started failing:

it('should correctly restore the column when changing from aggregated to non-aggregated', () => {
const { setProps } = render(<Test aggregationModel={{ id: 'max' }} />);
expect(getColumnHeaderCell(0, 0).textContent).to.equal('idmax');
setProps({ aggregationModel: {} });
expect(getColumnHeaderCell(0, 0).textContent).to.equal('id');
});

The only reason I can think of is that aggregations didn't explicitly trigger column updates, but somehow were piggybacking on viewportInnerSizeChange event for taking care of it. Adding an explicit updateColumns call fixed it. Is there any other explanation to it?

If that's the case, then I suppose there was a bug whereby columns didn't clear when there was no totals row visible, as viewPortInnerSize would only change if there was a pinned row.

@lauri865
Copy link
Contributor Author

I've also observed that issue, and I've mentionned that we could get rid of the perf cost of running all the selectors during scrolling by switching from selector-based reactivity to event-based reactivity (conceptual notes in this gist). That's of course a general solution to selector-based reactivity's issues, but yours could provide more immediate gains. I'm wary though of adding quick workarounds, the codebase already has a fair amount of tech debt to resolve.

That's also why getCellParams was a single bag-of-data selector previously, it allowed to keep the number of selector-listeners as low as possible.

Thanks for the additional context, sounds like an interesting approach. I thought about alternative approaches as well, but after some quick measurements, I was more leaning towards the number of selectors not being an issue in and of itself – they're pretty performant. It's only an issue when the state updates a) with high frequency and b) we're rendering based on each state change / where every last ms counts. The only two areas I can think of are renderContext and potentially dimensions (if not throttled, but even that shouldn't be much of an issue anymore).

So, that leaves only renderContext (at least to my current understanding, let me know if you can think of anything else). Unless we can measurably show an issue, then maybe it's not worth refactoring too much of the code base, only to measurably gain in one pretty contained use case.

};
}

const scrollbarSizeCache = new WeakMap<Element, number>();
Copy link
Member

Choose a reason for hiding this comment

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

A minor downside is that the data grid won't properly react to scrollbar size change (e.g. on Mac "Show scroll bars: Automatically => Always).
It's faster though, so let's keep it like this.

@@ -103,7 +102,9 @@ export const filterRowTreeFromTreeData = (
}
}

filteredRowsLookup[node.id] = shouldPassFilters;
if (!shouldPassFilters) {
filteredRowsLookup[node.id] = false;
Copy link
Member

Choose a reason for hiding this comment

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

I spotted 2 places like this where we were still assigning true values, so I narrowed the type in 3449782 (#15648)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch, thanks. Fixed a few checks as well on that front, and tests are passing again for all cases.

@lauri865
Copy link
Contributor Author

lauri865 commented Feb 1, 2025

Since streamlining state updates made detailPanel tests fail, I was forced to fix some measurement propagation issues on that front as well. Now there's no layout shifts when opening up detail panels anymore, and further reduced state updates:

Before:

master-detail-before.mp4

After:

master-detail-after.mp4

@MBilalShafi MBilalShafi self-requested a review February 3, 2025 14:19
@@ -67,4 +73,4 @@ export type GridFilteringMethodValue = Omit<GridFilterState, 'filterModel'>;
* A row is visible if it is passing the filters AND if its parents are expanded.
* If a row is not registered in this lookup, it is visible.
*/
export type GridVisibleRowsLookupState = Record<GridRowId, boolean>;
export type GridVisibleRowsLookupState = Record<GridRowId, false>;
Copy link
Member

Choose a reason for hiding this comment

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

Same change for visibleRowsLookup as previously with filteredRowsLookup.

packages/x-data-grid/src/hooks/utils/useGridSelector.ts Outdated Show resolved Hide resolved
@@ -20,7 +26,7 @@ export interface GridFilterState {
* If a row is not registered in this lookup, it is filtered.
* This is the equivalent of the `visibleRowsLookup` if all the groups were expanded.
*/
filteredRowsLookup: Record<GridRowId, boolean>;
filteredRowsLookup: Record<GridRowId, false>;
Copy link
Member

@MBilalShafi MBilalShafi Feb 4, 2025

Choose a reason for hiding this comment

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

Since we don't store true values now, does it make sense to simplify the type further? It could be slightly better performance wise, and more aligned DX wise, as the filteredRowsLookup no longer stores the "filtered" rows, rather it stores "excluded" ones.

For consistency, a similar concept could be applied on the visible rows (invisibleRows: Set<GridRowId>), column visibility model (hiddenColumns: Set<GridColDef['field']>), etc.

Suggested change
filteredRowsLookup: Record<GridRowId, false>;
excludedRows: Set<GridRowId>;

Copy link
Member

Choose a reason for hiding this comment

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

Changing Record to Set/Map won't necessarily be faster – see #9120 (comment)

Copy link
Contributor

Choose a reason for hiding this comment

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

See #10068 (comment) and #10068 (comment). I think the change is good and has potential for improvement, but I didn't have the bandwidth to investigate it more.

@@ -20,7 +26,7 @@ export interface GridFilterState {
* If a row is not registered in this lookup, it is filtered.
Copy link
Member

@MBilalShafi MBilalShafi Feb 4, 2025

Choose a reason for hiding this comment

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

Suggested change
* If a row is not registered in this lookup, it is filtered.
* All the rows are filtered except the ones registered in this lookup with `false` values.

@cherniavskii cherniavskii requested a review from romgrk February 4, 2025 15:15
Comment on lines +29 to +30
firstColumnIndex: undefined,
lastColumnIndex: undefined,
Copy link
Contributor

Choose a reason for hiding this comment

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

If this value isn't used, can we use -1 as an empty value marker instead? It will avoid changing the shape of those objects.

Comment on lines +85 to +94
const handleColumnHeaderDragStart = useEventCallback(() => {
setDragging(true);
});

const handleColumnHeaderDragEnd = useEventCallback(() => {
setDragging(false);
});

useGridApiEventHandler(apiRef, 'columnHeaderDragStart', handleColumnHeaderDragStart);
useGridApiEventHandler(apiRef, 'columnHeaderDragEnd', handleColumnHeaderDragEnd);
Copy link
Contributor

@romgrk romgrk Feb 4, 2025

Choose a reason for hiding this comment

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

useGridApiEventHandler doesn't need stable functions (with useEventCallback), it already replicates the same logic that useEventCallback implements.

Copy link
Contributor

Choose a reason for hiding this comment

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

This file should probably be under the features/dimensions folder, to keep with existing conventions. You can add an export { ... } from '.../features/dimensions/dimensionSelectors' where appropriate to expose these from @mui/x-data-grid/internals.

Comment on lines +5 to +6
export const gridDimensionsColumnsTotalWidthSelector = (state: GridStateCommunity) =>
state.dimensions.columnsTotalWidth;
Copy link
Contributor

@romgrk romgrk Feb 4, 2025

Choose a reason for hiding this comment

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

You said there is a name conflict with another selector called gridColumnsTotalWidthSelector. Are the two selectors returning the same value, calculated differently? If so, would it be appropriate to remove one of them?

Otherwise, could we rename the other one and keep the short name for this one?

Comment on lines +401 to +406
renderContext?:
| GridRenderContext
| (Pick<GridRenderContext, 'firstRowIndex' | 'lastRowIndex'> & {
firstColumnIndex: undefined;
lastColumnIndex: undefined;
});
Copy link
Contributor

Choose a reason for hiding this comment

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

We could also use -1 for the type.

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.

LGTM beyond the small comments above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
component: data grid This is the name of the generic UI component, not the React module! needs cherry-pick The PR should be cherry-picked to master after merge performance v7.x
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[data grid] performance regression on resizing on v7
6 participants