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

Add useStoreSelectors hook #26692

Merged
merged 15 commits into from
Nov 6, 2020
Merged

Add useStoreSelectors hook #26692

merged 15 commits into from
Nov 6, 2020

Conversation

adamziel
Copy link
Contributor

@adamziel adamziel commented Nov 4, 2020

Description

This PR evolved into a shorthand hook called useStoreSelectors, used like this:

const {
	selectionStart,
	selectionEnd,
} = useStoreSelectors(
         'core/editor',
	( { getEditorSelectionStart, getEditorSelectionEnd } ) => ( {
		selectionStart: getEditorSelectionStart(),
		selectionEnd: getEditorSelectionEnd(),
	} )
);

Dev notes

Added useStoreSelectors hook

useStoreSelectors is a custom react hook for retrieving selectors from registered stores. It could be considered a shorthand for useSelect() that provides all the selectors from a given store. For example:

const { useStoreSelectors } = wp.data;

function HammerPriceDisplay( { currency } ) {
  const price = useStoreSelectors(
    'my-shop',
    ( { getPrice } ) => getPrice( 'hammer', currency ),
    [ currency ]
  );
  return new Intl.NumberFormat( 'en-US', {
    style: 'currency',
    currency,
  } ).format( price );
}

// Rendered in the application:
// <HammerPriceDisplay currency="USD" />

In the above example, when HammerPriceDisplay is rendered into an application, the price will be retrieved from the store state using the mapSelectors callback on useStoreSelectors. If the currency prop changes then any price in the state for that currency is retrieved. If the currency prop doesn't change and other props are passed in that do change, the price will
not change because the dependency is just the currency.

Original description for posterity:

Every key stroke in the editor re-runs all registered selectors, sometimes multiple times. useSelect() is great for monitoring state changes. but problem with it is that the mapSelect function is executed on each registry change, related or not. Fortunately the component is only re-rendered if the newly computed value is different from the last one, but the selector is still executed every time.

This PR proposes a first argument to useSelect called store. If provided, useSelect will only subscribe to specific stores instead of the entire registry. Consider the following setup:

const MyComponent = () => {
    const someStatistic = useSelect(( select ) => {
        console.log("Value re-computed");
        return select('core/edit-widgets').getSomeStatistic()
    });
    console.log("Component re-rendered");
    const { editEntityRecord } = useDispatch( 'core' );
    return (
        <div>
            Some statistic: {someStatistic}
            <textarea onInput=((e) => { editEntityRecord('root', 'postType', 15, { value: e.target.value } }) />
        </div>
    );
}

Typing "Gutenberg" in the textarea would log the following:

Before:

console.log("Value re-computed"); // Initial render
console.log("Component re-rendered");
console.log("Value re-computed"); // G
console.log("Value re-computed"); // u
console.log("Value re-computed"); // t
console.log("Value re-computed"); // e
console.log("Value re-computed"); // n
console.log("Value re-computed"); // b
console.log("Value re-computed"); // e
console.log("Value re-computed"); // r
console.log("Value re-computed"); // g

After:
Adding store dependencies like:

    const someStatistic = useSelect( 'core/edit-widgets', ( { getSomeStatistic } ) => {
        console.log("Value re-computed");
        return getSomeStatistic();
    }, []);

Reduces the number of times the selector is triggered:

console.log("Value re-computed"); // Initial render
console.log("Component re-rendered");

Note that the following code implies that deps will be passed to useCallback:

useSelect( 'core/data', fn );

I think it's an okay limitation though as the alternative would be fairly complex to implement. One is better off using deps anyway.

Other considerations

Specifying dependent stores for all selectors should yield noticeable performance gains, but we can go even further. Some ideas:

  • Don't re-run mapSelect immediately, instead use throttle of let's say 10ms (Throttle useSelect calls #26695)
  • Replace dirty-checking with observables as much as possible
  • Specify specific state branch as a dependency (entity type? entity id?)

How has this been tested?

  1. Confirm the unit tests pass
  2. A follow-up PR will add a few usage to the widgets editor, it will be much easier to reason about the behavior then

Types of changes

New feature (non-breaking change which adds functionality)

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.

@github-actions
Copy link

github-actions bot commented Nov 4, 2020

Size Change: +29 B (0%)

Total Size: 1.21 MB

Filename Size Change
build/data/index.js 8.8 kB +29 B (0%)
ℹ️ View Unchanged
Filename Size Change
build/a11y/index.js 1.14 kB 0 B
build/annotations/index.js 3.78 kB 0 B
build/api-fetch/index.js 3.45 kB 0 B
build/autop/index.js 2.84 kB 0 B
build/blob/index.js 665 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-editor/index.js 133 kB 0 B
build/block-editor/style-rtl.css 11.2 kB 0 B
build/block-editor/style.css 11.2 kB 0 B
build/block-library/editor-rtl.css 9.01 kB 0 B
build/block-library/editor.css 9.01 kB 0 B
build/block-library/index.js 146 kB 0 B
build/block-library/style-rtl.css 7.98 kB 0 B
build/block-library/style.css 7.98 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.88 kB 0 B
build/block-serialization-spec-parser/index.js 3.1 kB 0 B
build/blocks/index.js 48.1 kB 0 B
build/components/index.js 172 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.81 kB 0 B
build/core-data/index.js 12.5 kB 0 B
build/data-controls/index.js 772 B 0 B
build/date/index.js 31.8 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.46 kB 0 B
build/edit-navigation/index.js 11.2 kB 0 B
build/edit-navigation/style-rtl.css 881 B 0 B
build/edit-navigation/style.css 885 B 0 B
build/edit-post/index.js 306 kB 0 B
build/edit-post/style-rtl.css 6.41 kB 0 B
build/edit-post/style.css 6.39 kB 0 B
build/edit-site/index.js 22.5 kB 0 B
build/edit-site/style-rtl.css 3.91 kB 0 B
build/edit-site/style.css 3.91 kB 0 B
build/edit-widgets/index.js 26.3 kB 0 B
build/edit-widgets/style-rtl.css 3.12 kB 0 B
build/edit-widgets/style.css 3.12 kB 0 B
build/editor/editor-styles-rtl.css 480 B 0 B
build/editor/editor-styles.css 482 B 0 B
build/editor/index.js 42.8 kB 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.65 kB 0 B
build/escape-html/index.js 735 B 0 B
build/format-library/index.js 7.71 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.16 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 712 B 0 B
build/keyboard-shortcuts/index.js 2.52 kB 0 B
build/keycodes/index.js 1.94 kB 0 B
build/list-reusable-blocks/index.js 3.11 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.34 kB 0 B
build/notices/index.js 1.79 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 791 B 0 B
build/redux-routine/index.js 2.85 kB 0 B
build/reusable-blocks/index.js 3.06 kB 0 B
build/rich-text/index.js 13.2 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.06 kB 0 B
build/viewport/index.js 1.84 kB 0 B
build/warning/index.js 1.14 kB 0 B
build/wordcount/index.js 1.22 kB 0 B

compressed-size-action

@ellatrix
Copy link
Member

ellatrix commented Nov 4, 2020

What if we limit useSelect to a single store per call? If you want to use multiple store, make multiple calls. This is already the case for useDispatch.

useSelect( 'core/block-editor', ( { getSomething } ) => getSomething( dependency ), [ dependency ] );

That also makes it more similar to useDispatch, just with mapping included.

useSelect( storeKey, mapping, dependencies );
useDispatch( storeKey );

@adamziel
Copy link
Contributor Author

adamziel commented Nov 4, 2020

@ellatrix I like it! let's try that

Comment on lines 84 to 90
if ( arguments.length === 3 ) {
[ storeKey, _mapSelect, deps ] = arguments;
} else {
// Backwards compatible signature
storeKey = null;
[ _mapSelect, deps ] = arguments;
}
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 here to keep the arguments in README and make the linter happy. I don't like it too much. The alternative is having a new useStoreSelect selector and deprecate this one, but I don't like a longer name for the recommended default. Thoughts?

Copy link
Member

Choose a reason for hiding this comment

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

Backwards compatibility code is good to have?

useStoreSelect would be interesting if we export it for the modules creating the store instead of importing the general useSelect from the data module. Tbh, I think that makes more sense than hardcoding the keys as strings. @gziolo was also working on a PR to export keys from the components creating the stores.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is good to have, I just don't like processing arguments like that.

useStoreSelect would be interesting if we export it for the modules creating the store instead of importing the general useSelect from the data module. Tbh, I think that makes more sense than hardcoding the keys as strings. @gziolo was also working on a PR to export keys from the components creating the stores.

Sounds like same goes for useDispatch(). It would work, we'd just have to update all the store-registering modules and remember about exporting this in the newly created ones 🤔

Copy link
Contributor Author

@adamziel adamziel Nov 4, 2020

Choose a reason for hiding this comment

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

Hm..

// Deprecated, calls the callback with a `select` function
import { useSelect } from '@wordpress/data';

// Recommended, calls the callback with module-specific selectors:
import { useSelect as useCoreDataSelect } from '@wordpress/core-data';
import { useSelect as useEditWidgetsSelect } from '@wordpress/edit-widgets';

I'm not sure yet if I like it or not 😄

Copy link
Contributor Author

@adamziel adamziel Nov 4, 2020

Choose a reason for hiding this comment

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

Similarly:

// Deprecated, a function expecting a module as an argument
import { useDispatch } from '@wordpress/data';

// Recommended, function that takes no arguments
import { useDispatch as useCoreDataDispatch } from '@wordpress/core-data';
import { useDispatch as useEditWidgetsDispatch } from '@wordpress/edit-widgets';

Copy link
Contributor Author

@adamziel adamziel Nov 4, 2020

Choose a reason for hiding this comment

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

I'm intrigued though, I'll get in touch with @gziolo and let's see where it goes.

@adamziel
Copy link
Contributor Author

adamziel commented Nov 4, 2020

@ellatrix I updated the code and PR description.

@ellatrix
Copy link
Member

ellatrix commented Nov 4, 2020

It would be nice it update e.g. all useSelect calls in the block editor package and maybe paragraph block and see how it impacts performance.

@noisysocks
Copy link
Member

Performance improvements aside, I prefer this API. There's less boilerplate and it makes useSelect and useDispatch consistent. What do you think about deprecating the old signature?

Comment on lines 84 to 91
if ( arguments.length < 3 ) {
// Backwards compatible signature
[ storeKey, _mapSelect, deps ] = [
null,
arguments[ 0 ],
arguments[ 1 ],
];
}
Copy link
Member

Choose a reason for hiding this comment

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

Can we instead just check the type of the first argument? If it's a function then it's mapSelect, if it's a string then it's storeKey?

Copy link
Member

Choose a reason for hiding this comment

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

I agree that it might be handled differently.

In general, extending API surface for useSelect creates some challenges:

  • how to update JSDoc comment to cover new usage to ensure that proper documentation is generated for developers
  • how to announce new API in a way that it doesn't become confusing, is it clear when to use which form?

@@ -203,4 +203,79 @@ describe( 'useSelect', () => {
}
);
} );

