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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Memoize getEntityRecords to prevent infinite re-renders #26447

Merged
merged 7 commits into from
Nov 3, 2020

Conversation

kevin940726
Copy link
Member

Description

Memoize reusableBlocks to prevent infinite re-renders in some cases.

reusableBlocks is called from getEntityRecords here:

reusableBlocks: select( 'core' ).getEntityRecords(
'postType',
'wp_block'
),

When the data hasn't arrived yet, getEntityRecords will return an empty array as defined here:

if ( ! queriedState ) {
return [];
}

Keep in mind that, for every change to the store, getEntityRecords will return a new instance of the empty array.

reusableBlocks is then passed to useMemo below to form a settings object.

settings is passed to <BlockEditorProvider> below, which schedules an effect to update the settings whenever it changes:

useEffect( () => {
updateSettings( settings );
}, [ settings ] );

This process creates an infinite loop before the quriedData arrived.

  1. getEntityRecords returns a new empty array, hence a new reusableBlocks.
  2. Since reusableBlocks changes, useMemo computes a new settings.
  3. Since settings changes, useEffect calls updateSettings to update the store.
  4. Since the store changes, useSelect calls getEntityRecords again and returns a new empty array, back to (1).

This loop only ends when getEntityRecords returns the queriedData returned from the network request in (1).

This PR tries to fix this by memoize reusableBlocks so that we don't have to create a new settings whenever getEntityRecords returns a new array, breaking the loop in (2).

How has this been tested?

  1. Add an arbitrary console.log in the useMemo of settings
  2. Reload the page
  3. Observe that the log gets called several times before the network response arrived
  4. Apply this PR, and reload the page
  5. Observe that there's only 3 calls now.

See the screenshots below.

Screenshots

