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

Improve DataTable re-rendering performance #2993

Merged
merged 5 commits into from
Feb 11, 2022

Conversation

alexzherdev
Copy link
Contributor

@alexzherdev alexzherdev commented Jan 28, 2022

Additional description

This improves re-rendering performance of the DataTable in scenarios involving many rows (in the hundreds/thousands). Main optimizations introduced:

  • <DataTable>: memoized TableContext value so that if table gets re-rendered with no real changes (e.g. when its parent re-rendered and didn't pass any new props), it doesn't trigger unnecessary re-renders in the context consumers
  • <DataTable>: memoized assistive text and columns passed down to rows to reduce their re-renders
  • <DataTableRow>: any change in TableContext would mark all rows for re-rendering due to useContext in the row. Implemented option 3 here to stop unnecessary re-rendering below the row (with the exception of relevant nested context consumers)
  • context-helper: turned it into a hook to enable useCallback. This was necessary so that the functions returned from the helper could be properly used as useMemo dependencies elsewhere.

Note that to take advantage of these improvements, the consumer application needs to memoize the columns passed into the table:

const columns = useMemo(() => [
  <DataTableColumn key="0" />,
  <DataTableColumn key="1" />
], []);

<DataTable>{columns}</DataTable>

Traces below are from a consumer application showing reduced re-rendering when clicking a row (these are taken from the re-render caused by the focus change in the table). Overall rendering time (measured via Chrome's profiler) decreased by 40-50%.

Before
Screen Shot 2022-01-27 at 5 57 22 PM

After
Screen Shot 2022-01-27 at 6 00 24 PM


CONTRIBUTOR checklist (do not remove)

Please complete for every pull request

  • First-time contributors should sign the Contributor License Agreement. It's a fancy way of saying that you are giving away your contribution to this project. If you haven't before, wait a few minutes and a bot will comment on this pull request with instructions.
  • npm run lint:fix has been run and linting passes.
  • Mocha, Jest (Storyshots), and components/component-docs.json CI tests pass (npm test).
  • Tests have been added for new props to prevent regressions in the future. See readme.
  • Review the appropriate Storybook stories. Open http://localhost:9001/.
  • Review tests are passing in the browser. Open http://localhost:8001/.
  • Review markup conforms to SLDS by looking at DOM snapshot strings.

REVIEWER checklist (do not remove)

  • CircleCI tests pass. This includes linting, Mocha, Jest, Storyshots, and components/component-docs.json tests.
  • Tests have been added for new props to prevent regressions in the future. See readme.
  • Review the appropriate Storybook stories. Open http://localhost:9001/.
  • The Accessibility panel of each Storybook story has 0 violations (aXe). Open http://localhost:9001/.
  • Review tests are passing in the browser. Open http://localhost:8001/.
  • Review markup conforms to SLDS by looking at DOM snapshot strings.
Required only if there are markup / UX changes
  • Add year-first date and commit SHA to last-slds-markup-review in package.json and push.
  • Request a review of the deployed Heroku app by the Salesforce UX Accessibility Team.
  • Add year-first review date, and commit SHA, last-accessibility-review, to package.json and push.
  • While the contributor's branch is checked out, run npm run local-update within locally cloned site repo to confirm the site will function correctly at the next release.

@alexzherdev alexzherdev force-pushed the datatable-perf branch 3 times, most recently from 89589b6 to a5c98d4 Compare January 28, 2022 17:53
@@ -16,8 +16,9 @@ import shortid from 'shortid';

import classNames from 'classnames';
import assign from 'lodash.assign';
import isEqual from 'lodash.isequal';
import memoize from 'memoize-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.

The problem with using lodash.memoize is it only bases its memoization on the first argument supplied to the function by default, and you need to hand-write the cache key calculation otherwise. memoize-one is free of that shortcoming and is a popular library that's lightweight and performant.

I think there is only one usage of lodash.memoize that I could remove if necessary.

Copy link
Contributor

@interactivellama interactivellama Feb 8, 2022

Choose a reason for hiding this comment

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

Yes, one memoize library would be great!

}
return result;
},
isEqual
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because assistiveText is an object rather than a scalar value, we need to provide a comparison function

@@ -587,6 +687,13 @@ class DataTable extends React.Component {
}
};

// eslint-disable-next-line camelcase
UNSAFE_componentWillUpdate(nextProps) {
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 is fixing an existing issue (not related to performance) where navigating cells via keyboard and pressing spacebar wasn't triggering the interactive element.
I spent limited amount of time fixing this because it's out of scope so I didn't look into ways of avoiding an unsafe lifecycle.

columns.push({
Cell,
props,
dataTableProps: this.props,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

When extracting this code, I removed this property since dataTableProps were not being used.

if (child && child.type.displayName === DataTableColumn.displayName) {
const { children, ...columnProps } = child.props;

const props = assign({}, this.props);
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 essentially propagates all table props onto columns. In the extracted function I replaced this with individual props that are actually used. Passing more props than necessary can exacerbate the issue with unnecessary re-renders.

// Always use the canonical component name as the React display name.
static displayName = DATA_TABLE_HEAD;
// ### Prop Types
const propTypes = {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Most of the changes in this file are to refactor the component to a function so that it can use the context helper hook.

showRowActions: PropTypes.bool,
};

const ActionsHeader = (props) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Extracted the ActionsHeader and SelectHeader subcomponents since otherwise the hook calls were under a condition which is not allowed. (and also just seemed a good thing to do in general)

*/
width: PropTypes.string,
};
const DataTableHeaderCell = (props) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Refactored this to a function as well to use the context helper hook.

@@ -98,6 +98,7 @@
"lodash.isfunction": "^3.0.9",
"lodash.memoize": "^4.1.2",
"lodash.reject": "^4.6.0",
"memoize-one": "^6.0.0",
Copy link
Contributor

Choose a reason for hiding this comment

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

do we need some approval process to use a new lib?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

not sure, @interactivellama?

Copy link
Contributor

Choose a reason for hiding this comment

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

We have license and security checking from Snyk in our CI, so it should conform to Snyk's policy https://app.snyk.io/org/salesforce-oss/pr-checks/ab6331d2-5eb8-4973-8bf5-aab69313e91f

props.fixedLayout
);
const { fixedHeader, canSelectRows } = props;
const getContent = (idSuffix, style, ariaHidden) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

does it make any perf difference to make such functions as callback or useMemo? I noticed there are couple similar functions in other files as well

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 particular one is in the table head so it only renders O(1) per table as opposed to O(rowcount) so I left it as is

@interactivellama
Copy link
Contributor

interactivellama commented Feb 7, 2022

I can see a noticeable difference on a table of 250 rows (7 columns) when selecting a row! 🎉

meizibupt
meizibupt previously approved these changes Feb 8, 2022
@interactivellama
Copy link
Contributor

@alexzherdev Please merge when ready.

@alexzherdev alexzherdev merged commit 9a384ab into salesforce:master Feb 11, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants