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] Improve resize performance #15549

Merged
merged 9 commits into from
Nov 25, 2024

Conversation

lauri865
Copy link
Contributor

@lauri865 lauri865 commented Nov 21, 2024

We noticed that our sidebar animation lags heavily with every page that have a Datagrid on them, even with pretty generous throttling. The root cause is in how resizing is handled currently within Datagrid. Namely, even though it uses ResizeObserver, it disregards the already cached measurements and triggers new measurements with getComputedStyle (could be getBoundingClientRect to begin with, since it's only used to get the dimensions). Which triggers a new measurement, and leads to unnecessary trashing. With a smooth animation, the result is... laggy.

const computedStyle = ownerWindow(element).getComputedStyle(element);

Instead, we can use the cached contentRect values directly from the resize observer, and trigger a resize event from there.

I also moved the responsibility to publishing the event to useGridVirtualScroller.tsx, as it's responsible for the mainElementRef. Attaching a resize observer from gridDimensions would have been inefficient and/or needed a breaking change of the resize api method.

Since the resize method is no-longer used internally, or even useful for anything (from what I can see), I suggest removing it in the future versions to save some bundle size. Resizing can be triggered by resizing the parent container. Didn't do it right now, as that would have been a breaking change, and I wanted to keep this PR simple.

Note, the ref has a cleanup function that only has an effect from React 19 (but shouldn't cause any issues in earlier versions). It's not strictly necessary as it gets cleared when the node detaches automatically (correct me if I'm wrong), but included it anyways for good hygiene. Didn't add any backward compatible cleaning to save some bundle size and avoid detached logic (would need to add a separate ref for the observe and a useEffect for it).

Our animations are buttery smooth now, even with a page that has many datagrids on it.

Should also address some of the issues in #12908 – a datagrid with variable height (autoHeight or flex parent), would cause unnecessary trashing.

@mui-bot
Copy link

mui-bot commented Nov 21, 2024

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

Generated by 🚫 dangerJS against 0880afb

@lauri865
Copy link
Contributor Author

lauri865 commented Nov 22, 2024

On the topic of #12908 – this seems to fix all the remaining issues I have with the mount performance:

if (!deepEqual(prevDimensions, newDimensions)) {
    setDimensions(newDimensions);
    apiRef.current.updateRenderContext?.();
}

In:

const prevDimensions = apiRef.current.state.dimensions;
setDimensions(newDimensions);
if (!areElementSizesEqual(newDimensions.viewportInnerSize, prevDimensions.viewportInnerSize)) {
apiRef.current.publishEvent('viewportInnerSizeChange', newDimensions.viewportInnerSize);
}
apiRef.current.updateRenderContext?.();

(definitely much faster page mounts with no visible delays anymore)

@lauri865
Copy link
Contributor Author

I found an even bigger performance issue, but it only affects React 19. Which I understand doesn't have official support yet, but good to keep this in mind.

Repro: https://drw2n4.csb.app/
Source: https://codesandbox.io/embed/8x7qdm?module=/src/Demo.tsx&fontsize=12

Turn on devtools + highlight on changes + resize browser to see how everything rerenders. Rootprops changes on every render. Just for the mere existance of a ref prop with undefined value.

Props is not referentially stable in conjunction with React.forwardRef. I'm not sure if it's a bug or a know issue in how they made ForwardRef backwards compatible, but it has some implications on how Mui itself should offer backwards compatibility, as just keeping ForwardRef around as is may not be a good idea.

cc @romgrk, @cherniavskii

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.

This approach looks great.

@romgrk
Copy link
Contributor

romgrk commented Nov 22, 2024

Can you try to appease the CI? You'll want to run pnpm run prettier and probably pnpm run eslint:ci --fix, at least.

@romgrk romgrk added performance component: data grid This is the name of the generic UI component, not the React module! labels Nov 22, 2024
@romgrk
Copy link
Contributor

romgrk commented Nov 22, 2024

I found an even bigger performance issue, but it only affects React 19. Which I understand doesn't have official support yet, but good to keep this in mind.

Repro: https://drw2n4.csb.app/ Source: https://codesandbox.io/embed/8x7qdm?module=/src/Demo.tsx&fontsize=12

Turn on devtools + highlight on changes + resize browser to see how everything rerenders. Rootprops changes on every render. Just for the mere existance of a ref prop with undefined value.

Props is not referentially stable in conjunction with React.forwardRef. I'm not sure if it's a bug or a know issue in how they made ForwardRef backwards compatible, but it has some implications on how Mui itself should offer backwards compatibility, as just keeping ForwardRef around as is may not be a good idea.

cc @romgrk, @cherniavskii

That might be something worth reporting to React :| If they made forwardRef unstable that's an issue.

@lauri865
Copy link
Contributor Author

I found an even bigger performance issue, but it only affects React 19. Which I understand doesn't have official support yet, but good to keep this in mind.
Repro: https://drw2n4.csb.app/ Source: https://codesandbox.io/embed/8x7qdm?module=/src/Demo.tsx&fontsize=12
Turn on devtools + highlight on changes + resize browser to see how everything rerenders. Rootprops changes on every render. Just for the mere existance of a ref prop with undefined value.
Props is not referentially stable in conjunction with React.forwardRef. I'm not sure if it's a bug or a know issue in how they made ForwardRef backwards compatible, but it has some implications on how Mui itself should offer backwards compatibility, as just keeping ForwardRef around as is may not be a good idea.
cc @romgrk, @cherniavskii

That might be something worth reporting to React :| If they made forwardRef unstable that's an issue.

I did: facebook/react#31613

I agree, it's an issue. They didn't deprecate it, and it's not behaving the same way either. It was a major pain to actually debug this issue. Even more so, because the ref wasn't actually set, and not visible in the props, so it seemed like props were stable, but memoizations broke anyways.

@lauri865
Copy link
Contributor Author

lauri865 commented Nov 22, 2024

Hope the CI is happy now.

Found out why all the grid header cells render on every resize as well, even if the columns don't need to resize. Every column is passed sectionLength only to calculate whether the right border needs to be applied – shouldn't it be done on the GridHeaderRow level and then passed as a boolean to the header cell instead (cc @cherniavskii)? Also, the style prop seems to trigger a re-render for pinned columns (but haven't debugged what's changing there, because the resulting DOM node is identical)

const showRightBorder = shouldCellShowRightBorder(
pinnedPosition,
indexInSection,
sectionLength,
rootProps.showColumnVerticalBorder,
gridHasFiller,
);

Pinned column:

@lauri865
Copy link
Contributor Author

If sectionLength was replaced by isLastVisibleInSection: boolean, then GridHeaderItems wouldn't have to re-render on every resize / renderContext change. shouldCellShowRightBorder would need to be adjusted accordingly.

If style prop was replaced from object to pinnedOffset, similar to the GridCell implementation, the re-renderings would be fixed for those as well. Right now pinned GridHeaderItems re-render every time something changes, since a new object is created for the style prop, and fastMemo doesn't support deep equality.

@lauri865
Copy link
Contributor Author

After we applied all the fixes locally, we're getting pretty sweet performance on page navigation as well as resizing now – instant feedback to keyboard/pointer input. Even Safari is fast. Used to be pretty jittery w/ dropped frames. And this is with scroll restoration + charts + images + the 2nd page has 5 datagrids.

Spamming enter+escape, no browser history/snapshots involved – UI stress test passed:
https://github.com/user-attachments/assets/caabe347-3036-4109-8af5-3122ae5bf8e3

@cherniavskii cherniavskii added needs cherry-pick The PR should be cherry-picked to master after merge v7.x labels Nov 22, 2024
@cherniavskii cherniavskii self-requested a review November 22, 2024 17:54
@cherniavskii
Copy link
Member

I found an even bigger performance issue, but it only affects React 19. Which I understand doesn't have official support yet, but good to keep this in mind.

Repro: drw2n4.csb.app Source: codesandbox.io/embed/8x7qdm?module=/src/Demo.tsx&fontsize=12

Turn on devtools + highlight on changes + resize browser to see how everything rerenders. Rootprops changes on every render. Just for the mere existance of a ref prop with undefined value.

Props is not referentially stable in conjunction with React.forwardRef. I'm not sure if it's a bug or a know issue in how they made ForwardRef backwards compatible, but it has some implications on how Mui itself should offer backwards compatibility, as just keeping ForwardRef around as is may not be a good idea.

cc @romgrk, @cherniavskii

The behavior is the same with React 18, no?
https://codesandbox.io/p/sandbox/distracted-hill-7prk99

Copy link
Member

@cherniavskii cherniavskii left a comment

Choose a reason for hiding this comment

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

Thank you!
I made some minor changes to make the CI pass 👍🏻

@cherniavskii cherniavskii requested a review from romgrk November 22, 2024 21:03
@lauri865
Copy link
Contributor Author

lauri865 commented Nov 22, 2024

I found an even bigger performance issue, but it only affects React 19. Which I understand doesn't have official support yet, but good to keep this in mind.
Repro: drw2n4.csb.app Source: codesandbox.io/embed/8x7qdm?module=/src/Demo.tsx&fontsize=12
Turn on devtools + highlight on changes + resize browser to see how everything rerenders. Rootprops changes on every render. Just for the mere existance of a ref prop with undefined value.
Props is not referentially stable in conjunction with React.forwardRef. I'm not sure if it's a bug or a know issue in how they made ForwardRef backwards compatible, but it has some implications on how Mui itself should offer backwards compatibility, as just keeping ForwardRef around as is may not be a good idea.
cc @romgrk, @cherniavskii

The behavior is the same with React 18, no? https://codesandbox.io/p/sandbox/distracted-hill-7prk99

Nope, in React 18 only rows re-render (which make sense, since they need to be width-aware), but not all cells and everything that depends on rootProps. The repro I posted under react's repo should show the difference between 18 vs. 19.

Except for the the Checkbox cell, which I haven't looked into myself yet, but that one seems to re-render in all versions.

@cherniavskii
Copy link
Member

Nope, in React 18 only rows re-render

Right 👍🏻

The repro I posted under react's repo should show the difference between 18 vs. 19.

BTW, these repros are private, so I can't access them 😅

@cherniavskii
Copy link
Member

On the topic of #12908 – this seems to fix all the remaining issues I have with the mount performance:

if (!deepEqual(prevDimensions, newDimensions)) {
    setDimensions(newDimensions);
    apiRef.current.updateRenderContext?.();
}

In:

const prevDimensions = apiRef.current.state.dimensions;
setDimensions(newDimensions);
if (!areElementSizesEqual(newDimensions.viewportInnerSize, prevDimensions.viewportInnerSize)) {
apiRef.current.publishEvent('viewportInnerSizeChange', newDimensions.viewportInnerSize);
}
apiRef.current.updateRenderContext?.();

(definitely much faster page mounts with no visible delays anymore)

I can't see any noticeable performance gains with this change.
Is it faster for you in Safari?

@lauri865
Copy link
Contributor Author

lauri865 commented Nov 22, 2024

On the topic of #12908 – this seems to fix all the remaining issues I have with the mount performance:

if (!deepEqual(prevDimensions, newDimensions)) {
    setDimensions(newDimensions);
    apiRef.current.updateRenderContext?.();
}

In:

const prevDimensions = apiRef.current.state.dimensions;
setDimensions(newDimensions);
if (!areElementSizesEqual(newDimensions.viewportInnerSize, prevDimensions.viewportInnerSize)) {
apiRef.current.publishEvent('viewportInnerSizeChange', newDimensions.viewportInnerSize);
}
apiRef.current.updateRenderContext?.();

(definitely much faster page mounts with no visible delays anymore)

I can't see any noticeable performance gains with this change. Is it faster for you in Safari?

Well, it depends on the test case. With a single datagrid where data is immediately available – unlikely.

With a page where multiple datagrids (in our case 5) render in parallel and have loading states, autoHeight, etc. – definitely see a difference.

I'm not sure it's the root cause of the issue though, but alleviates the symptoms for us at least for now.

It could well be that this flushSync call could cause some issues for us on pages with multiple datagrids (but that's just a hunch, and haven't had time to study/test it thoroughly):

// Prevents batching render context changes
ReactDOM.flushSync(() => {
updateRenderContext(nextRenderContext);
});

Might be a good idea to avoid flushSync calls, unless it's in direct reaction to user interaction (e.g. focus management), at least in my experience. Even though it might make the event loop easier in one place, you might unintentionally affect the rendering of other things on the page.

Edit: correction, flushSync only affects scroll in this case, was on a wrong path there.

@lauri865
Copy link
Contributor Author

lauri865 commented Nov 22, 2024

Have to correct myself, the flush sync call doesn't affect mount at all, only scroll.

However, removing flushSync does seem to improve scroll performance on Safari:

With flush sync:
https://github.com/user-attachments/assets/a2c1f455-a3db-4d14-8936-74628a6d4f93

Without flush sync:
https://github.com/user-attachments/assets/a144d5ae-9f3a-4f5d-b0d2-2f87c2f1095a

The only caveat is that it added a barely perceptible flash when restoring scroll. But it might be our implementation that's breaking there. If we run flushSync on first scroll, all is the same, but investigating further.

Edit: fixed our scroll restoration.

@lauri865
Copy link
Contributor Author

lauri865 commented Nov 23, 2024

@romgrk, do you remember what's the rationale of the flushSync call?

From my testing:

  • Smooth scroll is visibly better without flushSync (especially in Safari, where banding is virtually eliminated, looking almost as good as Chrome now)
  • Scrolling while hovering the scrollbar feels smoother
  • What's measurably worse: renderContext jumps, either due to dragging the scrollbar or calling scrollToIndexes – results in visible flash of content.

So, maybe the better solution would be to call flushSync only if the next renderContext skips over some between the next and current renderContext? Just a thought.

Side note – this seems duplicated:

apiRef.current.publishEvent('columnsChange', columnsState.orderedFields);
apiRef.current.updateRenderContext?.();

useGridApiEventHandler(apiRef, 'columnsChange', forceUpdateRenderContext);

@lauri865
Copy link
Contributor Author

lauri865 commented Nov 23, 2024

My naive implementation, which seems to solve the flashing, yet enables avoiding flushSync when scrolling smoothly:

const hasPreviousContext = previousRowContext.current.lastRowIndex !== 0;
const isJumpingOverRows = nextRenderContext.firstRowIndex > previousRowContext.current.lastRowIndex || nextRenderContext.lastRowIndex < previousRowContext.current.firstRowIndex;
const isJumpingOverColumns = nextRenderContext.firstColumnIndex > previousRowContext.current.lastColumnIndex || nextRenderContext.lastColumnIndex < previousRowContext.current.firstColumnIndex;

if (hasPreviousContext && (isJumpingOverRows || isJumpingOverColumns)) {
  ReactDOM.flushSync(() => {
    updateRenderContext(nextRenderContext);
  });
}
else {
  updateRenderContext(nextRenderContext);
}

Very happy with the performance of this in all browsers.

@romgrk
Copy link
Contributor

romgrk commented Nov 23, 2024

I haven't looked into flushSync, it was there before I was. I'm guessing on slower browsers the effect is bad because it triggers multiple renders where otherwise a single render might have gone through after batching.

I'm happy with your solution, I would also be ok with passing an enum UpdateSource there to flush-sync depending on that. Does it make sense to include those changes in this PR or do you prefer to open a separate one?

Comment on lines +173 to +181
observer.observe(node);

if (reactMajor >= 19) {
return () => {
mainRef.current = null;
observer.disconnect();
};
}
return undefined;
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this really correct? We're not disconnecting on react <= 18?

Copy link
Member

Choose a reason for hiding this comment

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

Right, ref cleanup is only supported in React 19.
Also, I'm checking the React major because React 18 logs this error: https://github.com/facebook/react/blob/f1338f8080abd1386454a10bbf93d67bfe37ce85/packages/react-reconciler/src/ReactFiberCommitWork.new.js#L292-L295
I can add a cleanup effect for older versions.

Copy link
Contributor

Choose a reason for hiding this comment

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

This issue wasn't addressed, I think this might be leaking memory.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, I forgot about this one.
I'll open a PR with a cleanup effect.

Copy link
Contributor

Choose a reason for hiding this comment

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

Btw I would skip the version checking and use a single strategy, to keep the code simple.

Copy link
Member

Choose a reason for hiding this comment

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

Verify that no detached nodes are left behind.

How do you verify this?
I see retained detached nodes in Chrome Devtools, even with the resize observer commented out in useGridVirtualScroller:

Screen.Recording.2024-11-25.at.17.47.11.mov

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Try here: https://w8n3kg.csb.app/
Source: https://codesandbox.io/p/sandbox/w8n3kg

Whole Datagrid was a bad example perhaps. There may or may not be a memory leak somewhere else there. I've spent good deal of time trying to debug this in our own app, and I'm not sure at this point. Could be that it's very slow to garbage collect (which I don't understand why – (is it the DOM tree size?) and running a profile too quickly creates a reference to the detached node, which in turn prevents garbage collection). But not sure either if and when it would automatically get garbage collected. Bugs in devotools doesn't make it a joy to debug.

See: #15459 (comment)

Copy link
Member

Choose a reason for hiding this comment

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

Could be that it's very slow to garbage collect (which I don't understand why – (is it the DOM tree size?) and running a profile too quickly creates a reference to the detached node

I clicked the "Collect garbage" button in the video above, but it didn't change anything.

Bugs in devotools doesn't make it a joy to debug.

Yep, I get different results and I'm not sure it's reliable at this point:

Screen.Recording.2024-11-25.at.18.25.25.mov

But it looks like there are no memory leaks from the resize observer, and the DOM node is garbage collected once removed from the DOM.
I think we shouldn't bother with cleanups here and take a look at #15459 instead

Copy link
Contributor

Choose a reason for hiding this comment

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

Are you confident that everything is GC'ed correctly across all browsers? I would tend to add cleanup just to be safe.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can never be sure about that, but from quick checks with Safari and Firefox, it seems to be the case. But let's add it to be extra safe.

@lauri865
Copy link
Contributor Author

lauri865 commented Nov 23, 2024

I haven't looked into flushSync, it was there before I was. I'm guessing on slower browsers the effect is bad because it triggers multiple renders where otherwise a single render might have gone through after batching.

I'm happy with your solution, I would also be ok with passing an enum UpdateSource there to flush-sync depending on that. Does it make sense to include those changes in this PR or do you prefer to open a separate one?

Probably makes sense to open a new PR for scroll-related things.

In the meanwhile, I also found some areas of improvements for the virtual scrollbars.

  1. isLocked ref is shared between virtualScroller and Scrollbar – assuming the intention is enable syncing only from the container where the scroll is happening, but the current implementation seems to block the next even also on the scrolling container, meaning some events get lost and could result in sync issues::
    if (isLocked.current) {
    isLocked.current = false;
    return;
    }
    isLocked.current = true;

if (isLocked.current) {
isLocked.current = false;
return;
}
isLocked.current = true;

Probably something along the lines of this would make sense – locking opposing scroll container events, but not blocking itself:

const onScrollerScroll = useEventCallback((e) => {
    // redacted

    if (isScrollerLocked.current) {
      isScrollerLocked.current = false;
      return;
    }
    
    isScrollbarLocked.current = true;
}
const onScrollbarScroll = useEventCallback((e) => {
    if (isScrollbarLocked.current) {
      isScrollbarLocked.current = false;
      return;
    }

    isScrollerLocked.current = true;
}
  1. Scroller scroll event should probably be passive for better performance, we don't want to block actual scroller container scroll only for virtual scrollbar sync logic. The opposite is probably fine, since scrollbars don't render any content anyways.
  2. On a similar note, onScrollerScroll scroll position syncing to virtual scrollbars might be better done on requestAnimationFrame, this only needs to be synced with the rendering.
  3. (least clear to me, so sorry if I misunderstood anything in the following) Not entirely convinced this is necessary (cost vs. benefit of clearing the frozenContext with a timer that's re-registered on every scroll event). Disabled the timer, and I failed to create a case where I managed to see any horizontal banding after scrolling vertically. Either I'm misunderstanding the cause/implications of this, or it simply gets cleared by itself (fast enough) under normal circumstances, or scroll acceleration makes it impossible to scroll fast enough to trigger the issue it may intend to fix (at least my understanding of it).
    scrollTimeout.start(1000, triggerUpdateRenderContext);

@lauri865
Copy link
Contributor Author

Also, it seems that GridRows re-render on every renderContext change due to renderContext prop.
But the rows only make use of first and last column indexes, which should be made flat props then instead of a full object with unused props. Otherwise memoization is mostly just overhead.

@lauri865
Copy link
Contributor Author

Fixing row memoization almost completely eliminated flashing content as well when scrolling from top to bottom with scrollToIndexes. There's ever so slight flash, almost imperceptible at 120fps that flushSync takes care of, but that's probably just masking another issue that's actually behind the flash. I guess if scrollToIndexes triggered updateRenderContext directly, it would be gone, and there would be no need for flushSync.

@lauri865
Copy link
Contributor Author

Correct, if we'd manually dispatch a scroll event alongside apiref.current.scroll, then the flash is gone, and no need for flushSync anymore. So, maybe exposing an updateScroll method to private api, or add a param updateScroll to updateRenderContext, as it doesn't update the scroll position otherwise.

@cherniavskii cherniavskii merged commit 66ac7fb into mui:master Nov 25, 2024
18 checks passed
Copy link

Cherry-pick PRs will be created targeting branches: v7.x

github-actions bot pushed a commit that referenced this pull request Nov 25, 2024
Co-authored-by: Andrew Cherniavskyi <andrew@mui.com>
@romgrk
Copy link
Contributor

romgrk commented Nov 25, 2024

@lauri865 Thanks a lot for the detailed exploration, if you want to open PRs for those we'll be happy to see if those improvements don't have any downsides.

IIRC, the scroll locking is to prevent the scroller & scrollbar from feeding events into each other. It does ignore events, because they are triggered even when one element is programatically scrolled.

import { useRtl } from '@mui/system/RtlProvider';
import reactMajor from '@mui/x-internals/reactMajor';
Copy link
Member

Choose a reason for hiding this comment

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

Just noticed this while working on React 19 bump.
@mui/xgrid Was there a reason to create a local utility, when everywhere else it is imported from @mui/internal-test-utils? 🤔

Copy link
Member

Choose a reason for hiding this comment

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

We needed this in the data grid package itself, not in the test files.

Copy link
Member

Choose a reason for hiding this comment

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

A possible solution mui/base-ui#1047. We could argue that if we can do import reactMajor from '@mui/x-internals/reactMajor'; then we should remove import { reactMajor } from '@mui/internal-test-utils'; as it can be a footgun.

LukasTy pushed a commit to LukasTy/mui-x that referenced this pull request Dec 19, 2024
Co-authored-by: Andrew Cherniavskyi <andrew@mui.com>
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.

6 participants