(It really shouldn't be rendered in the log but hopefully you get the point 馃槄 )

Before After
Screen Shot 2020-10-26 at 5 18 22 PM Screen Shot 2020-10-26 at 5 17 32 PM

Types of changes

Bug fix

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 added [Type] Bug An existing feature does not function as intended [Type] Performance Related to performance efforts [Feature] Widgets Screen The block-based screen that replaced widgets.php. labels Oct 26, 2020
@kevin940726 kevin940726 added this to PRs in progress in Block-based Widgets Editor via automation Oct 26, 2020
@kevin940726
Copy link
Member Author

Question is: where should we memoize it? Should we memoize it in the selector level, or component level? Or else where? 馃

@noisysocks
Copy link
Member

noisysocks commented Oct 26, 2020

Hmmm, good question! 馃槄

I think it's reasonable to expect that getEntityRecords( ... ) !== getEntityRecords( ...) should only ever be true when there has been some change in state.

I played around in DevTools to check that this is already the case when there is data:

Screen Shot 2020-10-27 at 10 23 52

So, I think the fix should be to change this:

if ( ! queriedState ) {
return [];
}

To use a shared reference to [] defined at the top of the file:

if ( ! queriedState ) {
	return EMPTY_ARRAY;
}

You can see that we've done similar optimisations in packages/editor/src/store/selectors.js.

@kevin940726
Copy link
Member Author

@noisysocks That's actually my first solution!

However, the problem is that I feel like it's pretty normal to return a new instance of the data in the selectors. Take Array.prototype.map and Array.prototype.filter for example, they are commonly used in selectors to transform the state from the store, but they will also return a new instance every time being called. Moreover, I think it's not very clear and intuitive for developers to know when to memoize the value (using EMPTY_ARRAY technique) and when not to. In the end, I came to a conclusion that EMPTY_ARRAY feels more like a hack than an ultimate solution to me.

The other solution would be to memoize the selector using createSelector from rememo. That's normally how it's solved in Redux's world (reselect). However, this solution also has some problems. First of all, as developers, we still don't have any clue when to memoize the selectors. To me, memoizing selectors should be to fixing performance issues, not fixing referential equality issues or preventing bugs like infinite loops.

Secondly, rememo (and @wordpress/data in general) doesn't provide a way to memoize between different instances of the selector. getEntityRecords is used in several places/packages and they all have different arguments. AFAIK rememo only memoize the last call of the function (has cache size of 1). What that means is that repetitive calls of getEntityRecords in the same useSelect function might not be memoized if there's another call of getEntiryRecords in between from a different useSelect function.

  1. getEntityRecords('postType', 'wp_block'): cache not hit, cached (1)
  2. getEntityRecords('postType', 'post'): cache not hit, cleared (1) and cached (2)
  3. getEntityRecords('postType', 'wp_block'): cache not hit, cleared (2) and cached (3)

Note that even though (3) and (1) have the same arguments, (3) still couldn't reuse the cache from (1) due to the cache size.

reselect offers a way to create selector creators so that different component can use different instances of the selector and prevent from sharing the same cache. @wordpress/data doesn't seem to provide an easy way to do this though 馃 .

@youknowriad
Copy link
Contributor

However, the problem is that I feel like it's pretty normal to return a new instance of the data in the selectors.

I don't think that's normal, we explicitly avoid this across our entire code base, in general when we "map", we always memoize the selector itself unless it's a selector that is used rarely. I also lean towards memoizing in the selectors (using the shared array here)

I think it's not very clear and intuitive for developers to know when to memoize the value (using EMPTY_ARRAY technique) and when not to.

I disagree, I feel it's more consistent to say that all selectors returning objects and arrays should be memoized rather than thinking adhoc in components.

Note that even though (3) and (1) have the same arguments, (3) still couldn't reuse the cache from (1) due to the cache size.

I'm not sure that's true, if it is, it's a bug, we explicitly designed our selectors to keep the cache for different arguments and not just limit it to 1. I see that getEntityRecords is memoized using equivalent-key-map which I believe works that way. If what you say is true, it's an important bug that need to be fixed.

@kevin940726
Copy link
Member Author

I don't think that's normal, we explicitly avoid this across our entire code base, in general when we "map", we always memoize the selector itself unless it's a selector that is used rarely. I also lean towards memoizing in the selectors (using the shared array here)

I disagree, I feel it's more consistent to say that all selectors returning objects and arrays should be memoized rather than thinking adhoc in components.

If that's the case then it makes sense to me! I didn't know that there's such practice in our code base, and from a quick search I can still find several places which aren't memoizing selectors. Is this documented somewhere? I'm not sure if it counts as idiomatic code, but would help if it's documented explicitly.

we explicitly designed our selectors to keep the cache for different arguments and not just limit it to 1.
I don't think there's a bug. It turns out we have to explicitly use createSelector and list all the necessary dependencies in the second argument to make it work. Thanks for pointing this out!

I feel like using createSelector is a more general way of solving this, rather than, say, using EMPTY_ARRAY. I'll try to push a change to use createSelector instead, and maybe spend some time studying how createSelector really works 馃槄.

@youknowriad
Copy link
Contributor

I feel like using createSelector is a more general way of solving this, rather than, say, using EMPTY_ARRAY. I'll try to push a change to use createSelector instead, and maybe spend some time studying how createSelector really works 馃槄.

true, but EMPTY_ARRAY is way less costfull.

I can still find several places which aren't memoizing selectors. Is this documented somewhere? I'm not sure if it counts as idiomatic code, but would help if it's documented explicitly.

We do have that document to explain some of the performance decisions but this one is definitely not there, it would be good to add.

https://developer.wordpress.org/block-editor/architecture/performance/

This Post also has some good context about the different performant-related work.

https://riad.blog/2020/02/14/a-journey-towards-a-performant-web-editor/

@noisysocks
Copy link
Member

I wonder if we can automatically catch when selectors are incorrectly returning new instances by doing something like this in our unit tests.

import { getEntityRecords as _getEntityRecords } from '../selectors';

const checkSelectorStability = ( selector ) => ( ...args ) => {
	const firstResult = selector( ...args );
	const secondResult = selector( ...args );
	expect( secondResult ).toBe( firstResult );
	return secondResult;
};

const getEntityRecords = checkSelectorStability( _getEntityRecords );

@kevin940726 kevin940726 force-pushed the update/fix-reusable-blocks-rerenders branch 2 times, most recently from b2d74e9 to be1a741 Compare November 2, 2020 04:23
@github-actions
Copy link

github-actions bot commented Nov 2, 2020

Size Change: +14 B (0%)

Total Size: 1.21 MB

Filename Size Change
build/core-data/index.js 12.3 kB +15 B (0%)
build/edit-widgets/index.js 26.4 kB -1 B
鈩癸笍 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 130 kB 0 B
build/block-editor/style-rtl.css 11.1 kB 0 B
build/block-editor/style.css 11.1 kB 0 B
build/block-library/editor-rtl.css 9.02 kB 0 B
build/block-library/editor.css 9.02 kB 0 B
build/block-library/index.js 146 kB 0 B
build/block-library/style-rtl.css 7.84 kB 0 B
build/block-library/style.css 7.85 kB 0 B
build/block-library/theme-rtl.css 837 B 0 B
build/block-library/theme.css 838 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.2 kB 0 B
build/components/style.css 15.2 kB 0 B
build/compose/index.js 9.81 kB 0 B
build/data-controls/index.js 772 B 0 B
build/data/index.js 8.77 kB 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.1 kB 0 B
build/edit-site/style-rtl.css 3.88 kB 0 B
build/edit-site/style.css 3.88 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 480 B 0 B
build/editor/editor-styles.css 482 B 0 B
build/editor/index.js 43.1 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.7 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.13 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

if ( ! queriedState ) {
return [];
}
return getQueriedItems( queriedState, query );
}
},
( state, kind, name ) => [ getQueriedState( state, kind, name ) ]
Copy link
Contributor

