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

useSelect: incrementally subscribe to stores when first selected from #47243

Merged
merged 5 commits into from
Mar 29, 2023

Conversation

jsnajdr
Copy link
Member

@jsnajdr jsnajdr commented Jan 18, 2023

Fixes #47145. On every call of mapSelect, it looks at the list of stores it selected from and incrementally subscribes to new ones.

A typical lifecycle in a React component looks like this:

  1. React mounts the useSelect hook, and useSyncExternalStore inside it calls the getValue function. The getValue function will call mapSelect, and initialize the activeStores variable with the list of selected stores.
  2. In an effect, useSyncExternalStore calls the subscribe function. It subscribes to the activeStores.
  3. The next call of useSelect calls mapSelect again, and notes which stores were selected from this time. It calls updateStores with this fresh list.
  4. updateStores will add new stores to the activeStores list, so that new subscribe calls subscribe to them, too. And will also go through list of activeSubscriptions and subscribes to the new stores for each of them.
  5. Finally, useSelect hook unmounts and all the subscribed stores are unsubscribed from.

The updateStores call is done during getValue, which is called during render, which is not 100% OK, because it's a side-effect. But we don't have any other opportunity to update the subscriptions.

To make updateStores safe, I'm only adding new subscriptions, never removing old ones. If one call of mapSelect selects from store-1 and a later call selects from store-2, we keep the subscription to store-1, and only add a new subscription to store-2. This is more concurrent-mode safe. It could happen that a useSelect render would unsubscribe from store-1 and then the results of that render wouldn't be committed. Because of a higher-priority update, because of suspension, because of error. The subscription to store-1 would be incorrectly removed.

Cc: @davilera

How to test: Covered by unit tests.

@github-actions
Copy link

github-actions bot commented Jan 18, 2023

Size Change: +61 B (0%)

Total Size: 1.34 MB

Filename Size Change
build/data/index.min.js 8.64 kB +61 B (+1%)
ℹ️ View Unchanged
Filename Size
build/a11y/index.min.js 993 B
build/annotations/index.min.js 2.78 kB
build/api-fetch/index.min.js 2.27 kB
build/autop/index.min.js 2.15 kB
build/blob/index.min.js 483 B
build/block-directory/index.min.js 7.2 kB
build/block-directory/style-rtl.css 1.04 kB
build/block-directory/style.css 1.04 kB
build/block-editor/content-rtl.css 4.11 kB
build/block-editor/content.css 4.1 kB
build/block-editor/default-editor-styles-rtl.css 403 B
build/block-editor/default-editor-styles.css 403 B
build/block-editor/index.min.js 197 kB
build/block-editor/style-rtl.css 14.4 kB
build/block-editor/style.css 14.4 kB
build/block-library/blocks/archives/editor-rtl.css 61 B
build/block-library/blocks/archives/editor.css 60 B
build/block-library/blocks/archives/style-rtl.css 90 B
build/block-library/blocks/archives/style.css 90 B
build/block-library/blocks/audio/editor-rtl.css 150 B
build/block-library/blocks/audio/editor.css 150 B
build/block-library/blocks/audio/style-rtl.css 122 B
build/block-library/blocks/audio/style.css 122 B
build/block-library/blocks/audio/theme-rtl.css 138 B
build/block-library/blocks/audio/theme.css 138 B
build/block-library/blocks/avatar/editor-rtl.css 116 B
build/block-library/blocks/avatar/editor.css 116 B
build/block-library/blocks/avatar/style-rtl.css 91 B
build/block-library/blocks/avatar/style.css 91 B
build/block-library/blocks/block/editor-rtl.css 305 B
build/block-library/blocks/block/editor.css 305 B
build/block-library/blocks/button/editor-rtl.css 587 B
build/block-library/blocks/button/editor.css 587 B
build/block-library/blocks/button/style-rtl.css 628 B
build/block-library/blocks/button/style.css 627 B
build/block-library/blocks/buttons/editor-rtl.css 337 B
build/block-library/blocks/buttons/editor.css 337 B
build/block-library/blocks/buttons/style-rtl.css 332 B
build/block-library/blocks/buttons/style.css 332 B
build/block-library/blocks/calendar/style-rtl.css 239 B
build/block-library/blocks/calendar/style.css 239 B
build/block-library/blocks/categories/editor-rtl.css 84 B
build/block-library/blocks/categories/editor.css 83 B
build/block-library/blocks/categories/style-rtl.css 100 B
build/block-library/blocks/categories/style.css 100 B
build/block-library/blocks/code/editor-rtl.css 53 B
build/block-library/blocks/code/editor.css 53 B
build/block-library/blocks/code/style-rtl.css 121 B
build/block-library/blocks/code/style.css 121 B
build/block-library/blocks/code/theme-rtl.css 124 B
build/block-library/blocks/code/theme.css 124 B
build/block-library/blocks/columns/editor-rtl.css 108 B
build/block-library/blocks/columns/editor.css 108 B
build/block-library/blocks/columns/style-rtl.css 406 B
build/block-library/blocks/columns/style.css 406 B
build/block-library/blocks/comment-author-avatar/editor-rtl.css 125 B
build/block-library/blocks/comment-author-avatar/editor.css 125 B
build/block-library/blocks/comment-content/style-rtl.css 92 B
build/block-library/blocks/comment-content/style.css 92 B
build/block-library/blocks/comment-template/style-rtl.css 199 B
build/block-library/blocks/comment-template/style.css 198 B
build/block-library/blocks/comments-pagination-numbers/editor-rtl.css 123 B
build/block-library/blocks/comments-pagination-numbers/editor.css 121 B
build/block-library/blocks/comments-pagination/editor-rtl.css 222 B
build/block-library/blocks/comments-pagination/editor.css 209 B
build/block-library/blocks/comments-pagination/style-rtl.css 235 B
build/block-library/blocks/comments-pagination/style.css 231 B
build/block-library/blocks/comments-title/editor-rtl.css 75 B
build/block-library/blocks/comments-title/editor.css 75 B
build/block-library/blocks/comments/editor-rtl.css 840 B
build/block-library/blocks/comments/editor.css 839 B
build/block-library/blocks/comments/style-rtl.css 637 B
build/block-library/blocks/comments/style.css 636 B
build/block-library/blocks/cover/editor-rtl.css 612 B
build/block-library/blocks/cover/editor.css 613 B
build/block-library/blocks/cover/style-rtl.css 1.6 kB
build/block-library/blocks/cover/style.css 1.59 kB
build/block-library/blocks/embed/editor-rtl.css 293 B
build/block-library/blocks/embed/editor.css 293 B
build/block-library/blocks/embed/style-rtl.css 410 B
build/block-library/blocks/embed/style.css 410 B
build/block-library/blocks/embed/theme-rtl.css 138 B
build/block-library/blocks/embed/theme.css 138 B
build/block-library/blocks/file/editor-rtl.css 300 B
build/block-library/blocks/file/editor.css 300 B
build/block-library/blocks/file/style-rtl.css 265 B
build/block-library/blocks/file/style.css 265 B
build/block-library/blocks/file/view.min.js 353 B
build/block-library/blocks/freeform/editor-rtl.css 2.44 kB
build/block-library/blocks/freeform/editor.css 2.44 kB
build/block-library/blocks/gallery/editor-rtl.css 984 B
build/block-library/blocks/gallery/editor.css 988 B
build/block-library/blocks/gallery/style-rtl.css 1.55 kB
build/block-library/blocks/gallery/style.css 1.55 kB
build/block-library/blocks/gallery/theme-rtl.css 122 B
build/block-library/blocks/gallery/theme.css 122 B
build/block-library/blocks/group/editor-rtl.css 654 B
build/block-library/blocks/group/editor.css 654 B
build/block-library/blocks/group/style-rtl.css 57 B
build/block-library/blocks/group/style.css 57 B
build/block-library/blocks/group/theme-rtl.css 78 B
build/block-library/blocks/group/theme.css 78 B
build/block-library/blocks/heading/style-rtl.css 76 B
build/block-library/blocks/heading/style.css 76 B
build/block-library/blocks/html/editor-rtl.css 332 B
build/block-library/blocks/html/editor.css 333 B
build/block-library/blocks/image/editor-rtl.css 830 B
build/block-library/blocks/image/editor.css 829 B
build/block-library/blocks/image/style-rtl.css 652 B
build/block-library/blocks/image/style.css 652 B
build/block-library/blocks/image/theme-rtl.css 137 B
build/block-library/blocks/image/theme.css 137 B
build/block-library/blocks/latest-comments/style-rtl.css 357 B
build/block-library/blocks/latest-comments/style.css 357 B
build/block-library/blocks/latest-posts/editor-rtl.css 213 B
build/block-library/blocks/latest-posts/editor.css 212 B
build/block-library/blocks/latest-posts/style-rtl.css 478 B
build/block-library/blocks/latest-posts/style.css 478 B
build/block-library/blocks/list/style-rtl.css 88 B
build/block-library/blocks/list/style.css 88 B
build/block-library/blocks/media-text/editor-rtl.css 266 B
build/block-library/blocks/media-text/editor.css 263 B
build/block-library/blocks/media-text/style-rtl.css 507 B
build/block-library/blocks/media-text/style.css 505 B
build/block-library/blocks/more/editor-rtl.css 431 B
build/block-library/blocks/more/editor.css 431 B
build/block-library/blocks/navigation-link/editor-rtl.css 716 B
build/block-library/blocks/navigation-link/editor.css 715 B
build/block-library/blocks/navigation-link/style-rtl.css 115 B
build/block-library/blocks/navigation-link/style.css 115 B
build/block-library/blocks/navigation-submenu/editor-rtl.css 299 B
build/block-library/blocks/navigation-submenu/editor.css 299 B
build/block-library/blocks/navigation/editor-rtl.css 2.13 kB
build/block-library/blocks/navigation/editor.css 2.14 kB
build/block-library/blocks/navigation/style-rtl.css 2.22 kB
build/block-library/blocks/navigation/style.css 2.2 kB
build/block-library/blocks/navigation/view-modal.min.js 2.81 kB
build/block-library/blocks/navigation/view.min.js 447 B
build/block-library/blocks/nextpage/editor-rtl.css 395 B
build/block-library/blocks/nextpage/editor.css 395 B
build/block-library/blocks/page-list/editor-rtl.css 401 B
build/block-library/blocks/page-list/editor.css 401 B
build/block-library/blocks/page-list/style-rtl.css 175 B
build/block-library/blocks/page-list/style.css 175 B
build/block-library/blocks/paragraph/editor-rtl.css 174 B
build/block-library/blocks/paragraph/editor.css 174 B
build/block-library/blocks/paragraph/style-rtl.css 279 B
build/block-library/blocks/paragraph/style.css 281 B
build/block-library/blocks/post-author/style-rtl.css 175 B
build/block-library/blocks/post-author/style.css 176 B
build/block-library/blocks/post-comments-form/editor-rtl.css 96 B
build/block-library/blocks/post-comments-form/editor.css 96 B
build/block-library/blocks/post-comments-form/style-rtl.css 501 B
build/block-library/blocks/post-comments-form/style.css 501 B
build/block-library/blocks/post-date/style-rtl.css 61 B
build/block-library/blocks/post-date/style.css 61 B
build/block-library/blocks/post-excerpt/editor-rtl.css 71 B
build/block-library/blocks/post-excerpt/editor.css 71 B
build/block-library/blocks/post-excerpt/style-rtl.css 134 B
build/block-library/blocks/post-excerpt/style.css 134 B
build/block-library/blocks/post-featured-image/editor-rtl.css 586 B
build/block-library/blocks/post-featured-image/editor.css 584 B
build/block-library/blocks/post-featured-image/style-rtl.css 322 B
build/block-library/blocks/post-featured-image/style.css 322 B
build/block-library/blocks/post-navigation-link/style-rtl.css 153 B
build/block-library/blocks/post-navigation-link/style.css 153 B
build/block-library/blocks/post-template/editor-rtl.css 99 B
build/block-library/blocks/post-template/editor.css 98 B
build/block-library/blocks/post-template/style-rtl.css 282 B
build/block-library/blocks/post-template/style.css 282 B
build/block-library/blocks/post-terms/style-rtl.css 96 B
build/block-library/blocks/post-terms/style.css 96 B
build/block-library/blocks/post-title/style-rtl.css 100 B
build/block-library/blocks/post-title/style.css 100 B
build/block-library/blocks/preformatted/style-rtl.css 103 B
build/block-library/blocks/preformatted/style.css 103 B
build/block-library/blocks/pullquote/editor-rtl.css 135 B
build/block-library/blocks/pullquote/editor.css 135 B
build/block-library/blocks/pullquote/style-rtl.css 326 B
build/block-library/blocks/pullquote/style.css 325 B
build/block-library/blocks/pullquote/theme-rtl.css 167 B
build/block-library/blocks/pullquote/theme.css 167 B
build/block-library/blocks/query-pagination-numbers/editor-rtl.css 122 B
build/block-library/blocks/query-pagination-numbers/editor.css 121 B
build/block-library/blocks/query-pagination/editor-rtl.css 221 B
build/block-library/blocks/query-pagination/editor.css 211 B
build/block-library/blocks/query-pagination/style-rtl.css 288 B
build/block-library/blocks/query-pagination/style.css 284 B
build/block-library/blocks/query-title/style-rtl.css 63 B
build/block-library/blocks/query-title/style.css 63 B
build/block-library/blocks/query/editor-rtl.css 463 B
build/block-library/blocks/query/editor.css 463 B
build/block-library/blocks/quote/style-rtl.css 222 B
build/block-library/blocks/quote/style.css 222 B
build/block-library/blocks/quote/theme-rtl.css 223 B
build/block-library/blocks/quote/theme.css 226 B
build/block-library/blocks/read-more/style-rtl.css 132 B
build/block-library/blocks/read-more/style.css 132 B
build/block-library/blocks/rss/editor-rtl.css 149 B
build/block-library/blocks/rss/editor.css 149 B
build/block-library/blocks/rss/style-rtl.css 289 B
build/block-library/blocks/rss/style.css 288 B
build/block-library/blocks/search/editor-rtl.css 165 B
build/block-library/blocks/search/editor.css 165 B
build/block-library/blocks/search/style-rtl.css 409 B
build/block-library/blocks/search/style.css 406 B
build/block-library/blocks/search/theme-rtl.css 114 B
build/block-library/blocks/search/theme.css 114 B
build/block-library/blocks/separator/editor-rtl.css 146 B
build/block-library/blocks/separator/editor.css 146 B
build/block-library/blocks/separator/style-rtl.css 234 B
build/block-library/blocks/separator/style.css 234 B
build/block-library/blocks/separator/theme-rtl.css 194 B
build/block-library/blocks/separator/theme.css 194 B
build/block-library/blocks/shortcode/editor-rtl.css 474 B
build/block-library/blocks/shortcode/editor.css 474 B
build/block-library/blocks/site-logo/editor-rtl.css 489 B
build/block-library/blocks/site-logo/editor.css 489 B
build/block-library/blocks/site-logo/style-rtl.css 203 B
build/block-library/blocks/site-logo/style.css 203 B
build/block-library/blocks/site-tagline/editor-rtl.css 86 B
build/block-library/blocks/site-tagline/editor.css 86 B
build/block-library/blocks/site-title/editor-rtl.css 116 B
build/block-library/blocks/site-title/editor.css 116 B
build/block-library/blocks/site-title/style-rtl.css 57 B
build/block-library/blocks/site-title/style.css 57 B
build/block-library/blocks/social-link/editor-rtl.css 184 B
build/block-library/blocks/social-link/editor.css 184 B
build/block-library/blocks/social-links/editor-rtl.css 674 B
build/block-library/blocks/social-links/editor.css 673 B
build/block-library/blocks/social-links/style-rtl.css 1.4 kB
build/block-library/blocks/social-links/style.css 1.39 kB
build/block-library/blocks/spacer/editor-rtl.css 332 B
build/block-library/blocks/spacer/editor.css 332 B
build/block-library/blocks/spacer/style-rtl.css 48 B
build/block-library/blocks/spacer/style.css 48 B
build/block-library/blocks/table/editor-rtl.css 433 B
build/block-library/blocks/table/editor.css 433 B
build/block-library/blocks/table/style-rtl.css 651 B
build/block-library/blocks/table/style.css 650 B
build/block-library/blocks/table/theme-rtl.css 157 B
build/block-library/blocks/table/theme.css 157 B
build/block-library/blocks/tag-cloud/style-rtl.css 251 B
build/block-library/blocks/tag-cloud/style.css 253 B
build/block-library/blocks/template-part/editor-rtl.css 404 B
build/block-library/blocks/template-part/editor.css 404 B
build/block-library/blocks/template-part/theme-rtl.css 101 B
build/block-library/blocks/template-part/theme.css 101 B
build/block-library/blocks/text-columns/editor-rtl.css 95 B
build/block-library/blocks/text-columns/editor.css 95 B
build/block-library/blocks/text-columns/style-rtl.css 166 B
build/block-library/blocks/text-columns/style.css 166 B
build/block-library/blocks/verse/style-rtl.css 99 B
build/block-library/blocks/verse/style.css 99 B
build/block-library/blocks/video/editor-rtl.css 552 B
build/block-library/blocks/video/editor.css 555 B
build/block-library/blocks/video/style-rtl.css 179 B
build/block-library/blocks/video/style.css 179 B
build/block-library/blocks/video/theme-rtl.css 139 B
build/block-library/blocks/video/theme.css 139 B
build/block-library/classic-rtl.css 179 B
build/block-library/classic.css 179 B
build/block-library/common-rtl.css 1.11 kB
build/block-library/common.css 1.11 kB
build/block-library/editor-elements-rtl.css 75 B
build/block-library/editor-elements.css 75 B
build/block-library/editor-rtl.css 11.6 kB
build/block-library/editor.css 11.6 kB
build/block-library/elements-rtl.css 54 B
build/block-library/elements.css 54 B
build/block-library/index.min.js 201 kB
build/block-library/reset-rtl.css 478 B
build/block-library/reset.css 478 B
build/block-library/style-rtl.css 12.7 kB
build/block-library/style.css 12.7 kB
build/block-library/theme-rtl.css 698 B
build/block-library/theme.css 703 B
build/block-serialization-default-parser/index.min.js 1.13 kB
build/block-serialization-spec-parser/index.min.js 2.83 kB
build/blocks/index.min.js 51 kB
build/components/index.min.js 208 kB
build/components/style-rtl.css 11.7 kB
build/components/style.css 11.7 kB
build/compose/index.min.js 12.4 kB
build/core-data/index.min.js 16.3 kB
build/customize-widgets/index.min.js 12.2 kB
build/customize-widgets/style-rtl.css 1.41 kB
build/customize-widgets/style.css 1.41 kB
build/data-controls/index.min.js 663 B
build/date/index.min.js 40.4 kB
build/deprecated/index.min.js 518 B
build/dom-ready/index.min.js 336 B
build/dom/index.min.js 4.72 kB
build/edit-post/classic-rtl.css 571 B
build/edit-post/classic.css 571 B
build/edit-post/index.min.js 34.8 kB
build/edit-post/style-rtl.css 7.55 kB
build/edit-post/style.css 7.54 kB
build/edit-site/index.min.js 65 kB
build/edit-site/style-rtl.css 10.2 kB
build/edit-site/style.css 10.2 kB
build/edit-widgets/index.min.js 17.3 kB
build/edit-widgets/style-rtl.css 4.56 kB
build/edit-widgets/style.css 4.56 kB
build/editor/index.min.js 45.8 kB
build/editor/style-rtl.css 3.54 kB
build/editor/style.css 3.53 kB
build/element/index.min.js 4.95 kB
build/escape-html/index.min.js 548 B
build/format-library/index.min.js 7.26 kB
build/format-library/style-rtl.css 557 B
build/format-library/style.css 556 B
build/hooks/index.min.js 1.66 kB
build/html-entities/index.min.js 454 B
build/i18n/index.min.js 3.79 kB
build/is-shallow-equal/index.min.js 535 B
build/keyboard-shortcuts/index.min.js 1.79 kB
build/keycodes/index.min.js 1.94 kB
build/list-reusable-blocks/index.min.js 2.14 kB
build/list-reusable-blocks/style-rtl.css 865 B
build/list-reusable-blocks/style.css 865 B
build/media-utils/index.min.js 2.99 kB
build/notices/index.min.js 977 B
build/plugins/index.min.js 1.95 kB
build/preferences-persistence/index.min.js 2.23 kB
build/preferences/index.min.js 1.35 kB
build/primitives/index.min.js 960 B
build/priority-queue/index.min.js 1.52 kB
build/private-apis/index.min.js 937 B
build/react-i18n/index.min.js 702 B
build/react-refresh-entry/index.min.js 8.44 kB
build/react-refresh-runtime/index.min.js 7.31 kB
build/redux-routine/index.min.js 2.75 kB
build/reusable-blocks/index.min.js 2.26 kB
build/reusable-blocks/style-rtl.css 265 B
build/reusable-blocks/style.css 265 B
build/rich-text/index.min.js 11 kB
build/server-side-render/index.min.js 2.09 kB
build/shortcode/index.min.js 1.52 kB
build/style-engine/index.min.js 1.53 kB
build/token-list/index.min.js 650 B
build/url/index.min.js 3.74 kB
build/vendors/inert-polyfill.min.js 2.48 kB
build/vendors/react-dom.min.js 41.8 kB
build/vendors/react.min.js 4.02 kB
build/viewport/index.min.js 1.09 kB
build/warning/index.min.js 280 B
build/widgets/index.min.js 7.3 kB
build/widgets/style-rtl.css 1.18 kB
build/widgets/style.css 1.18 kB
build/wordcount/index.min.js 1.06 kB

compressed-size-action

@jsnajdr jsnajdr force-pushed the update/use-select-resubscribe branch from 7b1e9e5 to e230e05 Compare January 18, 2023 13:48
@davilera
Copy link
Contributor

This goes beyond my understanding of the internals of useSelect, but your description looks pretty solid. Thanks for working on this PR, @jsnajdr! Let's see if someone can review and merge it.

@jsnajdr jsnajdr force-pushed the update/use-select-resubscribe branch from e230e05 to 3455787 Compare January 19, 2023 09:39
@github-actions
Copy link

github-actions bot commented Jan 19, 2023

Flaky tests detected in 490e315.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/4403530658
📝 Reported issues:

@tyxla
Copy link
Member

tyxla commented Jan 20, 2023

Thanks for the thorough explanation in the PR, @jsnajdr 👍

There's a bunch of test failures - any chance they could be related?

@jsnajdr
Copy link
Member Author

jsnajdr commented Jan 20, 2023

There's a bunch of test failures - any chance they could be related?

Yes, it's very likely that there's something wrong with the new useSelect. I'll need to investigate that before we can think of merging this.

@jsnajdr
Copy link
Member Author

jsnajdr commented Mar 9, 2023

I'm returning back to this after some time. A mobile unit test is failing where a Button block is created and saved. The url attribute is not undefined, but an empty string, "". There is some subtle bug either in the new useSelect implementation, or in the React Native code that works with the blocks store.

@jsnajdr
Copy link
Member Author

jsnajdr commented Mar 10, 2023

Something goes wrong when there are two selects where the second depends on the result of the first one:

const first = useSelect( select => select( store ).getFirst() );
const second = useSelect( select => first + select( store ).getSecond(), [ first ] );

Then, after the getFirst() store value changes, it's like the second select callback still uses the old first value.

Before this PR, the second select would re-subscribe to the store, i.e., unsubscribe and subscribe again, when the dependencies change. That leads to extra calculation of the latest value. But with this PR, useSelect detects that the selected stores haven't really changed and doesn't resubscribe. That's smart, but apparently there is some little bug hidden there.

@jsnajdr jsnajdr force-pushed the update/use-select-resubscribe branch 2 times, most recently from cf782c4 to 490e315 Compare March 13, 2023 09:51
@jsnajdr
Copy link
Member Author

jsnajdr commented Mar 13, 2023

Well this was quite hard to debug but I think I found the culprit. When there is a useSelect with dependencies:

useSelect( ( select ) => {
  return select( blockEditorStore ).getBlockName( clientId );
}, [ clientId ] );

we need to correctly handle the transition when the clientId changes. There is a new mapSelect callback being passed in, with a completely different return value and meaning.

The following was happening here, leading to bad behavior of the editor (crashes, failed tests):

  1. There is an internal useSyncExternalStore( subscribe, getValue ) call, and the getValue function is bound to a certain version of the mapSelect callback.
  2. When there is a store update, React will call this getValue callback to get the latest value. The getValue callback is saved in the hook's internal state.
  3. When getValue changes (after a mapSelect update, after a dependency update), the new version is saved to internal state in an effect. That means there is a little delay between the render that produced the new getValue and the effect that saves it.
  4. getValue caches the result value, and returns the cached value if current mapSelect is same as lastMapSelect.
  5. lastMapSelect is set during render, before the new getValue callback is stored into internal state.
  6. When a store update comes between render and effect, it still uses the old getValue, and will compute and cache a value computed by the old mapSelect.
  7. But then then following render will use that incorrect cached value because it thinks it was calculated with the new mapSelect.

So, it can happen that a render with a new clientId will get a value computed using the old clientId. That's a complete disaster for the entire editor.

My solution is to set the lastMapSelect always in sync with the lastMapResult. Then we won't return incorrect cached values. At all times, we know that lastMapResult was computed by lastMapSelect, they're never out of sync, and we can decide whether we like that value or not.

Before this PR, we were always re-subscribing to all stores on a dependency update. New subscription invalidates the cached value, so the bug was never triggered. Now, after this PR, the code is smarter, it knows that we still read only from blockEditorStore, no matter what the clientId is, so we don't need to re-subscribe. But it triggered the bug.

At this moment I don't know how to write a test to catch the bug. I suspect that in act() testing environment, the right timing might be impossible to reproduce.

@jsnajdr
Copy link
Member Author

jsnajdr commented Mar 13, 2023

This PR is now green and can be reviewed and merged. FYI @youknowriad @kevin940726

@jsnajdr
Copy link
Member Author

jsnajdr commented Mar 14, 2023

I have some concerns whether this useSelect implementation is really correct. Consider this usage:

const name = useSelect( ( select ) => {
  return select( blockEditorStore ).getBlockName( clientId );
}, [ clientId ] );

The component with the hook is mounted, with clientId="A", and is rerendered whenever the A block's name changes. Now let's rerender it with clientId="B". There's this sequence of events:

  1. The component is rendered, useSelect is called with [ 'B' ] dependencies, and with a callback whose clientId refers to 'B' in the closure.
  2. The useSelect's internal useSyncExternalStore returns a new getValue callback, bound to the new mapSelect, and it will be returning name of block B.
  3. useSyncExternalStore will use this new getValue to calculate B's name and return it.
  4. The component is rendered with variable name having the value of B block's name, in sync with the clientId prop.
  5. An effect is scheduled to store the new getValue into the hook's internal state.
  6. Now, after render and before the effect, a new store update comes. useSyncExternalStore's listener will be called. The listener will compute the latest value, but it will use the old version of getValue, stored in internal state, and bound to clientId="A"! If, in this update, B's name has changed and A's name didn't, no component rerender will be triggered. The component will remain rendered with old B's name.

