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

Minimize the calls in useSelect by subscribing to only the stores needed #26724

Merged
merged 17 commits into from
Nov 27, 2020

Conversation

kevin940726
Copy link
Member

@kevin940726 kevin940726 commented Nov 5, 2020

Description

Minimize the calls of the selectors in useSelect by only subscribing to the stores needed.

The problem

Currently, useSelect works by subscribing to the registry instance and calls the mapSelect (the first argument of useSelect) function every time the store changes. It works fine, but it also fires many unnecessary mapSelect calls when we know that the store updates won't cause a re-render.

Most components subscribe to the default registry, and most stores also registered to that same registry. Let's say that some component has a counter store and multiple other stores like core, core/editor, core/block-editor, etc. The counter store has a selector getCounter, which simply gets the count state from the store. A component using this selector could look like this.

function TestComponent() {
	const { count } = useSelect( select => ({
		count: select( 'counter' ).getCounter(),
	}), [] );

	return count;
}

It's clear that this component doesn't care about state updates from stores other than counter. However, the selector still gets called when we dispatch any actions no matter if the action doesn't belong to the counter store.

Fortunately, since the count state from getCounter() doesn't change, <TestComponent> doesn't have to re-render. Still, it's an unnecessary call of the getCounter selector. In this specific case, it doesn't matter much, but it would matter if the mapSelect function is very complex or slow. Especially when we have resolver for each selector, which could potentially cause unwanted side-effects to be introduced.

The solution

This PR propose a solution to minimize the calls of the mapSelect function as much as possible. The idea is to only call the mapSelect function if and only if the store this mapSelect has referenced to gets updated.

For instance,

useSelect( select => ({
	count: select( 'counter' ).getCounter(),
}), [] );

this useSelect only cares about updates within the counter store itself and nothing else. We only have to re-run the mapSelect function whenever the state from the counter store updates.

Another example,

useSelect( select => ({
	count: select( 'counter' ).getCounter(),
	isPublished: select( 'core/editor' ).isCurrentPostPublished(),
}), [] );

this useSelect cares about state updates from two different stores: counter and core/editor. Whenever either one of the store updates, we need to re-run the mapSelect function.

This is done by trapping the select function of the registry to keep track of every store it has called. It works very similarly like monkey-patching the select function as follow (the actual implementation is slightly different because of some issues with legacy features with use and withPlugins):

const listeningStores = [];

const originalSelect = registry.select;
registry.select = function trapSelect( storeKey ) {
	listeningStores.push( storeKey );
	return originalSelect.call( this, storeKey );
};

Whenever reigstry.select( storeKey ) is called, we'll push the storeKey to the listeningStores. Later, we can then only subscribe to those stores rather than subscribing to the whole registry.

- registry.subscribe( onChange );
+ listeningStores.forEach( storeKey => registry.stores[ storeKey ].subscribe( onChange ) );

How has this been tested?

Added a bunch of unit tests and also fixed some tests.
End-to-end tests should also be passing.

Performance tests

>> post-editor

┌─────────┬───────────┬────────────┬────────────┬─────────────┬─────────────┬────────────┬─────────────┐
│ (index) │   load    │    type    │  minType   │   maxType   │    focus    │  minFocus  │  maxFocus   │
├─────────┼───────────┼────────────┼────────────┼─────────────┼─────────────┼────────────┼─────────────┤
│ This PR │ '6733 ms' │ '44.5 ms'  │ '40.78 ms' │ '100.23 ms' │ '98.93 ms'  │ '73.53 ms' │ '131.48 ms' │
│ master  │ '7360 ms' │ '53.59 ms' │ '46.28 ms' │ '113.3 ms'  │ '105.19 ms' │ '65.89 ms' │ '143.31 ms' │
└─────────┴───────────┴────────────┴────────────┴─────────────┴─────────────┴────────────┴─────────────┘

>> site-editor

┌─────────┬──────────────┐
│ (index) │     load     │
├─────────┼──────────────┤
│ This PR │  '1243 ms'   │
│ master  │ '1255.33 ms' │
└─────────┴──────────────┘

As seen in the report, this PR out-performs every test. However, the difference is not that big though, it might be just a micro-optimization in the end.

Types of changes

New feature

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code follows the accessibility standards.
  • My code has proper inline documentation.
  • I've included developer documentation if appropriate.
  • I've updated all React Native files affected by any refactorings/renamings in this PR.

@kevin940726 kevin940726 requested a review from adamziel November 5, 2020 11:42
@github-actions
Copy link

github-actions bot commented Nov 5, 2020

Size Change: +617 B (0%)

Total Size: 1.2 MB

Filename Size Change
build/block-editor/index.js 134 kB +90 B (0%)
build/block-editor/style-rtl.css 11.3 kB -28 B (0%)
build/block-editor/style.css 11.3 kB -24 B (0%)
build/block-library/editor-rtl.css 8.96 kB -10 B (0%)
build/block-library/editor.css 8.96 kB -10 B (0%)
build/block-library/index.js 148 kB +57 B (0%)
build/components/index.js 172 kB +1 B
build/data/index.js 8.97 kB +170 B (1%)
build/edit-post/index.js 306 kB +83 B (0%)
build/edit-post/style-rtl.css 6.42 kB -38 B (0%)
build/edit-post/style.css 6.4 kB -38 B (0%)
build/edit-site/index.js 24 kB +379 B (1%)
build/edit-widgets/index.js 26.3 kB +6 B (0%)
build/editor/index.js 43.3 kB -21 B (0%)
ℹ️ View Unchanged
Filename Size Change
build/a11y/index.js 1.14 kB 0 B
build/annotations/index.js 3.8 kB 0 B
build/api-fetch/index.js 3.42 kB 0 B
build/autop/index.js 2.84 kB 0 B
build/blob/index.js 664 B 0 B
build/block-directory/index.js 8.72 kB 0 B
build/block-directory/style-rtl.css 943 B 0 B
build/block-directory/style.css 942 B 0 B
build/block-library/style-rtl.css 8.23 kB 0 B
build/block-library/style.css 8.23 kB 0 B
build/block-library/theme-rtl.css 792 B 0 B
build/block-library/theme.css 793 B 0 B
build/block-serialization-default-parser/index.js 1.87 kB 0 B
build/block-serialization-spec-parser/index.js 3.06 kB 0 B
build/blocks/index.js 48.1 kB 0 B
build/components/style-rtl.css 15.3 kB 0 B
build/components/style.css 15.3 kB 0 B
build/compose/index.js 9.95 kB 0 B
build/core-data/index.js 14.8 kB 0 B
build/data-controls/index.js 827 B 0 B
build/date/index.js 11.2 kB 0 B
build/deprecated/index.js 768 B 0 B
build/dom-ready/index.js 571 B 0 B
build/dom/index.js 4.95 kB 0 B
build/edit-navigation/index.js 11.1 kB 0 B
build/edit-navigation/style-rtl.css 881 B 0 B
build/edit-navigation/style.css 885 B 0 B
build/edit-site/style-rtl.css 3.92 kB 0 B
build/edit-site/style.css 3.92 kB 0 B
build/edit-widgets/style-rtl.css 3.13 kB 0 B
build/edit-widgets/style.css 3.13 kB 0 B
build/editor/editor-styles-rtl.css 476 B 0 B
build/editor/editor-styles.css 478 B 0 B
build/editor/style-rtl.css 3.85 kB 0 B
build/editor/style.css 3.85 kB 0 B
build/element/index.js 4.62 kB 0 B
build/escape-html/index.js 735 B 0 B
build/format-library/index.js 6.86 kB 0 B
build/format-library/style-rtl.css 547 B 0 B
build/format-library/style.css 548 B 0 B
build/hooks/index.js 2.27 kB 0 B
build/html-entities/index.js 623 B 0 B
build/i18n/index.js 3.57 kB 0 B
build/is-shallow-equal/index.js 698 B 0 B
build/keyboard-shortcuts/index.js 2.54 kB 0 B
build/keycodes/index.js 1.94 kB 0 B
build/list-reusable-blocks/index.js 3.1 kB 0 B
build/list-reusable-blocks/style-rtl.css 476 B 0 B
build/list-reusable-blocks/style.css 476 B 0 B
build/media-utils/index.js 5.32 kB 0 B
build/notices/index.js 1.81 kB 0 B
build/nux/index.js 3.42 kB 0 B
build/nux/style-rtl.css 671 B 0 B
build/nux/style.css 668 B 0 B
build/plugins/index.js 2.56 kB 0 B
build/primitives/index.js 1.43 kB 0 B
build/priority-queue/index.js 790 B 0 B
build/redux-routine/index.js 2.84 kB 0 B
build/reusable-blocks/index.js 2.92 kB 0 B
build/rich-text/index.js 13.3 kB 0 B
build/server-side-render/index.js 2.77 kB 0 B
build/shortcode/index.js 1.69 kB 0 B
build/token-list/index.js 1.27 kB 0 B
build/url/index.js 4.05 kB 0 B
build/viewport/index.js 1.86 kB 0 B
build/warning/index.js 1.14 kB 0 B
build/wordcount/index.js 1.22 kB 0 B

compressed-size-action

@adamziel
Copy link
Contributor

adamziel commented Nov 5, 2020

@kevin940726 it doesn't account for selectors like

export const getWidgets = createRegistrySelector( ( select ) => () => {
	const widgets = select( 'core' ).getEntityRecords(
		'root',
		'widget',
		buildWidgetsQuery()
	);

	return keyBy( widgets, 'id' );
} );

To make it work, you'd need to somehow hook into the actual select( store ), store all the synchronous calls, and access them in useSelect. I wonder it that could get somehow mixed up with an unrelated mapSelect too.

@adamziel
Copy link
Contributor

adamziel commented Nov 5, 2020

@kevin940726 I played with it more and came up with this: #26733 - still not working properly though

@gziolo gziolo added [Package] Data /packages/data [Type] Enhancement A suggestion for improvement. labels Nov 8, 2020
@kevin940726 kevin940726 force-pushed the try/use-select-performance branch 2 times, most recently from 6866559 to 048c33b Compare November 10, 2020 07:09
@kevin940726 kevin940726 marked this pull request as ready for review November 10, 2020 10:06
@kevin940726 kevin940726 requested a review from nerrad as a code owner November 10, 2020 10:06
@youknowriad
Copy link
Contributor

Does it support this as well #26724 (comment) ?

@kevin940726
Copy link
Member Author

@youknowriad Yep, it's also in the unit tests :).

@adamziel
Copy link
Contributor

@kevin940726 I think this PR is obsolete now that #26866 was merged

@kevin940726
Copy link
Member Author

@kevin940726 I think this PR is obsolete now that #26866 was merged

Thx for the notice! Note to myself that I'll try to run some of the unit tests introduced here after rebasing and close this 👍.

@youknowriad
Copy link
Contributor

Actually, I'm considering reverting the @wordpress/stan temporarily (to give me time to do more performance tests as I discovered some issues that need clarification). Maybe we can land this smaller approach after the revert.

@kevin940726 kevin940726 force-pushed the try/use-select-performance branch from 5c099ce to 0ce8271 Compare November 25, 2020 08:47
@kevin940726
Copy link
Member Author

@youknowriad Thx for the inputs! I've rebased the branch and updated the performance report in the description.

@youknowriad
Copy link
Contributor

It seems the performance impact here is good but not as good as what we had in the atom's approach.
I believe the difference with atoms, is that there was no need for the selectors to run again inside useEffect on first mount. This optimization though, doesn't seem to be possible here.

Copy link
Contributor

@youknowriad youknowriad left a comment

Choose a reason for hiding this comment

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

LGTM

Copy link
Contributor

@adamziel adamziel left a comment

Choose a reason for hiding this comment

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

I like the performance gains, and the code looks good as well. 🚢

@adamziel
Copy link
Contributor

adamziel commented Nov 27, 2020

I believe the difference with atoms, is that there was no need for the selectors to run again inside useEffect on first mount. This optimization though, doesn't seem to be possible here.

Our stores are large so it's normal to have selectors "watching" a branch that remains the same over multiple "notifyListeners" calls. That's another difference with the atoms approach where only relevant changes bubble up to their observers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Package] Data /packages/data [Type] Enhancement A suggestion for improvement.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants