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] Avoid <GridRoot /> double-render pass on mount in SPA mode #15648

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

Conversation

lauri865
Copy link
Contributor

@lauri865 lauri865 commented Nov 28, 2024

Currently, GridRoot double-renders in all cases on mount to prevent SSR. In SPA-mode this is irrelevant, and can be avoided with the help of useSyncExternalStore, since there's no server snapshot in SPA-mode, the gird will be mounted directly. As a result, the rootRef becomes immediately available on first mount in SPAs, as one would expect.

Adds a new dependency use-sync-external-store to make it backwards compatible with React 17. Charts and treeview have the same dependency.

@mui-bot
Copy link

mui-bot commented Nov 28, 2024

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

Generated by 🚫 dangerJS against 4b7f544

@lauri865 lauri865 force-pushed the avoid-double-render-pass-in-spas branch from dd1a1e1 to 8da261b Compare November 28, 2024 13:53
@flaviendelangle flaviendelangle added the component: data grid This is the name of the generic UI component, not the React module! label Nov 28, 2024
@flaviendelangle flaviendelangle changed the title [Data Grid] Avoid GridRoot double-render pass on mount in SPA mode [Data Grid] Avoid <GridRoot /> double-render pass on mount in SPA mode Nov 28, 2024
@lauri865
Copy link
Contributor Author

Side-benefit is also probably more robust tests if the Datagrid is mounted on the first pass.

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.

Code LGTM, we can merge once the tests are passing.

@lauri865
Copy link
Contributor Author

lauri865 commented Dec 4, 2024

I have no idea how to fix the last failing test. Either it's a symptom of column headers updated more times now or just a bad test. But I lack understanding why it was previously expected that the warning message will be output exactly twice.

Edit: discovered some areas to improve around mounting / state initialization

@lauri865 lauri865 force-pushed the avoid-double-render-pass-in-spas branch from 12fb3da to c8580d9 Compare December 4, 2024 12:39
@lauri865
Copy link
Contributor Author

lauri865 commented Dec 4, 2024

Also fixes an annoying flicker coming out of loading state. It's probably the root cause of a layout shift I once reported with autoHeight grid coming out of loading state.

Before:
https://github.com/user-attachments/assets/1a68b2de-f2fd-4ef4-a93d-215d044e35b7

After:
https://github.com/user-attachments/assets/cfe55012-092a-429d-a79d-75e243124d10

@lauri865
Copy link
Contributor Author

lauri865 commented Dec 4, 2024

Tests should be passing now. 4 was the right amount of warnings for this approach, 1 extra pass (+1 warning for header/row each = 2+2 = 4) that doesn't even flush to the dom, so I think we're good there.

Curiously, after this optimisation:

const hasFlexColumns = gridVisibleColumnDefinitionsSelector(apiRef).some(
(col) => col.flex && col.flex > 0,
);
if (!hasFlexColumns) {
return;
}
setGridColumnsState(
hydrateColumnsWidth(
gridColumnsStateSelector(apiRef.current.state),
apiRef.current.getRootDimensions(),
),
);

This test started failing:

it('should correctly restore the column when changing from aggregated to non-aggregated', () => {
const { setProps } = render(<Test aggregationModel={{ id: 'max' }} />);
expect(getColumnHeaderCell(0, 0).textContent).to.equal('idmax');
setProps({ aggregationModel: {} });
expect(getColumnHeaderCell(0, 0).textContent).to.equal('id');
});

The only reason I can think of is that aggregations didn't explicitly trigger column updates, but somehow were piggybacking on viewportInnerSizeChange event for taking care of it. Adding an explicit updateColumns call fixed it. Is there any other explanation to it?

If that's the case, then I suppose there was a bug whereby columns didn't clear when there was no totals row visible, as viewPortInnerSize would only change if there was a pinned row.

Comment on lines +1107 to +1110