Before this PR, useSelect would unsubscribe from the stores and subscribe again when the clientId dependency changed. And useSyncExternalStore has methods to catch missed updates between the render and the effect that actually establishes the subscription. It will call getValue again right after subscription to check if the value has changed. That part is now missing.

I don't have proof or test that this really happens, and I didn't see it happen in production. But the above reasoning tells me that it can happen.

@jsnajdr
Copy link
Member Author

jsnajdr commented Mar 15, 2023

There is a useSyncExternalStoreWithSelector hook, direcly in the React repo, used by react-redux. I'll have to study if and how it avoids this "store listener using a stale selector" problem.

@jsnajdr jsnajdr force-pushed the update/use-select-resubscribe branch from 490e315 to 1bee787 Compare March 16, 2023 11:19
@jsnajdr
Copy link
Member Author

jsnajdr commented Mar 16, 2023

I have some concerns whether this useSelect implementation is really correct.

I looked at this more closely today, and tested both our and react-redux implementation, and my concerns turn out to be unfounded. If a store update comes in between selector change (render) and effect, then yes, it will use the stale selector, but then in the effect it will always do another checkIfSnapshotChanged, this time with the new selector, and the missed update will be processed.

I improved the test cases for this. There was an old test for updates between render and subscribe, and I simplified it, removing class component in favor of a functional one. And added a new test case for the store update between selector change and effect.

