-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
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] Fix row, cell and header memoizations #15666
base: master
Are you sure you want to change the base?
Conversation
Deploy preview: https://deploy-preview-15666--material-ui-x.netlify.app/ |
Now also fixed rows having to re-render on focus/tabindex change, as that responsibility already falls to the cells. This removed an extra call to getCellParams on a row level to each cell. And |
I moved Grid Header, Body and Footer to GridRoot and memoized GridRoot. I'm not sure it has any significant performance improvements, however, currently grid state change triggers the re-rendering of the whole tree. Which makes it very difficult to debug why each component re-rendered, as, unless they're memoized, they will automatically re-render because the parent re-render. But without the change, it also makes use of any selectors in the non-memoized tree (e.g. within VirtualScroller) pure overhead, as they could be reading the root state to begin with. As a direct result of this change, I could easily debug that each focus change causes the whole VirtualScroller to re-render, even if the RenderContext doesn't change. The cause was in the virtual focus cell implementation, whereby VirtualScroller reacted to each focus change, even though in reality, it only needs to concern about cases where focus is outside of the |
✅ All tests passing – ready for review. |
packages/x-data-grid/src/hooks/features/columns/gridColumnsInterfaces.ts
Outdated
Show resolved
Hide resolved
packages/x-data-grid-pro/src/components/headerFiltering/GridHeaderFilterCell.tsx
Outdated
Show resolved
Hide resolved
|
||
cellStyle[side] = pinnedOffset; | ||
const pinnedSide = rtlFlipSide(pinnedPosition, isRtl); | ||
if (pinnedSide && pinnedOffset !== undefined) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could that condition be expressed in terms of pinnedPosition
instead? And would that allow us to keep the type of pinnedOffset: number
instead of ?: number
? And could we move the flip logic inside the if check? That would also simplify rtlFlipSide
as it would receive a PinnedColumnPosition
instead of a PinnedColumnPosition | undefined
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I actually did it like that initially, but it felt wrong to me. Why create a value that shouldn't exist – could end up in adding accidental inline styles for non-pinned columns that should never exist. The type of pinnedOffset
could be constrained to only exist if pinnedPoisition
is left/right though.
Moving the rtlFlipSide could make sense, albeit it currently also narrows the type of pinnedPosition – if pinnedSide is defined, pinnedPosition can only be left or right, so maybe not much of a change:
packages/x-data-grid/src/components/columnHeaders/GridColumnHeaderItem.tsx
Outdated
Show resolved
Hide resolved
packages/x-data-grid/src/hooks/features/columnHeaders/useGridColumnHeaders.tsx
Show resolved
Hide resolved
const { isIndeterminate, isChecked } = useGridSelector(apiRef, checkboxPropsSelector); | ||
const { isIndeterminate, isChecked } = useGridSelector( | ||
apiRef, | ||
checkboxPropsSelector, | ||
objectShallowCompare, | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you have time, the proper solution should be to fix the TODO comment here:
mui-x/packages/x-data-grid/src/hooks/features/rowSelection/utils.ts
Lines 49 to 50 in 90c1fc2
// TODO v8: Use `createSelectorV8` | |
export function getCheckboxPropsSelector(groupId: GridRowId, autoSelectParents: boolean) { |
We now have support for selectors with arguments, which is what the checkbox selectors were missing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might be misunderstanding the new selectors as there's no documentation and only one implementation so far in the code base, but wouldn't it still spit out a new object and trigger a new render, even if the object values stay the same?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
createSelectorMemoizedV8
would memoize its output based on its dependencies, e.g.
const checkboxSelector = createSelectorMemoizedV8(
aSelector,
bSelector,
cSelector,
(a, b, c, args) => { return { a, b, c } /* this result object would be memoized */ }
)
Though I think @MBilalShafi might handle it as part of #15200, so you could ignore this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This part I understand, but wouldn't it only work if a selector returns a slice of the state. In this case we're dealing with derived state, and creating a new object
In this case, you have two options:
- Use a selector that returns the derived state – return value never holds a stable reference, memoization fails.
- Put the state derivation logic in the last function – last function gets triggered also when unrelated rows get selected/deselected. Last function still creates an object with unstable object reference, everything still re-renders when other rows re-render.
So, while it fixes unrelated state triggering re-renders, it still doesn't enable us to hone in on the specific row's state. Either we need to not return an object if the row is not selected, or still need objectShallowCompare to memoize it. 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this case we're dealing with derived state, and creating a new object
Is it clear to you that the derived state (aka the new object) is memoized by createSelectorMemoized
? So it stays stable across state updates until one of its dependency (in the example above, aSelector
, bSelector
and cSelector
) changes. That function is similar to React.useMemo
in its behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok, I think I understand now after looking at the source code. Args are also passed nested into all of the subselectors, in which case gridSortedRowIdsSelector
needs to return the correct slice based on the args.
I was first under the impression that args are only passed to the last function that takes together the selector results, in which case it would be triggered by unrelated gridSortedRowIdsSelector
changes, and we would create a new object every time.
But if args are shared naively across any nested selectors, how do you plan to avoid any conflicts? Seems like something that needs utmost caution if used widely.
packages/x-data-grid/src/components/columnSelection/GridHeaderCheckbox.tsx
Outdated
Show resolved
Hide resolved
const focusedVirtualCell = useGridSelector( | ||
apiRef, | ||
() => { | ||
const currentRenderContext = gridRenderContextSelector(apiRef); | ||
const focusedCell = gridFocusCellSelector(apiRef); | ||
if (!focusedCell) { | ||
return null; | ||
} | ||
|
||
const rowIndex = currentPage.rows.findIndex((row) => row.id === focusedCell.id); | ||
const columnIndex = visibleColumns.findIndex((column) => column.field === focusedCell.field); | ||
|
||
if (rowIndex === -1 || columnIndex === -1) { | ||
return null; | ||
} | ||
|
||
const isFocusedCellInContext = | ||
currentRenderContext.firstRowIndex <= rowIndex && | ||
rowIndex <= currentRenderContext.lastRowIndex; | ||
|
||
if (isFocusedCellInContext) { | ||
return null; | ||
} | ||
return { | ||
...focusedCell, | ||
rowIndex, | ||
columnIndex, | ||
}; | ||
}, | ||
objectShallowCompare, | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another case of "should be a selector with arguments", but this is low-impact, so you could leave a TODO comment and I'm ok with it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Though now that I re-read, those findIndex()
calls aren't great. Those are O(n)
costs that are called everytime the state updates, I think it would be preferable to memoize that logic somehow (either through createSelectorMemoized
or React.useMemo
).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch. Reduced the lookup cost now, but if you have any ideas on how to make it even better, I'm all ears. Would rowIdToRowIndex
lookup map make any sense?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pushed another alternative with lookup maps.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I don't love the lookup maps, they add an O(n)
cost (plus a bunch of memory I/O) when the rows are updated to build the lookup map. For the use-cases where rows are updated frequently, it could have an effect. I would favor going back to memoization. If you removed React.useMemo
, I guess selector memoization would be the alternative. Here is an example how I think it could work (some of the dependency selectors are placeholders, can't remember the right names atm):
https://gist.github.com/romgrk/7f7a682578a1c5f2bbcd8b82aef3148e
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pushed a quick example of what I'd do – lookupMap creation takes 10ms in dev for 100k rows under page load (probably less under normal conditions). Would take anywhere from 60-100% longer if we used rowId as the map key instead of object reference, and consume much more memory with uuid ids.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Forgot to update the selector itself, which can now be improved thanks to the change to useGridVisibleRows. But you get the gist.
Since currentPage wasn't really a selector before, it didn't work with memoized selectors.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This selector needs objectShallowCompare still, since it revalidates every time renderContextSelector changes, however, the resulting derived state doesn't always change when renderContextSelector changes, but we can't provide a stable object reference. Would it be different with the v8 selectors?
The trick would be to create intermediate selectors, so that you can compute e.g. isFocusedCellInContext
when renderContext
changes, but the value of isFocusedCellInContext
would stay the same which wouldn't recompute the final focusedVirtualCell
selector. I think columnIndex
would also need to come from an intermediate selector.
You can keep the intermediate selectors in the new file, no need to expose every selector publicly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would intermediate selectors also have to be memoized or is it enough if the parent selector is? 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A selector should be memoized if either of these is true:
- The selector returns a non-primitive value (object, array, set, map, ...)
- The selector is computationally expensive (e.g.
O(n)
operation)
So for example the isFocusedCellInContextSelector
would not need to be memoized, because it returns a boolean which is referentially equal across runs.
Unit tests are passing now, we'll see about browser ones. This selector needs const focusedVirtualCell = useGridSelector(
apiRef,
gridFocusedVirtualCellSelector,
objectShallowCompare,
); @romgrk, let me know if you're happy with the change around This change forced me to reorder state initialization as well – visible rows are dependent on pagination, which has always been the case, but didn't surface before due to props being passed around. |
This pull request has conflicts, please resolve those before we can evaluate the pull request. |
5940d75
to
e357324
Compare
Very happy, there's so much refactoring that I'd like to do and not enough time. Not sure I'd refactor every consumer right now, this PR is already large. You can leave a TODO note and/or open a tracking github issue for that. |
Makes sense. Already left a to-do note on |
No idea why datepickers related test is failing, but otherwise we're looking good. |
This pull request has conflicts, please resolve those before we can evaluate the pull request. |
f753c2c
to
836f5ea
Compare
@romgrk, just fyi, I'm going on a parental leave beginning of next year, in case you need any additional input from me to get this merged. The last commits are a bit offtopic, but fixes some issues I hit during rebasing that were introduced by another PR. The issues weren't caught by unit tests due to an extraneous check for virtualization that hid them from unit tests. I only discovered the issues because I needed some props to live within |
fix memo (grid header, cells, rows) fix tests fix row re-rendering on cell focus change fix gridheaders re-rendering on each focus change move grid headers, body and footer to GridRoot, and memoize GridRoot fix VirtualScroller re-rendering on each focus change fix virtualRow in pinned rows fix focusedVirtualCell potentially depending on stale rendercontext undo some testing changes docs:api whoopsie fix pinned skeleton overlays move PinnedPosition enum to internals rename to PinnedColumnPosition rebuild docs api Update packages/x-data-grid/src/components/containers/GridRoot.tsx Co-authored-by: Rom Grk <romgrk@users.noreply.github.com> Signed-off-by: Lauri <lauri.lehtmaa@gmail.com> fastmemo undo erroneous change gridheaderfilter styles change reduce lookup cost alternative lookup with maps fix useGridVisibleRows memoization don't filter rows is there's no depth fix filter selector make it nicer test with row lookupMap unit tests passing + clean up a bit docs:api attachPinnedStyle lint fix up virtual focus cell selector pass only necessary dimensions into the GridRow
a89d11a
to
f5c4b24
Compare
This pull request has conflicts, please resolve those before we can evaluate the pull request. |
Think I managed to pin down everything I reported in #15618
Before:
before-memo.mp4
After:
memo-fixed.mp4