function roundToSubPixel(value: number) {
return Math.round(value * 10) / 10;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is 1 decimal place a better value?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Some browsers support subpixel positioning, but I saw some instances in the docs where width was 242.32px, which triggers unnecessary updates. Happy to change it to full pixels if that makes more sense.

Comment on lines 347 to 354
act(() => apiRef.current.publishEvent('scrollPositionChange', { left: 0, top: 3 * 52 }));
act(() => {
apiRef.current.publishEvent('renderedRowsIntervalChange', {
firstRowIndex: 3,
lastRowIndex: 6,
firstColumnIndex: 0,
lastColumnIndex: 0,
});
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this test modified?

Copy link
Contributor Author

@lauri865 lauri865 Dec 4, 2024

Choose a reason for hiding this comment

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

Seemed like a faulty premise for the test, but honestly in hindsight, I'm not sure why it previously worked to begin with.

Lazy loader only subscribes to renderedRowsIntervalChange:

useGridApiEventHandler(
privateApiRef,
'renderedRowsIntervalChange',
handleRenderedRowsIntervalChange,
);

scrollPositionChange shouldn't change actual scroll position nor trigger renderedRowsIntervalChange, so how can it even trigger a new slice to be loaded?

Maybe you understand it better, but I tried to test it in the browser and everything worked fine with actual scrolling.

Copy link
Contributor Author

@lauri865 lauri865 Dec 4, 2024

Choose a reason for hiding this comment

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

This test gave me so much grief trying to trace in the code base how scrollPositionChange can possibly trigger lazyLoader. And guess what? It doesn't.

Ran it on the master branch and removing the publishEvent line will still make the pass. But for whatever reason, it will always try to load all the rows minus the first 3 that it has – tha args to onFetchRows are (I set the page size to 500):

Object{firstRowToRender: 3, lastRowToRender: 500, sortModel: [], filterModel: Object{items: [], logicOperator: 'and', quickFilterValues: [], quickFilterLogicOperator: 'and'}}

Again, this is on the master branch, for the lack of any confusion.

Copy link
Contributor Author

@lauri865 lauri865 Dec 4, 2024

Choose a reason for hiding this comment

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

The test is still testing something useful that broke, just not what it says on the label currently. I reduced the amount updateDimensions calls, which called updateRenderContext ahead of dimensions state being updated, which prevented lazyLoader from working directly on mount (scroll still worked).

Fixed it now, and improved the tests – split it into two and improved the checks to more than just checking if fetchRows has been called. Hopefully no-one else will have to waste so much time on this anymore.

Comment on lines 66 to 67
apiRef.current.updateColumns([]);
apiRef.current.forceUpdate();
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this required?

While you're touching this, you could delete .forceUpdate(), it's a legacy function that should be removed.

Copy link
Contributor Author

@lauri865 lauri865 Dec 4, 2024

Choose a reason for hiding this comment

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

After some further debugging, it's not required as such, but there's a bug somehwere:

  • Removing aggregation via prop triggers checkAggregationRulesDiff -> triggers hydrateColumns -> updates columns (but aggregation is not cleared for whatever reason) -> triggers viewportInnerSizeChange -> triggers handleGridSizeChange (which often is unnecessary, unless there are flex columns) where aggregation is actually cleared.

So, it seems that hydrateColumns is probably ran before state has resolved.

The right fix then is to replace:

useGridApiOptionHandler(apiRef, 'aggregationModelChange', checkAggregationRulesDiff);

With something along the lines of (unless there's a better internal way to chain this):

const aggregationModel = useGridSelector(apiRef, gridAggregationModelSelector);
useEnhancedEffect(checkAggregationRulesDiff, [checkAggregationRulesDiff, aggregationModel]);

Copy link
Contributor Author

@lauri865 lauri865 Dec 4, 2024

Choose a reason for hiding this comment

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

Root cause is that these two get triggered out of order:

useGridApiOptionHandler(apiRef, 'aggregationModelChange', checkAggregationRulesDiff);

React.useEffect(() => {
    if (props.aggregationModel !== undefined) {
      apiRef.current.setAggregationModel(props.aggregationModel);
    }
  }, [apiRef, props.aggregationModel]);

As soon as the prop changes, checkAggregationRulesDiff is triggered, ahead of setAggregationModel, but columns cannot rehydrate before aggregationModel has been set. And it currently only works, because column update always triggers two state updates to columns (which in most cases is not needed), which triggers a second hydration from aggregation, only at which point the aggregation state has been updated.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

cc @flaviendelangle, any thoughts? 🙏 Seems like you worked on this feature.

Copy link
Member

Choose a reason for hiding this comment

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

I built the feature 2.5 years ago so my understanding of it might be rusted 😆

As soon as the prop changes, checkAggregationRulesDiff is triggered

That surprises me.
checkAggregationRulesDiff is triggered by the aggregationModelChange event, which is fired in the setState method, which is called by setAggregationModel

So basically:

props.aggregationModel changes => setState is called => state.aggregation.model is updated and aggregationModelChange is fired => checkAggregationRulesDiff is called

Maybe we have a problem during the initialization though.

The problem with firing updated like checkAggregationRulesDiff in effects is that you might end up with inconsistent states and flickering, but I don't know if that's the case for this specific feature.

Copy link
Contributor Author

@lauri865 lauri865 Dec 5, 2024

Choose a reason for hiding this comment

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

There problem seems to be on initialization indeed. Not sure if it has any real-world bearing, but on first prop change, the values are as such:

aggregationRules {}
rulesOnLastColumnHydration {}
aggregationRules {}
areAggregationRulesEqual(rulesOnLastColumnHydration, aggregationRules)

rulesOnLastColumnHydration is empty and thus hydrateColumns is not triggered directly after the prop change, even though columns have rules applied as can be seen from the test result:

1) <DataGridPremium /> - Aggregation
       Setting aggregation model
         prop: aggregationModel
           should correctly restore the column when changing from aggregated to non-aggregated:

      AssertionError: expected 'idmax' to equal 'id'
      + expected - actual

      -idmax
      +id

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 hack also helps the test pass, but not sure what the actual fix here is:

export const aggregationStateInitializer: GridStateInitializer<
  Pick<DataGridPremiumProcessedProps, 'aggregationModel' | 'initialState'>,
  GridPrivateApiPremium
> = (state, props, apiRef) => {
  apiRef.current.caches.aggregation = {
    rulesOnLastColumnHydration: props.aggregationModel !== undefined ? ({ _init: {} } as any) : {},
    rulesOnLastRowHydration: {},
  };

  return {
    ...state,
    aggregation: {
      model: props.aggregationModel ?? props.initialState?.aggregation?.model ?? {},
    },
  };
};

Copy link
Contributor Author

Choose a reason for hiding this comment

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

aha, I think I see the actual fix now

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 think the right place to update apiRef.current.caches.aggregation.rulesOnLastColumnHydration is in the preprocessor after hydrateColumns and not in checkAggregationRulesDiff

Copy link
Contributor

Choose a reason for hiding this comment

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

The argos CI test has a few failing cases, some of which would need to be fixed (at least the "no rows" one).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

"no rows" one should be fixed now. Created a selector for pinnedRow heights, which improves updateDimensions flow of updates (imho).

@lauri865
Copy link
Contributor Author

lauri865 commented Dec 5, 2024

Ok, so it seems like useGridSelector can under certain circumstances return the stale state:

  • Component mounts
  • Selector sets initialState
  • Something triggers a state change before Component can mount (i.e. before useOnMount/useEffect where store subscription is added)
  • Store subscription is added too late, we don't catch the state update, and internal state of the selector remains stale.

I will change useOnMount to useEnhancedEffect with an empty dependency array, unless there are any objections @romgrk? Store.subscribe is a very simple operation, so shouldn't make any difference performance-wise.

@lauri865
Copy link
Contributor Author

lauri865 commented Dec 5, 2024

Tests passing 🎉
(this few line change turned out to be quite a can of worms, but all for the better I hope 🙈)

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

github-actions bot commented Dec 6, 2024

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

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