Both test cases schedule the insidious store update using useLayoutEffect. That ensures it will happen after render and before the normal effect that useSyncExternalStore schedules. I learned about this technique in react-redux test.

This PR is ready for review and merging once again.

@jsnajdr
Copy link
Member Author

jsnajdr commented Mar 29, 2023

@youknowriad @kevin940726 Could you please have a look at this one? Seems like the pings went through the cracks.

@@ -109,7 +122,7 @@ describe( 'useSelect', () => {
);

expect( selectSpyFoo ).toHaveBeenCalledTimes( 2 );
expect( selectSpyBar ).toHaveBeenCalledTimes( 2 );
expect( selectSpyBar ).toHaveBeenCalledTimes( 1 );
Copy link
Contributor

Choose a reason for hiding this comment

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

So there's a perf improvement here or something?

Copy link
Member Author

Choose a reason for hiding this comment

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

Kind of, because when you do a select like this:

const name = useSelect( ( select ) => {
  return select( blockEditorStore ).getBlockName( clientId );
}, [ clientId ] );

then before this PR, when the clientId dependency changed, useSelect unsubscribed from the blockEditorStore and then immediately subscribed again. After this PR, this is no longer done, because useSelect knows that we're still selecting from this one store, that the set of observed stores didn't change. Only the clientId param has changed.

This kind of select is very common in block sidebar and toolbar, as they show info for the currently selected block.

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.

To be honest, this is a little bit hard to review but the test changes make sense and are a good indication that this works as intended.

@jsnajdr jsnajdr merged commit 51d6144 into trunk Mar 29, 2023
@jsnajdr jsnajdr deleted the update/use-select-resubscribe branch March 29, 2023 10:00
@github-actions github-actions bot added this to the Gutenberg 15.5 milestone Mar 29, 2023
@ryanwelcher ryanwelcher added the [Type] Bug An existing feature does not function as intended label Mar 29, 2023
@youknowriad
Copy link
Contributor

I actually suspect that this PR increased typing performance by approximatively 10%. Is there anything we can do about it.

If we can't do anything about it, we should ask ourselves what's better:

  • conditional useSelects support
  • 10% perf in typing metric

@youknowriad
Copy link
Contributor

It actually impacted other metrics similarly as well (block select, inserter open, inserter hover)

@jsnajdr
Copy link
Member Author

jsnajdr commented Apr 3, 2023

I actually suspect that this PR increased typing performance by approximatively 10%.

The extra work the new version does is tracking the listeningStores set on each mapSelect call and comparing it to the previous, stored one. Previously these checks were done only when:

  • for useSelect without dependencies (done mainly by withSelect), the checks were never done, the hook never resubscribed.
  • for useSelect with dependencies, the checks were done only when dependencies changes.

Now, to support conditional selects, we need to check on each store update.

Is there anything we can do about it.

One thing that could improve the perf again is getting rid of withSelect and other useSelect calls without specified dependencies. Selecting without dependencies means that the hook can never use a cached return value.

If we can't do anything about it, we should ask ourselves what's better:

That's not an easy tradeoff because we'd be trading performance for correctness. Like the Pentium fdiv bug from 1994 🙂

@youknowriad
Copy link
Contributor

That's not an easy tradeoff because we'd be trading performance for correctness. Like the Pentium fdiv bug from 1994

Indeed, because we never supported these conditional selects, personally I'm willing to just document that we don't support them. I don't think it's incorrect, it's just different.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Package] Data /packages/data [Type] Bug An existing feature does not function as intended
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Conditional useSelect not working as expected
5 participants