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

[DataGrid] Do not apply filters on rowExpansionChange #8671

Conversation

cherniavskii
Copy link
Member

@cherniavskii cherniavskii commented Apr 19, 2023

Fixes #8579

Before: https://codesandbox.io/s/friendly-gwen-w9ftyn?file=/demo.tsx
After: https://codesandbox.io/s/hardcore-mahavira-n88l5j?file=/demo.tsx

See #8579 (comment) for the issue explanation.
To avoid filtering rows on rowExpansionChange, I extracted visibleRowsLookup out of the filter state:

-state.filter.visibleRowsLookup
+state.visibleRows.lookup

Pros:

  • Filters are not applied on rowExpansionChange

Cons:

  • Before this change, visibleRowsLookup was updated along with state.filter.filteredRowsLookup in the same iteration over state.rows.tree. After this change, we iterate over state.rows.tree again to compute state.visibleRowsLookup.

TODO:

  • Add unit test

@cherniavskii cherniavskii added performance feature: Row grouping Related to the data grid Row grouping feature feature: Aggregation Related to the data grid Aggregation feature component: data grid This is the name of the generic UI component, not the React module! labels Apr 19, 2023
@mui-bot
Copy link

mui-bot commented Apr 19, 2023

Netlify deploy preview

Netlify deploy preview: https://deploy-preview-8671--material-ui-x.netlify.app/

Updated pages

No updates.

These are the results for the performance tests:

Test case Unit Min Max Median Mean σ
Filter 100k rows ms 614.8 1,569 614.8 988.38 344.409
Sort 100k rows ms 532.9 1,223.4 1,223.4 972.04 233.484
Select 100k rows ms 299 401.5 363.9 353.08 39.943
Deselect 100k rows ms 193.7 356.3 250.2 255.92 57.278

Generated by 🚫 dangerJS against f0629a3

filteredDescendantCountLookup: {},
},
};
};

function useGridVisibleRowsLookup(apiRef: React.MutableRefObject<GridPrivateApiCommunity>) {
Copy link
Member Author

Choose a reason for hiding this comment

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

I'm going to move it outside of the useGridFilter hook, it was just faster to do it like this to validate the approach

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

@cherniavskii cherniavskii marked this pull request as ready for review April 20, 2023 18:40
Copy link
Member

@joserodolfofreitas joserodolfofreitas left a comment

Choose a reason for hiding this comment

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

Wow, the performance step is impressive! Nice work!

@github-actions
Copy link

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged label May 18, 2023
@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged label May 22, 2023
@MBilalShafi
Copy link
Member

MBilalShafi commented May 24, 2023

Great work @cherniavskii, it does seem to solve the problem, though I think we would require to apply filtering and aggregation in some use cases, for example when we lazy-load row children, we will need to reapply filtering and aggregation to re-validate the stale data. (But we can do that on demand too, let's say when the promise for fetching new rows is resolved, not sure)

After digging a bit into the problem, I found the problem went all the way down to the applyAggregation method, which recomputes state.aggregation.lookup on each call (even when not needed):

const applyAggregation = React.useCallback(() => {
const aggregationLookup = createAggregationLookup({
apiRef,
getAggregationPosition: props.getAggregationPosition,
aggregationFunctions: props.aggregationFunctions,
aggregationRowsScope: props.aggregationRowsScope,
});
apiRef.current.setState((state) => ({
...state,
aggregation: { ...state.aggregation, lookup: aggregationLookup },
}));
}, [
apiRef,
props.getAggregationPosition,
props.aggregationFunctions,
props.aggregationRowsScope,
]);

One way to solve this could be to only recompute the state.aggregation.lookup on demand, something like:

diff --git a/packages/grid/x-data-grid-premium/src/hooks/features/aggregation/useGridAggregation.ts b/packages/grid/x-data-grid-premium/src/hooks/features/aggregation/useGridAggregation.ts
index 2a9e12601..d1b2f75b1 100644
--- a/packages/grid/x-data-grid-premium/src/hooks/features/aggregation/useGridAggregation.ts
+++ b/packages/grid/x-data-grid-premium/src/hooks/features/aggregation/useGridAggregation.ts
@@ -70,21 +70,38 @@ export const useGridAggregation = (
   );
 
   const applyAggregation = React.useCallback(() => {
-    const aggregationLookup = createAggregationLookup({
-      apiRef,
-      getAggregationPosition: props.getAggregationPosition,
-      aggregationFunctions: props.aggregationFunctions,
-      aggregationRowsScope: props.aggregationRowsScope,
-    });
-
-    apiRef.current.setState((state) => ({
-      ...state,
-      aggregation: { ...state.aggregation, lookup: aggregationLookup },
-    }));
+    const { rulesOnLastRowHydration } = apiRef.current.caches.aggregation;
+
+    const aggregationRules = props.disableAggregation
+      ? {}
+      : getAggregationRules({
+          columnsLookup: gridColumnLookupSelector(apiRef),
+          aggregationModel: gridAggregationModelSelector(apiRef),
+          aggregationFunctions: props.aggregationFunctions,
+        });
+
+    if (
+      !areAggregationRulesEqual(rulesOnLastRowHydration, aggregationRules) ||
+      !apiRef.current.state.aggregation.lookup
+    ) {
+      const aggregationLookup = createAggregationLookup({
+        apiRef,
+        getAggregationPosition: props.getAggregationPosition,
+        aggregationFunctions: props.aggregationFunctions,
+        aggregationRowsScope: props.aggregationRowsScope,
+      });
+
+      apiRef.current.setState((state) => ({
+        ...state,
+        aggregation: { ...state.aggregation, lookup: aggregationLookup },
+      }));
+    }
   }, [
     apiRef,
-    props.getAggregationPosition,
+    props.disableAggregation,
     props.aggregationFunctions,
+    props.getAggregationPosition,
     props.aggregationRowsScope,
   ]);

This is definitely not an ultimate fix as it partially solves the problem of not re-computing the aggregation lookup on rows expansion change, but it causes another bug, i.e. now the grid is not responding properly to the aggregation function change.

Another Dimension:

On the codesandbox where the package for this PR is installed, I played around and I observed that the page was not anymore stuck on row expansion change, but when I tried to change an aggregation function, it blocked the UI thread for a similar amount of time:
aggregation

I think the reason is that our diffing algorithm is not smart enough to check, "ok, so out of 100 columns, the aggregation functions for column 1 has changed, let's go ahead and only re-compute the aggregation lookup for this column", rather it recomputes all the data once any column updates it's aggregation method.

So maybe this direction could be a better one:

  1. Optimize the diffing algorithm to compute the specific sub-part of aggregation we need to compute (to solve problem 2)
  2. Apply this diffing also when we call applyAggregation on filteredRowsSet. (to solve the original problem)

What do you think?

@@ -17,6 +17,7 @@ import {
createRowTree,
Copy link
Member Author

Choose a reason for hiding this comment

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

@MBilalShafi

On the codesandbox where the package for this PR is installed, I played around and I observed that the page was not anymore stuck on row expansion change, but when I tried to change an aggregation function, it blocked the UI thread for a similar amount of time:

I think the reason is that our diffing algorithm is not smart enough to check, "ok, so out of 100 columns, the aggregation functions for column 1 has changed, let's go ahead and only re-compute the aggregation lookup for this column", rather it recomputes all the data once any column updates it's aggregation method.

That's a very good point, I'm sure we can optimize aggregation (especially when there's a lot of aggregated columns) 👍

visibleRows: visibleRowsState,
};
});
apiRef.current.forceUpdate();
Copy link
Member

Choose a reason for hiding this comment

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

Wouldn't be easier to understand if we updated the visibleRows state also inside

const updateFilteredRows = React.useCallback(() => {
and remove the filteredRowsSet event? We can make an exception and have a single hook controlling two states. This apiRef.current.forceUpdate call here but not in the filtering hook seems inconsistent. We don't forceUpdate there but expect that one listener of filteredRowsSet will call it. I had some problems in #9112 where, in one of the solutions I tried, I removed a forceUpdate from a method that was doing nothing and one test started to fail. I think it would be better if we tried to rely less on event listeners and make the code less "connected". At the end we need:

  1. update both states when applyFilters is called
  2. update only visibleRows if a row is expanded

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point, it will be easier to follow 👍

For the filteredRowsSet event, it will still be used by the aggregation hook.

Comment on lines 42 to 46
const getVisibleRows: GridStrategyProcessor<'visibleRows'> = (params) => {
return {
lookup: getVisibleRowsLookup(params),
};
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need to wrap in a return object?

Copy link
Member Author

Choose a reason for hiding this comment

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

Not really. I added a wrapper when I extracted the state management to a separate hook, but now that it's coupled with the filter state - I have removed the wrapper and renamed visibleRows to visibleRowsLookup 1aca18c (#8671)

Copy link
Member

@MBilalShafi MBilalShafi left a comment

Choose a reason for hiding this comment

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

A very nice optimization 🚀
Great work!

@@ -0,0 +1,12 @@
import type { GridRowId } from '../../../models/gridRows';
Copy link
Member

Choose a reason for hiding this comment

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

Nit: Should this interface and gridVisibleRowsSelectors be moved in close proximity to the rest of the code hooks/features/filter/gridFilterState or models/gridVisibleRows for better organization of files as it's being used in useGridFilter?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, I'll move everything back to the filtering files (it's where it was originally anyway, before the state split)

@cherniavskii cherniavskii added the feature: Tree data Related to the data grid Tree data feature label May 31, 2023

visibleRowsLookup[node.id] = isPassingFiltering && areAncestorsExpanded;

// TODO rows v6: Should we keep storing the visibility status of footer independently or rely on the group visibility in the selector ?
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you have any opinions on this comment? Are footers sometimes hidden?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, you can have aggregation footer for each row group - see https://mui.com/x/react-data-grid/aggregation/#AggregationGetAggregationPosition.tsx

When the group is filtered out, the aggregation footer should be hidden as well.

Copy link
Contributor

Choose a reason for hiding this comment

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

IIUC, the visibility of the group is always equal to the visibility of the footer, right?

Copy link
Member Author

Choose a reason for hiding this comment

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

Not exactly, you can have a visible row group, but if it's not expanded - the footer row shouldn't be visible:

visibleRowsLookup[node.footerId] =
isPassingFiltering && areAncestorsExpanded && !!node.childrenExpanded;

row-group-footer.mov

Comment on lines 57 to 62
/**
* Visibility status for each row.
* 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 interface GridVisibleRowsLookupState extends Record<GridRowId, boolean> {}
Copy link
Contributor

Choose a reason for hiding this comment

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

Just fyi, you may have seen in my filtering PR, but I was hoping to convert both the filteredRows & visibleRows from objects into Sets as it improves performance for large datasets.

Minor note on the type, I would find type GridVisibleRowsLookupState = Record<GridRowId, boolean> more semantically accurate. We're not building a new interface, we're defining what the shape of that state is, and it is a record.

Copy link
Member Author

Choose a reason for hiding this comment

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

Just fyi, you may have seen in my filtering PR, but I was hoping to convert both the filteredRows & visibleRows from objects into Sets as it improves performance for large datasets.

Yes, but that would be a breaking change, right?
Unless we do something like this:

/**
 * For backward compatibility only, in case if anyone is using `gridVisibleRowsLookupSetSelector`
 * @deprecated Use `gridVisibleRowsLookupSetSelector` instead
 */
export const gridVisibleRowsLookupSelector = (state: GridStateCommunity) => setToObject(state.visibleRowsLookup);

// TODO v7: rename to `gridVisibleRowsLookupSelector`
export const gridVisibleRowsLookupSetSelector = (state: GridStateCommunity) => state.visibleRowsLookup;

Copy link
Member Author

Choose a reason for hiding this comment

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

Minor note on the type, I would find type GridVisibleRowsLookupState = Record<GridRowId, boolean> more semantically accurate.

Updated 👍

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes for the change, that's what I'm planning to do for the selectors.

@cherniavskii cherniavskii merged commit 4ecaf68 into mui:master Jun 1, 2023
4 checks passed
@cherniavskii cherniavskii deleted the do-not-apply-filters-on-rowExpansionChange branch June 1, 2023 09:48
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! feature: Aggregation Related to the data grid Aggregation feature feature: Row grouping Related to the data grid Row grouping feature feature: Tree data Related to the data grid Tree data feature performance
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[data grid] Recomputation of all aggregations with each interaction
6 participants