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] Fix row, cell and header memoizations #15666

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

Conversation

lauri865
Copy link
Contributor

@lauri865 lauri865 commented Nov 28, 2024

Think I managed to pin down everything I reported in #15618

Before:

before-memo.mp4

After:

memo-fixed.mp4

@mui-bot
Copy link

mui-bot commented Nov 28, 2024

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

Generated by 🚫 dangerJS against 57d4f0e

@zannager zannager added the component: data grid This is the name of the generic UI component, not the React module! label Nov 29, 2024
@lauri865
Copy link
Contributor Author

lauri865 commented Nov 29, 2024

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 focusedColumnIndex is only passed to invisible focused rows as others don't need to be aware of that.

@lauri865
Copy link
Contributor Author

lauri865 commented Nov 29, 2024

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 RenderContext, which is what I fixed now.

@lauri865
Copy link
Contributor Author

✅ All tests passing – ready for review.


cellStyle[side] = pinnedOffset;
const pinnedSide = rtlFlipSide(pinnedPosition, isRtl);
if (pinnedSide && pinnedOffset !== undefined) {
Copy link
Contributor

@romgrk romgrk Dec 2, 2024

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.

Copy link
Contributor Author

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:
Screenshot 2024-12-03 at 00 26 17

Comment on lines 95 to 100
const { isIndeterminate, isChecked } = useGridSelector(apiRef, checkboxPropsSelector);
const { isIndeterminate, isChecked } = useGridSelector(
apiRef,
checkboxPropsSelector,
objectShallowCompare,
);
Copy link
Contributor

@romgrk romgrk Dec 2, 2024

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:

// 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.

Copy link
Contributor Author

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?

Copy link
Contributor

@romgrk romgrk Dec 3, 2024

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.

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 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. 🤔

Copy link
Contributor

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.

Copy link
Contributor Author

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.

Comment on lines 207 to 237
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,
);
Copy link
Contributor

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.

Copy link
Contributor

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).

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. 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?

Copy link
Contributor Author

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.

Copy link
Contributor

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

Copy link
Contributor Author

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.

Copy link
Contributor Author

@lauri865 lauri865 Dec 3, 2024

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.

Copy link
Contributor

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.

Copy link
Contributor Author

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? 🤔

Copy link
Contributor

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.

@lauri865
Copy link
Contributor Author

lauri865 commented Dec 3, 2024

Unit tests are passing now, we'll see about browser ones.

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?

const focusedVirtualCell = useGridSelector(
    apiRef,
    gridFocusedVirtualCellSelector,
    objectShallowCompare,
);

@romgrk, let me know if you're happy with the change around useGridVisibleRows / getVisibleRows. If so, we should clean up the current consumers of those methods as well, as they don't need to provide props anymore.

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.

@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged label Dec 4, 2024
Copy link

github-actions bot commented Dec 4, 2024

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

@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged label Dec 4, 2024
@romgrk
Copy link
Contributor

romgrk commented Dec 4, 2024

let me know if you're happy with the change around useGridVisibleRows / getVisibleRows.

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.

@lauri865
Copy link
Contributor Author

lauri865 commented Dec 4, 2024

let me know if you're happy with the change around useGridVisibleRows / getVisibleRows.

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 useGridVisibleRows and getVisibleRows.

@lauri865
Copy link
Contributor Author

lauri865 commented Dec 5, 2024

No idea why datepickers related test is failing, but otherwise we're looking good.

@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged label Dec 11, 2024
Copy link

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

@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged label Dec 19, 2024
@lauri865
Copy link
Contributor Author

lauri865 commented Dec 20, 2024

@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 useGridVirtualScroller. A fairly small change though and keeps the renderContext logic centralised + abolishes the need for any manual overrides to renderContext in multiple places. Happy to roll them back if they should slot into another PR though, but that would block this PR until it's merged.

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
@lauri865 lauri865 force-pushed the fix-memoization branch 3 times, most recently from a89d11a to f5c4b24 Compare December 20, 2024 13:15
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 Dec 23, 2024
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! PR: out-of-date The pull request has merge conflicts and can't be merged
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants