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] Feature: First-party (performant) scroll restoration #15190

Open
lauri865 opened this issue Oct 30, 2024 · 4 comments · May be fixed by #15623
Open

[data grid] Feature: First-party (performant) scroll restoration #15190

lauri865 opened this issue Oct 30, 2024 · 4 comments · May be fixed by #15623
Labels
component: data grid This is the name of the generic UI component, not the React module! feature: Rendering layout Related to the data grid Rendering engine new feature New feature or request

Comments

@lauri865
Copy link
Contributor

lauri865 commented Oct 30, 2024

Summary

We implement most of our Datagrids while persisting state between navigations for better UX. It's all good implemented in userland, and no complaints there in general.

However, restoring scroll is extremely painful in userland, and it's impossible to achieve a performant outcome there, because the initial renderContext is flushSynced, and there's no way to override that. So you end up with reflows and unnecessary component renders (even if they don't end up in the DOM, e.g. canvas elements end up still be drawn and have to be trashed if they don't end up in the viewport) on remounting the grid.

Examples

What works but is slow / subject to reflows / unnecessary computations:

apiRef.current.subscribeEvent("viewportInnerSizeChange", ...
// 1. Set up MutationObserver
// 2. Check for whether `.MuiDataGrid-virtualScroller` exists
// 3. Restore scroll

Slightly more performant, but less widely supported option (based on heuristics rather than explictly waiting for the virtualScroll to enter the DOM):

React.useLayoutEffect(() => {
  queueMicrotask(() => {
      const rootRef = apiRef.current.rootElementRef?.current;
      const scrollContainer = rootRef?.querySelector(
        ".MuiDataGrid-virtualScroller",
      );
      if (!scrollContainer) return;

      if (gridState.scroll!.top) {
        scrollContainer.scrollTop = gridState.scroll!.top;
      }
      if (gridState.scroll!.left) {
        scrollContainer.scrollLeft = gridState.scroll!.left;
      }
  });
}, []);

Even more performant option (almost there, but could be better):

React.useLayoutEffect(() => {
  queueMicrotask(() => {
      const rootRef = apiRef.current.rootElementRef?.current;
      const scrollContainer = rootRef?.querySelector(
        ".MuiDataGrid-virtualScroller",
      );
      if (!scrollContainer) return;
      
      // we make sure that the renderContext matches the scrollPosition before updating the scroll
      flushSync(() => {
        apiRef.current.setState((state) => ({
          ...state,
          virtualization: {
            ...state.virtualization,
            renderContext: gridState.scroll?.renderContext!,
          },
        }));
      });

      if (gridState.scroll!.top) {
        scrollContainer.scrollTop = gridState.scroll!.top;
      }
      if (gridState.scroll!.left) {
        scrollContainer.scrollLeft = gridState.scroll!.left;
      }
  });
}, []);

Almost as snappy as without scroll restoration, but expensive components are still called and flushSync needs to wait for another flushSync to complete from what I understand, so we still lose a frame or a few and end up with higher CPU use.

Motivation

The source of the issue seems to be – the following call to updateRenderContext cannot be intercepted or overridden:
https://github.com/mui/mui-x/blob/f613377c4740cb7ebda19d4b3a81cf27be8e0a01/packages/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx#L580C3-L591C6

FlushSynced:

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

Maybe a stopgap solution could be if scrollPositionChange could prevent the default behaviour (e.g. could prevent the first event and first invocation of updateRenderContext).

Restoring scroll is difficult in userland, subject to performance woes and regardless of the approach, ends up in spaghetti code that may not work in future versions.

Related: #5071 and #6411
Opened a new issue as these discussions are stale and based on old versions.

Search keywords: restore scroll

@lauri865 lauri865 added new feature New feature or request status: waiting for maintainer These issues haven't been looked at yet by a maintainer labels Oct 30, 2024
@lauri865 lauri865 changed the title [Datagrid] Feature: First party scroll (performant) restoration [Datagrid] Feature: First party (performant) scroll restoration Oct 30, 2024
@michelengelen
Copy link
Member

Hey @lauri865 and thanks for opening an issue here.
Also thanks for providing so much information and potential use-cases to look into. As scrolling issues seem to be a very common thing in the last few days I will add this to the board for the team to have a look into it in more detail.

@mui/xgrid thoughts?

@michelengelen michelengelen changed the title [Datagrid] Feature: First party (performant) scroll restoration [data grid] Feature: First party (performant) scroll restoration Oct 30, 2024
@michelengelen michelengelen added component: data grid This is the name of the generic UI component, not the React module! and removed status: waiting for maintainer These issues haven't been looked at yet by a maintainer labels Oct 30, 2024
@github-project-automation github-project-automation bot moved this to 🆕 Needs refinement in MUI X Data Grid Oct 30, 2024
@michelengelen michelengelen added the feature: Rendering layout Related to the data grid Rendering engine label Oct 30, 2024
@michelengelen
Copy link
Member

michelengelen commented Oct 30, 2024

maybe related issue: #15179

@lauri865 lauri865 changed the title [data grid] Feature: First party (performant) scroll restoration [data grid] Feature: First-party (performant) scroll restoration Oct 30, 2024
@lauri865
Copy link
Contributor Author

lauri865 commented Oct 30, 2024

As an illustration what happens:
First render (scrolled to the top): 12 components/rows render
scroll to the bottom, navigate away and back
Restore scroll to the bottom:

[Violation] Forced reflow while executing JavaScript took 31ms
13 render
8 render
[Violation] Forced reflow while executing JavaScript took 37ms
3 render

It's even worse when the scroll is in the middle of the grid. Then it can render 13+23+3=39 rows (while the viewport fits 8-9) in order to display the restored scroll position.

[Violation] 'setTimeout' handler took 51ms
13 render
scheduler.development.js:14 [Violation] 'message' handler took 185ms
23 render
usePersistentDatagrid.ts:151 [Violation] 'message' handler took 247ms
[Violation] Forced reflow while executing JavaScript took 51ms
3 render

@lauri865
Copy link
Contributor Author

lauri865 commented Oct 30, 2024

I found the most disgusting way to prevent the first slice form being rendered 🤡:

  const unsubscribe = apiRef.current.subscribeEvent("stateChange", (state) => {
      state.virtualization.renderContext = gridState.renderContext;
  });

(need to unsubscribe from the event when the grid is ready / after restoring scroll)

You will probably get fired if you use it at work though, but it's the only option that gets rid of unnecessary rendering.

@lauri865 lauri865 linked a pull request Nov 26, 2024 that will close this issue
1 task
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: Rendering layout Related to the data grid Rendering engine new feature New feature or request
Projects
Status: 🆕 Needs refinement
Development

Successfully merging a pull request may close this issue.

2 participants