Choose a reason for hiding this comment

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

Will we have the same result if we use a const array? If it's the case, this seems to be way harder to understand and also more costly in terms of performance than just using a const.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point. I don't think that there's a difference in this specific case. However, I'd still prefer to use a more general solution so that future changes won't accidentally break the memoization.

Let's use a const array here, as it's more simpler and performant. I still think it's too complicated to choose the right memoization method with the current approach though 馃 .

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, I agree that memoization is hard in general. Redux forces us into thinking more about these details in a way.

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

@kevin940726 kevin940726 force-pushed the update/fix-reusable-blocks-rerenders branch from 95eda75 to 6b1d661 Compare November 3, 2020 09:25
@kevin940726 kevin940726 changed the title Memoize reusableBlocks to prevent infinite re-renders Memoize getEntityRecords to prevent infinite re-renders Nov 3, 2020
@kevin940726 kevin940726 merged commit 259751d into master Nov 3, 2020
Block-based Widgets Editor automation moved this from PRs in progress to Done Nov 3, 2020
@kevin940726 kevin940726 deleted the update/fix-reusable-blocks-rerenders branch November 3, 2020 10:31
@github-actions github-actions bot added this to the Gutenberg 9.4 milestone Nov 3, 2020
@talldan talldan added the Backport to WP Minor Release Pull request that needs to be backported to a WordPress minor release label Feb 12, 2021
@talldan
Copy link
Contributor

talldan commented Feb 12, 2021

I've added the Backport to Minor Release label so that if there's a 5.6.2 release this will be included.

If 5.6.2 doesn't happen and 5.7 is release the label can be removed, and it'll already be in 5.7.

@tellthemachines tellthemachines mentioned this pull request Feb 17, 2021
7 tasks
tellthemachines added a commit that referenced this pull request Feb 17, 2021
* Backport #26475 for the 5.6 branch.

* Backport #28604.

* Revert "Backport #28604."

This reverts commit 32784b6.

* Backport #26583 to the 5.6 branch.

* Commit lock file updates.

* Committing lock file differences after updating `browserslist`.

* Pin the version of NodeJS in the Compressed Size workflow.

* Memoize getEntityRecords to prevent infinite re-renders (#26447)

* Fix issue where gallery block requests all attachments when empty (#28621)

* Return early from building attachments for galleries

* Improve code clarity

* Fix link control styles to prevent scrollbar (#27777)

* Update package-lock

* Update package-lock again

Co-authored-by: Jonathan Desrosiers <desrosj@users.noreply.github.com>
Co-authored-by: Kai Hao <kevin830726@gmail.com>
Co-authored-by: Daniel Richards <daniel.richards@automattic.com>
Co-authored-by: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com>
@noisysocks noisysocks removed the Backport to WP Minor Release Pull request that needs to be backported to a WordPress minor release label Apr 6, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Feature] Widgets Screen The block-based screen that replaced widgets.php. [Type] Bug An existing feature does not function as intended [Type] Performance Related to performance efforts
Projects
No open projects
Development

Successfully merging this pull request may close these issues.

None yet

4 participants