it( 'uses memoized selector if dependent stores do not change', async () => {
Copy link
Member

Choose a reason for hiding this comment

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

Nitpick: we don't need the async keyword here?

Comment on lines 228 to 230
const selectSpyFoo = jest
.fn()
.mockImplementation( ( { getCounter } ) => getCounter() );
Copy link
Member

Choose a reason for hiding this comment

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

Nitpick: We can just write it like

Suggested change
const selectSpyFoo = jest
.fn()
.mockImplementation( ( { getCounter } ) => getCounter() );
const selectSpyFoo = jest.fn( ( { getCounter } ) => getCounter() );

Copy link
Member

Choose a reason for hiding this comment

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

Isn't it how Prettier formats it? I would personally leave all formatting choices to tools.

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 @kevin940726 meant omitting the .mockImplementation part

packages/data/src/components/use-select/test/index.js Outdated Show resolved Hide resolved
@adamziel
Copy link
Contributor Author

adamziel commented Nov 5, 2020

More audacious explorations here: #26723

It would be nice it update e.g. all useSelect calls in the block editor package and maybe paragraph block and see how it impacts performance.

Totally agreed! I'll be happy to explore that once we settle on specific API so that my explorations can double as a merge-able PR. As a side note - even if the performance gains are marginal I think we came up with a cleaner API that's worth having anyway.

Copy link
Member

@gziolo gziolo left a comment

Choose a reason for hiding this comment

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

Nice exploration. I wanted to point to prior work done in the same area at the tame when React hooks weren't so widely used, but some of the challenges might still be valid:

The main reason why this idea was abandoned is:

There are good ideas here, but with consideration of registry-enhanced selectors as introduced by #13662, the ability to determine store dependencies from a withSelect has become much more complex than this naive implementation can support.

where #13662 allows to:

In some situations, you want to build selectors that target multiple stores at the same time. Until now we were relying on the global select function but the issue is that it only targets the default registry. If we have a separate provider, this might not work as expected. In this PR, I'm introducing a createRegistrySelector helper used to mark a selector a cross-stores selector and providing a registry object.

An example of the selector that depends on another store:

export const getNewBlockTypes = createRegistrySelector(
( select ) => ( state ) => {
const usedBlockTree = select( 'core/block-editor' ).getBlocks();
const installedBlockTypes = getInstalledBlockTypes( state );
return installedBlockTypes.filter( ( blockType ) =>
hasBlockType( blockType, usedBlockTree )
);
}
);

A follow-up comment might be still relevant in this context: #13177 (comment).

There is also this issue that should be probably addressed first, which is how to ensure that there is a simple way to identify a given store as in many aspects using hardcoded strings doesn't scale well. See my proposal in #26655.

packages/data/src/components/use-select/test/index.js Outdated Show resolved Hide resolved
Comment on lines 84 to 91
if ( arguments.length < 3 ) {
// Backwards compatible signature
[ storeKey, _mapSelect, deps ] = [
null,
arguments[ 0 ],
arguments[ 1 ],
];
}
Copy link
Member

Choose a reason for hiding this comment

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

I agree that it might be handled differently.

In general, extending API surface for useSelect creates some challenges:

  • how to update JSDoc comment to cover new usage to ensure that proper documentation is generated for developers
  • how to announce new API in a way that it doesn't become confusing, is it clear when to use which form?

@gziolo
Copy link
Member

gziolo commented Nov 5, 2020

There is one more thing that we should keep in mind when working towards this proposal. In some cases useSelect can use selectors from several stores and some data is only used to pass as arguments to other selectors or to create conditions to use with data returned from the hook. Some examples:

const { isActive, isRequestingSiteIcon, postType, siteIconUrl } = useSelect(
( select ) => {
const { getCurrentPostType } = select( 'core/editor' );
const { isFeatureActive } = select( 'core/edit-post' );
const { isResolving } = select( 'core/data' );
const { getEntityRecord, getPostType } = select( 'core' );
const siteData =
getEntityRecord( 'root', '__unstableBase', undefined ) || {};
return {
isActive: isFeatureActive( 'fullscreenMode' ),
isRequestingSiteIcon: isResolving( 'core', 'getEntityRecord', [
'root',
'__unstableBase',
undefined,
] ),
postType: getPostType( getCurrentPostType() ),
siteIconUrl: siteData.site_icon_url,
};
},
[]
);

} = useSelect( ( select ) => {
const {
hasInserterItems,
getBlockRootClientId,
getBlockSelectionEnd,
} = select( 'core/block-editor' );
return {
hasFixedToolbar: select( 'core/edit-post' ).isFeatureActive(
'fixedToolbar'
),
// This setting (richEditingEnabled) should not live in the block editor's setting.
isInserterEnabled:
select( 'core/edit-post' ).getEditorMode() === 'visual' &&
select( 'core/editor' ).getEditorSettings()
.richEditingEnabled &&
hasInserterItems(
getBlockRootClientId( getBlockSelectionEnd() )
),
isInserterOpened: select( 'core/edit-post' ).isInserterOpened(),
isTextModeEnabled:
select( 'core/edit-post' ).getEditorMode() === 'text',
previewDeviceType: select(
'core/edit-post'
).__experimentalGetPreviewDeviceType(),
showIconLabels: select( 'core/edit-post' ).isFeatureActive(
'showIconLabels'
),
isNavigationTool: select( 'core/block-editor' ).isNavigationMode(),
hasReducedUI: select( 'core/edit-post' ).isFeatureActive(
'reducedUI'
),
};
}, [] );

const { sidebarName, keyboardShortcut } = useSelect( ( select ) => {
// The settings sidebar is used by the edit-post/document and edit-post/block sidebars.
// sidebarName represents the sidebar that is active or that should be active when the SettingsSidebar toggle button is pressed.
// If one of the two sidebars is active the component will contain the content of that sidebar.
// When neither of the the two sidebars is active we can not simply return null, because the PluginSidebarEditPost
// component, besides being used to render the sidebar, also renders the toggle button. In that case sidebarName
// should contain the sidebar that will be active when the toggle button is pressed. If a block
// is selected, that should be edit-post/block otherwise it's edit-post/document.
let sidebar = select( 'core/interface' ).getActiveComplementaryArea(
'core/edit-post'
);
if (
! [ 'edit-post/document', 'edit-post/block' ].includes( sidebar )
) {
if ( select( 'core/block-editor' ).getBlockSelectionStart() ) {
sidebar = 'edit-post/block';
}
sidebar = 'edit-post/document';
}
const shortcut = select(
'core/keyboard-shortcuts'
).getShortcutRepresentation( 'core/edit-post/toggle-sidebar' );
return { sidebarName: sidebar, keyboardShortcut: shortcut };
}, [] );

The more high-level components, the higher the chance that this optimization is going to be harder to apply.

@nerrad
Copy link
Contributor

nerrad commented Nov 5, 2020

My feedback here aligns very much along the lines of what @gziolo has already outlined. However, I do like the idea of optionally being able to subscribe to a single store (this has come up numerous times).

Before committing to this significant change though, have you done any performance measurements to see what actual impact this has? Relying on console.log dumps is a signal of a potential performance improvement but can also be misleading in a React context where actual impact is minimal. Would the performance checks via npm run test-performance measure these changes? If so it'd be nice to get some numbers to compare.

@adamziel
Copy link
Contributor Author

adamziel commented Nov 5, 2020

All good notes, thank you @gziolo and @nerrad !

Relying on console.log dumps is a signal of a potential performance improvement but can also be misleading in a React context where actual impact is minimal.

Of course! My point was more illustrative than qualitative. I didn't talk about actual performance improvements because I wasn't quite sure how to measure the potential gains without actually refactoring everything to store-specific selectors. Some baseline measures I took in chrome devtools showed that runSelector takes anywhere between 8%-13% of total blocking time depending on how I use the editor (fast typing, pressing enter, triggering toolbar). If I interact with the editor heavily and JS gets blocked occasionally, that's more like 8%-13% of the real time, e.g. 130ms out of every second is spent on just re-running selectors. I hoped maybe we could get that to ~20ms by just not calling most of them.

I spent more time thinking about this and I found a way to assess potential gains. I figured I could keep track of the stores used by each selector like this:

const dependentStore = useRef();
const mapSelect = useCallback( ( select, registry ) => {
	registry.__experimentalStartRecordingSelectStores();
	const retval = _mapSelect( select( storeKey ), registry );
	const stores = registry.__experimentalStopRecordingSelectStores();
	const newDependentStore = stores.length === 1 ? stores[0] : false;
	if ( newDependentStore !== dependentStore.current ) {
		dependentStore.current = newDependentStore;
		forceRender();
	}
	return retval;
}, deps );

Which then makes it trivial to subscribe to either the entire registry or just a single store, depending on the value of stores:

	if ( dependentStore.current ) {
		unsubscribe = registry.stores[ dependentStore.current ].subscribe( onChange );
	} else {
		unsubscribe = registry.subscribe( onChange );
	}

Which, by the way, makes it possible to keep track of the exact stores a selector needs to subscribe to even when registry selectors are involved (cc @gziolo @kevin940726). Unfortunately, even with that in place I saw the same 8%-13% range in the performance monitor. Boo hoo.

If that makes sense as a methodology, I think it concludes the part of the discussion about performance gains.

There's also the part about having a potentially nicer API like this:

const {
	selectionStart,
	selectionEnd,
} = useStoreSelectors(
         'core/editor',
	( { getEditorSelectionStart, getEditorSelectionEnd } ) => ( {
		selectionStart: getEditorSelectionStart(),
		selectionEnd: getEditorSelectionEnd(),
	} )
);

Or even this, using a shorthand notation:

const {
	getEditorSelectionStart: selectionStart,
	getEditorSelectionEnd: selectionEnd,
} = useStoreSelectors( 'core/editor', [ 'getEditorSelectionStart', 'getEditorSelectionEnd' ] );

I updated this PR with a draft of what could become useStoreSelectors.

@gziolo
Copy link
Member

gziolo commented Nov 6, 2020

I guess it's very difficult to verify the overall impact on performance without doing a major refactor.

There's also the part about having a potentially nicer API like this:

const {
	selectionStart,
	selectionEnd,
} = useStoreSelectors(
         'core/editor',
	( { getEditorSelectionStart, getEditorSelectionEnd } ) => ( {
		selectionStart: getEditorSelectionStart(),
		selectionEnd: getEditorSelectionEnd(),
	} )
);

One interesting thing about introducing useStoreSelectors is that we could revisit your explorations once this new API is more widely used. I like the new hook proposed.

Or even this, using a shorthand notation:

const {
	selectionStart,
	selectionEnd,
} = useStoreSelectors( 'core/editor', [ 'selectionStart', 'selectionEnd' ] );

I updated this PR with a draft of what could become useStoreSelectors.

I'm not entirely sure how this shorthand version would work. Given the efforts required to explain how this notation should be used and in which cases it is applicable, it's probably better to remove it from the scope of this PR.

@adamziel
Copy link
Contributor Author

adamziel commented Nov 6, 2020

@gziolo I removed the array shorthand and added tests to this PR. I still think there's a value in the array notation, but let's call it out of scope for this PR - I may explore it in another one, maybe as another hook.

@adamziel adamziel changed the title Allow useSelect per store Add useStoreSelectors hook Nov 6, 2020
@@ -712,6 +712,24 @@ _Returns_

- `Function`: A custom react hook.

<a name="useStoreSelectors" href="#useStoreSelectors">#</a> **useStoreSelectors**

A shorthand for useSelect() that returns all the selectors from a given store.
Copy link
Member

Choose a reason for hiding this comment

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

useSelect is explained as:

Custom react hook for retrieving props from registered selectors.

Documentation should follow the same theme. Doesn't it rather returns data processed by selectors from a single store.

Copy link
Member

Choose a reason for hiding this comment

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

A usage example would be very helpful as well.

One more, why did you pick the ending Selectors rather the Select that the original hook uses?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

One more, why did you pick the ending Selectors rather the Select that the original hook uses?

The original goes like this: useSelect( (select) => select('store').selector() ) - what you get is the select function, hence the name useSelect makes sense.

This hook goes like: useStoreSelectors( ({selector}) => selector() ) - what you get are all the selectors from a given store, hence the name useStoreSelectors makes more sense than useStoreSelect.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, it sounds convincing. 👍

Aside: I'm wondering if @nerrad had the same mindset when proposing useSelect :)

Copy link
Contributor

Choose a reason for hiding this comment

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

As a matter of fact: #15512

@adamziel adamziel merged commit 24a4f44 into master Nov 6, 2020
@adamziel adamziel deleted the try/use-select-store-deps branch November 6, 2020 15:37
@github-actions github-actions bot added this to the Gutenberg 9.4 milestone Nov 6, 2020
@youknowriad
Copy link
Contributor

Hey there! I'm not really convinced about this new hook, for me it's just a duplicate API over useSelect. it's a bit nicer for sure if you just need selectors from a store but that's rather a specific use-case and I'm not sure we want to maintain duplicate APIs for this.

@youknowriad
Copy link
Contributor

Also, why selectors and not actions?

For me, it feels like all what this will end up doing is losing consistency in our components since most would still need useSelect while some would use that one. Is the shorthand syntax here worth it enough to introduce this inconsistency and force contributors to learn a new hook?

@ellatrix
Copy link
Member

ellatrix commented Nov 6, 2020

I don't get the merged API. 🤔 So we're not reducing the amount of the subscriptions and selector calls by subscribing to one store only? We still go for the hardcoded store key strings?

@adamziel
Copy link
Contributor Author

adamziel commented Nov 9, 2020

@youknowriad @ellatrix I think this is pivoting into a more general discussion about selectors and registry API. In the next hour I'll spin a new issue to fully describe my thinking and potentially centralize all the different threads we're pulling. It may or may not be resolved quickly - what do you think about renaming this one to __experimentalUseStoreSelectors until then?

@adamziel
Copy link
Contributor Author

adamziel commented Nov 9, 2020

Actually I changed my mind, let's at least try to continue here. I'm still happy to create a new issue for that anytime if someone brings it up.

@youknowriad This work is very emergent but I'll do my best to answer all the questions. It will be indirect at first, but I promise I'll get there!

Overview

I imagine the data flow across components and selectors as a complex graph like this:

Zrzut ekranu 2020-11-9 o 13 42 34

Circles are selectors. Some selectors are simple and rely on just a single store. Other selectors depend on selectors from other stores which means they require registry access. Since we don't declare dependencies statically, it's hard or impossible to tell which selectors rely on which stores. Thus the components must listen to all changes on the entire registry:

Zrzut ekranu 2020-11-9 o 13 43 46

This is essentially a star topology with every change affecting every node in one way or another. Adding nodes and interactions scales non-linearly and following the graph-based data flow is harder than necessary.

Now, what is emerging from my recent discussions with @gziolo is that this entire model could be simplified like this:

Zrzut ekranu 2020-11-9 o 14 23 39

In this model, there are no registry selectors (an idea explored in another thread). Instead, components pull the data directly from the stores they are interested in. There is no concept of dependency graph at all, only a shallow list of dependent stores.

Of course, registry selectors sometimes contain complex logic - where should that logic go? Well, there already exists a mechanism that makes it possible to achieve exactly the same thing as registry selectors: React hooks.

Rationale

As noted in another thread, registry selectors may be a solution to a problem we just don't have anymore in core. If that's true, then they are just an inferior alternative to react hooks:

  • They make it hard/impossible to reliably subscribe to only those stores that are relevant - a dependent selector may change one day and break some components.
  • All registry selectors are public API.
  • It's unclear when to use registry selectors and when to use react hooks.
  • Registry selectors create a complex dependency graph that's hard to reason about. It is also a structure with a larger problem area, e.g. a graph may contain circular dependencies that won't be caught statically by webpack.
  • The complexity of the data flow scales non-linearly.

Also, as @nerrad mentioned above, the idea of subscribing to specific stores came up multiple times already:

However, I do like the idea of optionally being able to subscribe to a single store (this has come up numerous times).

Where we are now

As you noticed, for now this is just a duplicate API. I am a big proponent of having a moderate amount of well-placed shorthands, I believe they make the final code more succinct and easier to read. The maintenance and learning cost doesn't seem large to me as it is just a simple wrapper fulfilling the mythical 100% code coverage goal. However, I am fairly new to Gutenberg so I will fully trust the judgement of more experienced folks.

But let's look beyond that. Having a store-specific selector is a necessary first step for store-based subscriptions which I believe is an ideal state worth pursuing.

Besides the rationale laid out in the previous sections, there are potential performance gains involved. They are hard to assess upfront, for example I tried here: #26692 (comment)

I found that selectors-related code takes 10% of the blocking time when typing heavily. That's not 90% or 40% and there are definitely heavier tasks such as multiple re-renders going on. Still, it's 10% that we could potentially take down to 5% or 2%. I don't have a good idea how to reliably measure the impact of a full refactor. I tried to hack together a performance test, but it didn't show anything meaningful so I just abandoned this line of discussion until I'm able to get a better measurement.

Surface area

None of what you'll see below includes the numbers for registry selectors - that would be much harder to analyze. Still, I took a few naive measures to assess how many components relies on a single store vs multiple stores. According to the pipeline below:

$ git grep -A 1 'select(' | grep 'core/'  | grep "/components/" | gsed -E "s#^([^\t ]+)[\t ]([^']+)'([^']+)'.+#\1 \3#g" | uniq | awk '/ /{ print $1 $2 }' | uniq | awk -F: '{arr[$1]++}END{for (a in arr) print arr[a]": "a}' | sort -n
1 packages/block-directory/src/components/auto-block-uninstaller/index.js
1 packages/block-directory/src/components/downloadable-block-notice/index.js
...
$ !! |  awk -F: '{arr[$1]++}END{for (a in arr) print arr[a], a}' 
177 1
30 2
6 3
2 5
1 6

There are 177 components relying on just a single store, 30 relying on two stores, and only a single component relying on 6 stores.

I also measured how many components rely on each store:

$ git grep -A 1 'select(' | grep 'core/'  | grep "/components/" | gsed -E "s#^([^\t ]+)[\t ]([^']+)'([^']+)'.+#\1 \3#g" | uniq | awk '{print $2": "$1}' | sed "s/'//g" | uniq | sort
core/block-directory: packages/block-directory/src/components/auto-block-uninstaller/index.js:
core/block-directory: packages/block-directory/src/components/downloadable-block-list-item/index.js-
...
$ !! | awk -F: '{arr[$1]++}END{for (a in arr) print arr[a]": "a}' | sort -n
1: core/edit-navigation
1: core/edit-post/toggle-block-navigation
1: core/nux
1: core/viewport
3: core/data
4: core/block-directory
4: core/notices
5: core/edit-widgets
7: core/interface
9: core/keyboard-shortcuts
12: core/edit-site
18: core/blocks
53: core/edit-post
91: core/editor
131: core/block-editor

Direct answers

I talked about the maintenance/learning cost earlier, but there was also a concern @youknowriad raised about the consistency:

Is the shorthand syntax here worth it enough to introduce this inconsistency and force contributors to learn a new hook?

My thinking is that if we can get rid of the dependency graph, it would be worth it. But yes, at the moment this is just a tiny wrapper that doesn't do much.

I don't get the merged API. 🤔 So we're not reducing the amount of the subscriptions and selector calls by subscribing to one store only?

I'd like to get there in the end, this is just a "foot in the door" so to speak. I'm happy to make it an experimental API for now.

We still go for the hardcoded store key strings?

I'm all for object-based store keys, it's just beyond the scope of this PR. @gziolo is working on that change in another PR I believe - I don't want to steal his thunder :-)

Other considerations

useSelect combined with eslint rule requiring unique variable names involves some awkward naming conventions with _ prefixes, I really dislike that:

const { parentBlockType, firstParentClientId, shouldHide } = useSelect(
		( select ) => {
			const {
				getBlockName,
				getBlockParents,
				getSelectedBlockClientId,
			} = select( 'core/block-editor' );
			const { hasBlockSupport } = select( 'core/blocks' );
			const selectedBlockClientId = getSelectedBlockClientId();
			const parents = getBlockParents( selectedBlockClientId );
			const _firstParentClientId = parents[ parents.length - 1 ];
			const parentBlockName = getBlockName( _firstParentClientId );
			const _parentBlockType = getBlockType( parentBlockName );

@youknowriad
Copy link
Contributor

This is essentially a star topology with every change affecting every node in one way or another. Adding nodes and interactions scales non-linearly and following the graph-based data flow is harder than necessary.

How is this any different from any redux store, it's still a global store with everything in it. The only difference in the data module is that we try to split some smaller stores in an organized fashion low level stores but in the end, the top level store say edit-post or editor, still need too access data across stores and merge them in a single selector. If we don't do it at the selector level like we do with registry selectors, we'll end up duplicating this logic in components in useSelect calls.

getEditedPostContent is a good example here, How would you write this with stores that are completely independent?


Overall, my opinion is that we're trying to solve the wrong problem here tbh. What are we trying to achieve with these? It'sn to clear to me, it's it about performance? Show me the numbers this has, I don't believe it has any meaningful impact personally but happy to be wrong here.

For me, until we know what we're trying to solve, it's very premature to introduce APIs we might regret later.

@adamziel
Copy link
Contributor Author

adamziel commented Nov 9, 2020

@youknowriad let's separate two threads we're touching on here.

Thread 1 - this PR

Is the shorthand syntax here worth it enough to introduce this inconsistency and force contributors to learn a new hook?

For now I don't have much more than my strong opinion about shorthands 😄 I hear what you're saying, let's revert this PR and revisit if / when the part below reaches a conclusion.

Thread 2 - the bigger picture

How is this any different from any redux store, it's still a global store with everything in it.

The big picture isn't inherently different, it's just this is a highly interactive app with a high number of state actions and subscriptions. It differs in details - the store is divided into smaller modules. IMHO that division could involve less code and a simpler mental model.

What are we trying to achieve with these?

This part is more about the graphs and circles I posted above. I think core data is pretty complex and I think we are very much capable of reducing that complexity. Performance gains are possible but since it's hard to take reliable measures upfront, that's more of a gamble than a solid basis.

If we don't do it at the selector level like we do with registry selectors, we'll end up duplicating this logic in components in useSelect calls.

Not necessarily duplicating, my thinking is that the logic could still live in a single place. Consider:

// component.js:
	const postContent = useSelect(
		( select ) => select( 'core/editor' ).getEditedPostContent(),
		[]
	);

// selectors.js:
export const getEditedPostContent = createRegistrySelector(
	( select ) => ( state ) => {
		const postId = getCurrentPostId( state );
		const postType = getCurrentPostType( state );
		const record = select( 'core' ).getEditedEntityRecord(
			'postType',
			postType,
			postId
		);
		if ( record ) {
			if ( typeof record.content === 'function' ) {
				return record.content( record );
			} else if ( record.blocks ) {
				return serializeBlocks( record.blocks );
			} else if ( record.content ) {
				return record.content;
			}
		}
		return '';
	}
);

The following hook seems to be interchangeable:

// component.js:
	const postContent = useEditedPostContent();

// hooks.js
export const useEditedPostContent = () => {
	const [ postId, postType ] = useStoreSelectors( 'core/editor', ( { getCurrentPostId, getCurrentPostType } ) => ([
		getCurrentPostId(), getCurrentPostType()
	]));
	const record = useStoreSelectors( 'core', ( { getEditedEntityRecord }) => getEditedEntityRecord(
		'postType',
		postType,
		postId
	), [ postType, postId ]);
	if ( record ) {
		if ( typeof record.content === 'function' ) {
			return record.content( record );
		} else if ( record.blocks ) {
			return serializeBlocks( record.blocks );
		} else if ( record.content ) {
			return record.content;
		}
	}
	return '';
} );

Although I'm not sure what to do with usages like this:

content: yield controls.select( STORE_KEY, 'getEditedPostContent' ),

@adamziel
Copy link
Contributor Author

adamziel commented Nov 9, 2020

Either way, the conclusion for now is to revert this PR. If I can come up with a solid design improvement for selectors, I'll create a new issue.

adamziel added a commit that referenced this pull request Nov 9, 2020
@gziolo
Copy link
Member

gziolo commented Nov 9, 2020

@adamziel, great job doing all explorations, I personally learned a ton. The discussion triggered in this PR following the merge is a clear sign that the data layer became very complex over time because of many legacy implications like the limitation HOCs created. Hooks open new possibilities that are worth exploring but the number of backward compatibility considerations makes the process extremely challenging. @youknowriad thanks for sharing more context which isn't obvious to other contributors that can't follow closely development happening in this area.

adamziel added a commit that referenced this pull request Nov 9, 2020
@gziolo gziolo mentioned this pull request Nov 13, 2020
1 task
@noisysocks noisysocks mentioned this pull request Jan 28, 2021
9 tasks
@youknowriad youknowriad removed the Needs Dev Note Requires a developer note for a major WordPress release cycle label Jun 1, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Package] Core data /packages/core-data [Package] Data /packages/data
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants