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

Components: add useUpdateEffect to exhaustive-deps lint check #45771

Open
wants to merge 8 commits into
base: trunk
Choose a base branch
from

Conversation

chad1008
Copy link
Contributor

@chad1008 chad1008 commented Nov 15, 2022

What?

As discussed in #45044, now that exhaustive-deps is active for the Components package, it makes sense to include useUpdateEffect in the check. There may be other custom hooks we want to include here, but they can be addressed in separate PRs as needed.

Why?

For the same reason as the overall exhaustive-deps migration, keeping hook dependencies clean can help avoid unexpected behavior creeping into components.

How?

Fortunately, there only appears to be one component triggering warnings related to useUpdateEffect, and it's ToggleGroupControl. There are several warnings in a couple of different spots, more details to follow as this PR develops.

With just one component in play, I don't think we need a separate followup PR, so I'll manage all of the work here.

Mostly straightforward changes here. Most notable:

  • previousValue is actually a ref value provided by the usePrevious hook - so changes won't cause re-renders. Normally it wouldn't be a dependency, but eslint can't tell that it's actually a ref.
  • adding onChange to the dep array does cause the effect to fire more often when the function is defined/destructured, but it is not triggering any additional re-renders.
  • groupContext is defined on each render and therefor wouldn't be referentially stable, but wrapping in useMemo should resolve that.
  • adding radio to the as-radio-group dep array triggers an additional render as the object gets re-declared each time. I didn't see an easily viable solution, so I've ignored this error for now. It's possible there is an easy fix that I've just overlooked due to the TypeScript of it all.

Testing Instructions

  1. Apply this PR
  2. From your local gutenberg directory, run npx eslint packages/components/src/
  3. Confirm none of the errors displayed are caused by the exhaustive-deps rule, particularly related to useUpdateEffect

@codesandbox
Copy link

codesandbox bot commented Nov 15, 2022

CodeSandbox logoCodeSandbox logo  Open in CodeSandbox Web Editor | VS Code | VS Code Insiders

@github-actions
Copy link

github-actions bot commented Nov 15, 2022

Size Change: +20 B (0%)

Total Size: 1.32 MB

Filename Size Change
build/components/index.min.js 203 kB +20 B (0%)
ℹ️ 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 487 B
build/block-directory/index.min.js 7.16 kB
build/block-directory/style-rtl.css 1.03 kB
build/block-directory/style.css 1.04 kB
build/block-editor/content-rtl.css 2.71 kB
build/block-editor/content.css 2.71 kB
build/block-editor/default-editor-styles-rtl.css 401 B
build/block-editor/default-editor-styles.css 401 B
build/block-editor/index.min.js 181 kB
build/block-editor/style-rtl.css 14.5 kB
build/block-editor/style.css 14.5 kB
build/block-library/blocks/archives/editor-rtl.css 107 B
build/block-library/blocks/archives/editor.css 106 B
build/block-library/blocks/archives/style-rtl.css 129 B
build/block-library/blocks/archives/style.css 129 B
build/block-library/blocks/audio/editor-rtl.css 185 B
build/block-library/blocks/audio/editor.css 185 B
build/block-library/blocks/audio/style-rtl.css 158 B
build/block-library/blocks/audio/style.css 158 B
build/block-library/blocks/audio/theme-rtl.css 172 B
build/block-library/blocks/audio/theme.css 172 B
build/block-library/blocks/avatar/editor-rtl.css 154 B
build/block-library/blocks/avatar/editor.css 154 B
build/block-library/blocks/avatar/style-rtl.css 126 B
build/block-library/blocks/avatar/style.css 126 B
build/block-library/blocks/block/editor-rtl.css 338 B
build/block-library/blocks/block/editor.css 338 B
build/block-library/blocks/button/editor-rtl.css 517 B
build/block-library/blocks/button/editor.css 517 B
build/block-library/blocks/button/style-rtl.css 566 B
build/block-library/blocks/button/style.css 566 B
build/block-library/blocks/buttons/editor-rtl.css 373 B
build/block-library/blocks/buttons/editor.css 373 B
build/block-library/blocks/buttons/style-rtl.css 368 B
build/block-library/blocks/buttons/style.css 368 B
build/block-library/blocks/calendar/style-rtl.css 270 B
build/block-library/blocks/calendar/style.css 270 B
build/block-library/blocks/categories/editor-rtl.css 125 B
build/block-library/blocks/categories/editor.css 124 B
build/block-library/blocks/categories/style-rtl.css 138 B
build/block-library/blocks/categories/style.css 138 B
build/block-library/blocks/code/editor-rtl.css 102 B
build/block-library/blocks/code/editor.css 102 B
build/block-library/blocks/code/style-rtl.css 159 B
build/block-library/blocks/code/style.css 159 B
build/block-library/blocks/code/theme-rtl.css 160 B
build/block-library/blocks/code/theme.css 160 B
build/block-library/blocks/columns/editor-rtl.css 147 B
build/block-library/blocks/columns/editor.css 147 B
build/block-library/blocks/columns/style-rtl.css 442 B
build/block-library/blocks/columns/style.css 442 B
build/block-library/blocks/comment-author-avatar/editor-rtl.css 163 B
build/block-library/blocks/comment-author-avatar/editor.css 163 B
build/block-library/blocks/comment-content/style-rtl.css 134 B
build/block-library/blocks/comment-content/style.css 134 B
build/block-library/blocks/comment-template/style-rtl.css 237 B
build/block-library/blocks/comment-template/style.css 236 B
build/block-library/blocks/comments-pagination-numbers/editor-rtl.css 159 B
build/block-library/blocks/comments-pagination-numbers/editor.css 157 B
build/block-library/blocks/comments-pagination/editor-rtl.css 258 B
build/block-library/blocks/comments-pagination/editor.css 249 B
build/block-library/blocks/comments-pagination/style-rtl.css 272 B
build/block-library/blocks/comments-pagination/style.css 268 B
build/block-library/blocks/comments-title/editor-rtl.css 118 B
build/block-library/blocks/comments-title/editor.css 118 B
build/block-library/blocks/comments/editor-rtl.css 875 B
build/block-library/blocks/comments/editor.css 874 B
build/block-library/blocks/comments/style-rtl.css 672 B
build/block-library/blocks/comments/style.css 671 B
build/block-library/blocks/cover/editor-rtl.css 646 B
build/block-library/blocks/cover/editor.css 647 B
build/block-library/blocks/cover/style-rtl.css 1.61 kB
build/block-library/blocks/cover/style.css 1.6 kB
build/block-library/blocks/embed/editor-rtl.css 327 B
build/block-library/blocks/embed/editor.css 327 B
build/block-library/blocks/embed/style-rtl.css 446 B
build/block-library/blocks/embed/style.css 446 B
build/block-library/blocks/embed/theme-rtl.css 172 B
build/block-library/blocks/embed/theme.css 172 B
build/block-library/blocks/file/editor-rtl.css 335 B
build/block-library/blocks/file/editor.css 335 B
build/block-library/blocks/file/style-rtl.css 288 B
build/block-library/blocks/file/style.css 288 B
build/block-library/blocks/file/view.min.js 353 B
build/block-library/blocks/freeform/editor-rtl.css 2.47 kB
build/block-library/blocks/freeform/editor.css 2.47 kB
build/block-library/blocks/gallery/editor-rtl.css 987 B
build/block-library/blocks/gallery/editor.css 993 B
build/block-library/blocks/gallery/style-rtl.css 1.58 kB
build/block-library/blocks/gallery/style.css 1.58 kB
build/block-library/blocks/gallery/theme-rtl.css 157 B
build/block-library/blocks/gallery/theme.css 157 B
build/block-library/blocks/group/editor-rtl.css 687 B
build/block-library/blocks/group/editor.css 687 B
build/block-library/blocks/group/style-rtl.css 105 B
build/block-library/blocks/group/style.css 105 B
build/block-library/blocks/group/theme-rtl.css 125 B
build/block-library/blocks/group/theme.css 125 B
build/block-library/blocks/heading/style-rtl.css 128 B
build/block-library/blocks/heading/style.css 128 B
build/block-library/blocks/html/editor-rtl.css 365 B
build/block-library/blocks/html/editor.css 366 B
build/block-library/blocks/image/editor-rtl.css 861 B
build/block-library/blocks/image/editor.css 859 B
build/block-library/blocks/image/style-rtl.css 662 B
build/block-library/blocks/image/style.css 666 B
build/block-library/blocks/image/theme-rtl.css 172 B
build/block-library/blocks/image/theme.css 172 B
build/block-library/blocks/latest-comments/style-rtl.css 333 B
build/block-library/blocks/latest-comments/style.css 333 B
build/block-library/blocks/latest-posts/editor-rtl.css 250 B
build/block-library/blocks/latest-posts/editor.css 249 B
build/block-library/blocks/latest-posts/style-rtl.css 514 B
build/block-library/blocks/latest-posts/style.css 514 B
build/block-library/blocks/list/style-rtl.css 135 B
build/block-library/blocks/list/style.css 135 B
build/block-library/blocks/media-text/editor-rtl.css 300 B
build/block-library/blocks/media-text/editor.css 298 B
build/block-library/blocks/media-text/style-rtl.css 540 B
build/block-library/blocks/media-text/style.css 539 B
build/block-library/blocks/more/editor-rtl.css 465 B
build/block-library/blocks/more/editor.css 465 B
build/block-library/blocks/navigation-link/editor-rtl.css 746 B
build/block-library/blocks/navigation-link/editor.css 744 B
build/block-library/blocks/navigation-link/style-rtl.css 153 B
build/block-library/blocks/navigation-link/style.css 153 B
build/block-library/blocks/navigation-submenu/editor-rtl.css 333 B
build/block-library/blocks/navigation-submenu/editor.css 333 B
build/block-library/blocks/navigation/editor-rtl.css 2.19 kB
build/block-library/blocks/navigation/editor.css 2.19 kB
build/block-library/blocks/navigation/style-rtl.css 2.26 kB
build/block-library/blocks/navigation/style.css 2.25 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 428 B
build/block-library/blocks/nextpage/editor.css 428 B
build/block-library/blocks/page-list/editor-rtl.css 397 B
build/block-library/blocks/page-list/editor.css 398 B
build/block-library/blocks/page-list/style-rtl.css 212 B
build/block-library/blocks/page-list/style.css 212 B
build/block-library/blocks/paragraph/editor-rtl.css 214 B
build/block-library/blocks/paragraph/editor.css 214 B
build/block-library/blocks/paragraph/style-rtl.css 321 B
build/block-library/blocks/paragraph/style.css 321 B
build/block-library/blocks/post-author/style-rtl.css 212 B
build/block-library/blocks/post-author/style.css 212 B
build/block-library/blocks/post-comments-form/editor-rtl.css 137 B
build/block-library/blocks/post-comments-form/editor.css 137 B
build/block-library/blocks/post-comments-form/style-rtl.css 536 B
build/block-library/blocks/post-comments-form/style.css 537 B
build/block-library/blocks/post-date/style-rtl.css 107 B
build/block-library/blocks/post-date/style.css 107 B
build/block-library/blocks/post-excerpt/editor-rtl.css 119 B
build/block-library/blocks/post-excerpt/editor.css 119 B
build/block-library/blocks/post-excerpt/style-rtl.css 116 B
build/block-library/blocks/post-excerpt/style.css 116 B
build/block-library/blocks/post-featured-image/editor-rtl.css 620 B
build/block-library/blocks/post-featured-image/editor.css 618 B
build/block-library/blocks/post-featured-image/style-rtl.css 349 B
build/block-library/blocks/post-featured-image/style.css 349 B
build/block-library/blocks/post-navigation-link/style-rtl.css 190 B
build/block-library/blocks/post-navigation-link/style.css 189 B
build/block-library/blocks/post-template/editor-rtl.css 140 B
build/block-library/blocks/post-template/editor.css 139 B
build/block-library/blocks/post-template/style-rtl.css 317 B
build/block-library/blocks/post-template/style.css 317 B
build/block-library/blocks/post-terms/style-rtl.css 136 B
build/block-library/blocks/post-terms/style.css 136 B
build/block-library/blocks/post-title/style-rtl.css 138 B
build/block-library/blocks/post-title/style.css 138 B
build/block-library/blocks/preformatted/style-rtl.css 139 B
build/block-library/blocks/preformatted/style.css 139 B
build/block-library/blocks/pullquote/editor-rtl.css 170 B
build/block-library/blocks/pullquote/editor.css 170 B
build/block-library/blocks/pullquote/style-rtl.css 357 B
build/block-library/blocks/pullquote/style.css 357 B
build/block-library/blocks/pullquote/theme-rtl.css 201 B
build/block-library/blocks/pullquote/theme.css 201 B
build/block-library/blocks/query-pagination-numbers/editor-rtl.css 158 B
build/block-library/blocks/query-pagination-numbers/editor.css 156 B
build/block-library/blocks/query-pagination/editor-rtl.css 258 B
build/block-library/blocks/query-pagination/editor.css 247 B
build/block-library/blocks/query-pagination/style-rtl.css 326 B
build/block-library/blocks/query-pagination/style.css 322 B
build/block-library/blocks/query-title/style-rtl.css 108 B
build/block-library/blocks/query-title/style.css 108 B
build/block-library/blocks/query/editor-rtl.css 475 B
build/block-library/blocks/query/editor.css 477 B
build/block-library/blocks/quote/style-rtl.css 253 B
build/block-library/blocks/quote/style.css 253 B
build/block-library/blocks/quote/theme-rtl.css 255 B
build/block-library/blocks/quote/theme.css 259 B
build/block-library/blocks/read-more/style-rtl.css 168 B
build/block-library/blocks/read-more/style.css 168 B
build/block-library/blocks/rss/editor-rtl.css 239 B
build/block-library/blocks/rss/editor.css 240 B
build/block-library/blocks/rss/style-rtl.css 323 B
build/block-library/blocks/rss/style.css 323 B
build/block-library/blocks/search/editor-rtl.css 205 B
build/block-library/blocks/search/editor.css 205 B
build/block-library/blocks/search/style-rtl.css 441 B
build/block-library/blocks/search/style.css 439 B
build/block-library/blocks/search/theme-rtl.css 149 B
build/block-library/blocks/search/theme.css 149 B
build/block-library/blocks/separator/editor-rtl.css 184 B
build/block-library/blocks/separator/editor.css 184 B
build/block-library/blocks/separator/style-rtl.css 269 B
build/block-library/blocks/separator/style.css 269 B
build/block-library/blocks/separator/theme-rtl.css 229 B
build/block-library/blocks/separator/theme.css 229 B
build/block-library/blocks/shortcode/editor-rtl.css 508 B
build/block-library/blocks/shortcode/editor.css 508 B
build/block-library/blocks/site-logo/editor-rtl.css 522 B
build/block-library/blocks/site-logo/editor.css 522 B
build/block-library/blocks/site-logo/style-rtl.css 238 B
build/block-library/blocks/site-logo/style.css 238 B
build/block-library/blocks/site-tagline/editor-rtl.css 129 B
build/block-library/blocks/site-tagline/editor.css 129 B
build/block-library/blocks/site-title/editor-rtl.css 155 B
build/block-library/blocks/site-title/editor.css 155 B
build/block-library/blocks/site-title/style-rtl.css 101 B
build/block-library/blocks/site-title/style.css 101 B
build/block-library/blocks/social-link/editor-rtl.css 219 B
build/block-library/blocks/social-link/editor.css 219 B
build/block-library/blocks/social-links/editor-rtl.css 709 B
build/block-library/blocks/social-links/editor.css 708 B
build/block-library/blocks/social-links/style-rtl.css 1.43 kB
build/block-library/blocks/social-links/style.css 1.43 kB
build/block-library/blocks/spacer/editor-rtl.css 372 B
build/block-library/blocks/spacer/editor.css 372 B
build/block-library/blocks/spacer/style-rtl.css 96 B
build/block-library/blocks/spacer/style.css 96 B
build/block-library/blocks/table/editor-rtl.css 491 B
build/block-library/blocks/table/editor.css 491 B
build/block-library/blocks/table/style-rtl.css 670 B
build/block-library/blocks/table/style.css 669 B
build/block-library/blocks/table/theme-rtl.css 220 B
build/block-library/blocks/table/theme.css 220 B
build/block-library/blocks/tag-cloud/style-rtl.css 287 B
build/block-library/blocks/tag-cloud/style.css 288 B
build/block-library/blocks/template-part/editor-rtl.css 436 B
build/block-library/blocks/template-part/editor.css 436 B
build/block-library/blocks/template-part/theme-rtl.css 139 B
build/block-library/blocks/template-part/theme.css 139 B
build/block-library/blocks/text-columns/editor-rtl.css 135 B
build/block-library/blocks/text-columns/editor.css 135 B
build/block-library/blocks/text-columns/style-rtl.css 198 B
build/block-library/blocks/text-columns/style.css 198 B
build/block-library/blocks/verse/style-rtl.css 130 B
build/block-library/blocks/verse/style.css 130 B
build/block-library/blocks/video/editor-rtl.css 720 B
build/block-library/blocks/video/editor.css 723 B
build/block-library/blocks/video/style-rtl.css 218 B
build/block-library/blocks/video/style.css 218 B
build/block-library/blocks/video/theme-rtl.css 171 B
build/block-library/blocks/video/theme.css 171 B
build/block-library/classic-rtl.css 193 B
build/block-library/classic.css 193 B
build/block-library/common-rtl.css 1.05 kB
build/block-library/common.css 1.05 kB
build/block-library/editor-elements-rtl.css 126 B
build/block-library/editor-elements.css 126 B
build/block-library/editor-rtl.css 11.7 kB
build/block-library/editor.css 11.7 kB
build/block-library/elements-rtl.css 105 B
build/block-library/elements.css 105 B
build/block-library/index.min.js 196 kB
build/block-library/reset-rtl.css 514 B
build/block-library/reset.css 514 B
build/block-library/style-rtl.css 12.4 kB
build/block-library/style.css 12.4 kB
build/block-library/theme-rtl.css 749 B
build/block-library/theme.css 753 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 50.4 kB
build/components/style-rtl.css 11.6 kB
build/components/style.css 11.6 kB
build/compose/index.min.js 12.3 kB
build/core-data/index.min.js 15.9 kB
build/customize-widgets/index.min.js 11.6 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/data/index.min.js 8.12 kB
build/date/index.min.js 32.1 kB
build/deprecated/index.min.js 518 B
build/dom-ready/index.min.js 336 B
build/dom/index.min.js 4.74 kB
build/edit-navigation/index.min.js 16.2 kB
build/edit-navigation/style-rtl.css 4.08 kB
build/edit-navigation/style.css 4.09 kB
build/edit-post/classic-rtl.css 569 B
build/edit-post/classic.css 570 B
build/edit-post/index.min.js 34.5 kB
build/edit-post/style-rtl.css 7.43 kB
build/edit-post/style.css 7.42 kB
build/edit-site/index.min.js 60.7 kB
build/edit-site/style-rtl.css 8.55 kB
build/edit-site/style.css 8.55 kB
build/edit-widgets/index.min.js 16.7 kB
build/edit-widgets/style-rtl.css 4.44 kB
build/edit-widgets/style.css 4.44 kB
build/editor/index.min.js 44 kB
build/editor/style-rtl.css 3.65 kB
build/editor/style.css 3.64 kB
build/element/index.min.js 4.72 kB
build/escape-html/index.min.js 548 B
build/experiments/index.min.js 882 B
build/format-library/index.min.js 6.96 kB
build/format-library/style-rtl.css 596 B
build/format-library/style.css 596 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.86 kB
build/list-reusable-blocks/index.min.js 2.13 kB
build/list-reusable-blocks/style-rtl.css 858 B
build/list-reusable-blocks/style.css 857 B
build/media-utils/index.min.js 2.94 kB
build/notices/index.min.js 977 B
build/nux/index.min.js 2.07 kB
build/nux/style-rtl.css 772 B
build/nux/style.css 768 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.59 kB
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.22 kB
build/reusable-blocks/style-rtl.css 281 B
build/reusable-blocks/style.css 281 B
build/rich-text/index.min.js 10.7 kB
build/server-side-render/index.min.js 2.19 kB
build/shortcode/index.min.js 1.52 kB
build/style-engine/index.min.js 1.51 kB
build/token-list/index.min.js 650 B
build/url/index.min.js 3.7 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.23 kB
build/widgets/style-rtl.css 1.21 kB
build/widgets/style.css 1.21 kB
build/wordcount/index.min.js 1.06 kB

compressed-size-action

@chad1008 chad1008 requested review from mirka and ciampo November 15, 2022 15:37
@chad1008 chad1008 self-assigned this Nov 15, 2022
@chad1008 chad1008 added [Type] Code Quality Issues or PRs that relate to code quality [Package] Components /packages/components [Feature] Component System WordPress component system labels Nov 15, 2022
@chad1008 chad1008 marked this pull request as ready for review November 15, 2022 15:37
@chad1008 chad1008 requested a review from ajitbohra as a code owner November 15, 2022 15:37
Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

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

Left a couple of comments that could potentially simplify the changes, although it'd be great if @mirka could also have a look at this PR and smoke-test it, given her familiarity with the component in the past weeks


// Sync incoming value with groupContext.state.
useUpdateEffect( () => {
if ( value !== groupContext.state ) {
groupContext.setState( value );
}
}, [ value ] );
}, [ value, groupContext ] );
Copy link
Contributor

Choose a reason for hiding this comment

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

Since groupContext.setState is effectively setSelectedValue, I wonder if we could just swap the call inside the hook to use setSelectedValue instead of groupContext.setState. That way, ESLint should not complain about a missing dependency?

Copy link
Contributor Author

@chad1008 chad1008 Nov 17, 2022

Choose a reason for hiding this comment

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

I don't see any problem with that approach! One thing to bear in mind though is that to actually eliminate the the groupContext dependency, we'd also need to replace groupContext.state with selectedValue. At that point, selectedValue becomes a dependency, so we've kind of come full circle and are still adding a second dependency.

That said, if we feel that selectedValue and setSelectedValue are more readable than the current groupContext values, I'm happy to make that change! 🙂

Copy link
Contributor

@ciampo ciampo Nov 18, 2022

Choose a reason for hiding this comment

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

Actually, I was thinking that using selectedValue and setSelectedValue fits my mental model better:

  • value or selectedValue change
  • the hook calls setSelectedValue
  • this causes a re-render, which causes the useMemo hook to re-run and update groupContext with the latest selectedValue
  • groupContext is not really used internally by the component, but only created in order to pass it to the context provider, for child components to use. This allows also to move all context props under useMemo, in order to avoid creating a new context object every render
Something like this
diff --git a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
index d4a040580e..3708f9b7bb 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
+++ b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
@@ -22,7 +22,10 @@ import ToggleGroupControlBackdrop from './toggle-group-control-backdrop';
 import ToggleGroupControlContext from '../context';
 import { useUpdateEffect } from '../../utils/hooks';
 import type { WordPressComponentProps } from '../../ui/context';
-import type { ToggleGroupControlMainControlProps } from '../types';
+import type {
+	ToggleGroupControlMainControlProps,
+	ToggleGroupControlContextProps,
+} from '../types';
 
 function UnforwardedToggleGroupControlAsButtonGroup(
 	{
@@ -47,41 +50,39 @@ function UnforwardedToggleGroupControlAsButtonGroup(
 		'toggle-group-control-as-button-group'
 	).toString();
 	const [ selectedValue, setSelectedValue ] = useState( value );
-	const groupContext = useMemo(
-		() => ( {
-			baseId,
-			state: selectedValue,
-			setState: setSelectedValue,
-		} ),
-		[ baseId, selectedValue ]
-	);
 	const previousValue = usePrevious( value );
 
-	// Propagate groupContext.state change.
+	// Propagate selectedValue changes.
 	useUpdateEffect( () => {
-		// Avoid calling onChange if groupContext state changed
+		// Avoid calling onChange if the selectedValue changed
 		// from incoming value.
-		if ( previousValue !== groupContext.state ) {
-			onChange( groupContext.state );
+		if ( previousValue !== selectedValue ) {
+			onChange( selectedValue );
 		}
-	}, [ groupContext.state, previousValue, onChange ] );
+	}, [ selectedValue, previousValue, onChange ] );
 
-	// Sync incoming value with groupContext.state.
+	// Sync incoming value with selectedValue.
 	useUpdateEffect( () => {
-		if ( value !== groupContext.state ) {
-			groupContext.setState( value );
+		if ( value !== selectedValue ) {
+			setSelectedValue( value );
 		}
-	}, [ value, groupContext ] );
+	}, [ value, selectedValue ] );
+
+	// Expose selectedValue getter/setter via context
+	const groupContext: ToggleGroupControlContextProps = useMemo(
+		() => ( {
+			baseId,
+			state: selectedValue,
+			setState: setSelectedValue,
+			isBlock: ! isAdaptiveWidth,
+			isDeselectable: true,
+			size,
+		} ),
+		[ baseId, selectedValue, isAdaptiveWidth, size ]
+	);
 
 	return (
-		<ToggleGroupControlContext.Provider
-			value={ {
-				...groupContext,
-				isBlock: ! isAdaptiveWidth,
-				isDeselectable: true,
-				size,
-			} }
-		>
+		<ToggleGroupControlContext.Provider value={ groupContext }>
 			<View
 				aria-label={ label }
 				{ ...otherProps }

How does the above proposed change look to y'all? (cc @mirka )

Copy link
Contributor Author

@chad1008 chad1008 Nov 21, 2022

Choose a reason for hiding this comment

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

Interestingly, it looks like at least one of our failing tests on this PR is being caused by adding selectedValue (or groupContext under the previous implementation) to the dependency array of the second useUpdateEffect.

It doesn't look like the deselection is actually working. The test clicks the button once, then clicks it again, but the pressed state doesn't look like it's updating. I'll keep investigating.

Copy link
Member

Choose a reason for hiding this comment

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

Marco's suggestions make perfect sense to me 👍

Looks like the test failure is because the test is doing an uncontrolled usage. Uncontrolled usages would legitimately break if we added selectedValue to the deps array.

The second useEffect is intended to sync incoming value prop changes to the internal component state. (The first useEffect is the inverse — it syncs internal state up to the outer controlled value via onChange.)

We only want the effect to fire when value changes, not when selectedValue changes. So something like this is probably what we want instead:

	// Sync incoming value with selectedValue.
	useUpdateEffect( () => {
		if ( previousValue !== value ) {
			setSelectedValue( value );
		}
	}, [ value, previousValue ] );

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ah. yes that makes sense. Thank you!

Changes applied, waiting on CI. 🤞

Copy link
Contributor

Choose a reason for hiding this comment

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

Tested these changes on Storybook — it seems like the onChange callback gets called twice every time the value changes

Screenshot 2022-11-26 at 18 51 11

I wonder if using useControlledValue instead of the custom logic would help here with syncing controlled and uncontrolled updates?

Copy link
Contributor Author

@chad1008 chad1008 Nov 28, 2022

Choose a reason for hiding this comment

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

Oh, promising idea. I've pushed an attempt at this change in 3acacad. I had to address a type discrepancy on the setter function, let me know how it looks!

edit: updated commit sha after rebasing

Copy link
Contributor Author

@chad1008 chad1008 Dec 1, 2022

Choose a reason for hiding this comment

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

Okay, update on the useControlledValue approach. It proved to be problematic. Expected behavior is that you click a button once, it gets selected. Click it again, it deselects.

With useControlledValue, the second click doesn't do anything (at least not from a UI perspective). It's the third click that actually deactivates the button and resets the value.

I believe this is happening because useControlledValue looks for the value prop to be undefined, and ToggleGroupControlOptionBase intentionally passes undefined when deselecting. This means useControlledValue sees undefined as the value being passed in, assumes there is no value, and treats it as an uncontrolled component.

We can work around that by having ToggleGroupControlOptionBase pass an empty string instead of undefined, and make one small test fix. Before I push that change though, I wondered what your thoughts on that might be @ciampo @mirka

Copy link
Member

Choose a reason for hiding this comment

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

I prefer not to have to pass an empty string instead of undefined, since that is rather arbitrary and not a solution that can be adopted in the general case (e.g., what if our state was a number type?). In fact, it's probably worth fixing this limitation in useControlledValue itself (not in this PR).

In our specific case, I feel like the most straightforward fix for the double onChange problem is something like this:

	// Propagate selectedValue change.
	useUpdateEffect( () => {
		// Only call onChange when uncontrolled
		if ( previousValue === value ) {
			onChange( selectedValue );
		}
	}, [ selectedValue, previousValue, value, onChange ] );

This seems to be working in my brief testing for both controlled/uncontrolled cases, though I think it's worth adding tests. Marco and I were talking about how we might want to start adding a .each() to our component tests so everything is tested in both controlled/uncontrolled cases. @chad1008 Do you want to try that here? It might not fit the scope of this PR, and this PR has been running long, so feel free to defer if it's too high-effort to include here. As long as we are reasonably confident that we haven't regressed, this PR can land.


// Sync incoming value with radio.state.
useUpdateEffect( () => {
if ( value !== radio.state ) {
radio.setState( value );
}
// disable reason: Adding `radio` to the dependency array causes an extra render.
Copy link
Contributor

Choose a reason for hiding this comment

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

What if we did something like

const setRadioState = radio.setState;

// ...

useUpdateEffect( () => {
		if ( value !== radio.state ) {
			setRadioState( value );
		}
}, [ setRadioState, value ] );

That way, the hook will re-run only when setRadioState changes (and not the whole radio object). Furthermore, there's a chance that setState is an actual React useState setter, which means its value will be stable throughout the component's lifetime (although we should check for that)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea - playing with it got interesting though:

If we go this route, we then need radio.state as a dependency. ESLint wasn't asking for that before, perhaps because it would have been happy with the full radio object?

Anyway - if we add that dependency, we end up (once again) with an additional render whenever a new option is selected, just like we'd get if we added radio directly. I suppose that makes sense, since radio.state is going to change every time.

This leaves me uncertain of the best course forward. Leave the disabling comment for now? Just add radio as the linter originally wanted, rather than introduce a new variable that doesn't actually solve our issue? 🤔

Copy link
Contributor

Choose a reason for hiding this comment

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

Good point. Adding those dependencies sounds correct to me, though — we could apply the technique that I mentioned above to both state and setState, and do something like this

Click to expand
diff --git a/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx b/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx
index 8cf75e2266..a2fc579d2b 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx
+++ b/packages/components/src/toggle-group-control/toggle-group-control/as-radio-group.tsx
@@ -64,14 +64,14 @@ function UnforwardedToggleGroupControlAsRadioGroup(
 	}, [ radio.state, previousValue, onChange ] );
 
 	// Sync incoming value with radio.state.
+	const { state: radioState, setState: setRadioState } = radio;
 	useUpdateEffect( () => {
-		if ( value !== radio.state ) {
-			radio.setState( value );
+		if ( value !== radioState ) {
+			setRadioState( value );
 		}
-		// disable reason: Adding `radio` to the dependency array causes an extra render.
-		// Memoizing it doesn't look easily doable, so we're disabling the rule for now.
-		// eslint-disable-next-line react-hooks/exhaustive-deps
-	}, [ value ] );
+		// setRadioState needs to be listed even if in theory it's supposed to be a
+		// stable reference — that's an ESLint limitation.
+	}, [ radioState, setRadioState, value ] );
 
 	return (
 		<ToggleGroupControlContext.Provider

We'd need of course to perform some testing and make sure that this doesn't introduce regressions — but, in theory, the hook is relatively simple and it makes sense to me that we re-run the hook when the radio state updates

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 had a similar thought 🙂 I'll get those changes pushed now!

Copy link
Contributor

Choose a reason for hiding this comment

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

Left a couple additional suggestions. On top of that, I think that it would be best to update the as-radio-group.tsx file with the same pattern (using useControlledState for handling controlled/uncontrolled updates, creating the context value separately with useMemo), even if it's not 100% related to original scope of the PR

@chad1008 chad1008 force-pushed the enhance/add-useUpdateEffect-exhaustive-deps branch 2 times, most recently from ee82b6c to e184c9c Compare November 21, 2022 17:39
@chad1008
Copy link
Contributor Author

Investigating test failures...

@chad1008 chad1008 force-pushed the enhance/add-useUpdateEffect-exhaustive-deps branch from 64ef11f to 3acacad Compare November 28, 2022 19:19
}, [ value ] );

const [ selectedValue, setSelectedValue ] = useControlledValue( {
defaultValue: previousValue,
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we need this line (and probably we don't need previousValue at all anymore)

Comment on lines 63 to 65
setState: setSelectedValue as React.Dispatch<
React.SetStateAction< string | number | undefined >
>,
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of typecasting setSelectedValue, we can try to type useControlledValue a bit most narrowly using const assertions

diff --git a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
index 0de5543bcc..a9e7879071 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
+++ b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
@@ -60,9 +60,7 @@ function UnforwardedToggleGroupControlAsButtonGroup(
 		() => ( {
 			baseId,
 			state: selectedValue,
-			setState: setSelectedValue as React.Dispatch<
-				React.SetStateAction< string | number | undefined >
-			>,
+			setState: setSelectedValue,
 			isBlock: ! isAdaptiveWidth,
 			isDeselectable: true,
 			size,
diff --git a/packages/components/src/utils/hooks/use-controlled-value.ts b/packages/components/src/utils/hooks/use-controlled-value.ts
index 5a3824d465..bb7f3aea80 100644
--- a/packages/components/src/utils/hooks/use-controlled-value.ts
+++ b/packages/components/src/utils/hooks/use-controlled-value.ts
@@ -22,7 +22,7 @@ export function useControlledValue< T >( {
 	defaultValue,
 	onChange,
 	value: valueProp,
-}: Props< T > ): [ T | undefined, ( value: T ) => void ] {
+}: Props< T > ) {
 	const hasValue = typeof valueProp !== 'undefined';
 	const initialValue = hasValue ? valueProp : defaultValue;
 	const [ state, setState ] = useState( initialValue );
@@ -40,5 +40,5 @@ export function useControlledValue< T >( {
 		setValue = setState;
 	}
 
-	return [ value, setValue ];
+	return [ value, setValue as typeof setState ] as const;
 }

Basically:

  • in the return statement, we typecast setValue to be "whatever is the type of the setState function
  • we add as const, which basically tells TypeScript to interpret the type of the return statement as literally as possible

Copy link
Contributor

Choose a reason for hiding this comment

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

I can actually open a separate PR with this change, so that this PR stays focused on ToggleGroupControl

Copy link
Contributor

@ciampo ciampo Nov 29, 2022

Choose a reason for hiding this comment

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

#46164 (merged), we can rebase on top on trunk to include those changes, which should allow to remove the as React.Dispatch< ... type cast

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you for making that change. I'm updating the typecasting now.

@chad1008
Copy link
Contributor Author

chad1008 commented Dec 1, 2022

Something odd I'm finding but haven't been able to sort out yet - with any of these changes, the should be deselectable unit test fails. After the second click to deselect, the test still sees the button as aria-pressed=true. I can't replicate that in Storybook or the editor, so something in how the test is running is diverging from the actual use cases. 🤔

@chad1008 chad1008 force-pushed the enhance/add-useUpdateEffect-exhaustive-deps branch from 3acacad to f4a0eca Compare December 1, 2022 17:17
@ciampo
Copy link
Contributor

ciampo commented Dec 1, 2022

So! I spent some time investigating this issue — here are my findings.

Investigation

The failing test has a peculiarity: it seems to test the component in a mixed controlled/uncontrolled way:

  • it passes the value prop, like a controlled component
  • it but it doesn't update the value when the onChange callback fires (uncontrolled behaviour)

By applying this same scenario to the Storybook example, I was able to reproduce the same failure in Storybook — basically, since the value prop never changes, interacting with the component doesn't cause the toggle's value to change.

Here are the changes that I made in Storybook that allow replicating the bug
diff --git a/packages/components/src/toggle-group-control/stories/index.tsx b/packages/components/src/toggle-group-control/stories/index.tsx
index 9978481764..6c21a9548f 100644
--- a/packages/components/src/toggle-group-control/stories/index.tsx
+++ b/packages/components/src/toggle-group-control/stories/index.tsx
@@ -6,7 +6,7 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react';
 /**
  * WordPress dependencies
  */
-import { useState } from '@wordpress/element';
+// import { useState } from '@wordpress/element';
 import { formatLowercase, formatUppercase } from '@wordpress/icons';
 
 /**
@@ -20,7 +20,7 @@ import {
 import type {
 	ToggleGroupControlOptionProps,
 	ToggleGroupControlOptionIconProps,
-	ToggleGroupControlProps,
+	// ToggleGroupControlProps,
 } from '../types';
 
 const meta: ComponentMeta< typeof ToggleGroupControl > = {
@@ -43,17 +43,17 @@ const Template: ComponentStory< typeof ToggleGroupControl > = ( {
 	onChange,
 	...props
 } ) => {
-	const [ value, setValue ] =
-		useState< ToggleGroupControlProps[ 'value' ] >();
+	// const [ value, setValue ] =
+	// 	useState< ToggleGroupControlProps[ 'value' ] >();
 
 	return (
 		<ToggleGroupControl
 			{ ...props }
 			onChange={ ( ...changeArgs ) => {
-				setValue( ...changeArgs );
+				// setValue( ...changeArgs );
 				onChange?.( ...changeArgs );
 			} }
-			value={ value }
+			// value={ value }
 		/>
 	);
 };
@@ -133,4 +133,5 @@ export const Deselectable: ComponentStory< typeof ToggleGroupControl > =
 Deselectable.args = {
 	...WithIcons.args,
 	isDeselectable: true,
+	value: 'uppercase',
 };

I was then able to make the tests pass by playing around with the useControlledValue hook arguments, using defaultValue instead of the value property

See first attempted fix
diff --git a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
index a9e7879071..9b4191f750 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
+++ b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
@@ -9,7 +9,6 @@ import type { ForwardedRef } from 'react';
 import {
 	useMergeRefs,
 	useInstanceId,
-	usePrevious,
 	useResizeObserver,
 } from '@wordpress/compose';
 import { forwardRef, useRef, useMemo } from '@wordpress/element';
@@ -49,11 +48,9 @@ function UnforwardedToggleGroupControlAsButtonGroup(
 		ToggleGroupControlAsButtonGroup,
 		'toggle-group-control-as-button-group'
 	).toString();
-	const previousValue = usePrevious( value );
 	const [ selectedValue, setSelectedValue ] = useControlledValue( {
-		defaultValue: previousValue,
+		defaultValue: value,
 		onChange,
-		value,
 	} );
 	// Expose selectedValue getter/setter via context
 	const groupContext: ToggleGroupControlContextProps = useMemo(

Although that felt more like a hack that avoids the issue, rather than a fix.

Conclusions

It looks like ToggleGroupControl may have a few inconsistencies compared against how we usually expect controlled / uncontrolled behaviour to work, which were highlighted by the switch from custom logic to the useControlledState hook.

Initial value vs current value

On trunk, it seems that the value prop can be currently be used to set the component's initial value, but that the component is also able to ignore it for uncontrolled updates even while it's still set.

A better approach in my opinion would be for consumers of ToggleGroupControl to use the defaultValue prop to set the initial value (instead of the value prop) — especially for when the component is uncontrolled (it looks like the component somehow already accepts that prop)

Diff showing how `defaultValue` prop could be used in `ToggleGroupControlAsButtonGroup`
diff --git a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
index a9e7879071..8780e1a333 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
+++ b/packages/components/src/toggle-group-control/toggle-group-control/as-button-group.tsx
@@ -9,7 +9,6 @@ import type { ForwardedRef } from 'react';
 import {
 	useMergeRefs,
 	useInstanceId,
-	usePrevious,
 	useResizeObserver,
 } from '@wordpress/compose';
 import { forwardRef, useRef, useMemo } from '@wordpress/element';
@@ -34,6 +33,7 @@ function UnforwardedToggleGroupControlAsButtonGroup(
 		label,
 		onChange,
 		size,
+		defaultValue,
 		value,
 		...otherProps
 	}: WordPressComponentProps<
@@ -49,9 +49,8 @@ function UnforwardedToggleGroupControlAsButtonGroup(
 		ToggleGroupControlAsButtonGroup,
 		'toggle-group-control-as-button-group'
 	).toString();
-	const previousValue = usePrevious( value );
 	const [ selectedValue, setSelectedValue ] = useControlledValue( {
-		defaultValue: previousValue,
+		defaultValue: defaultValue as string | number,
 		onChange,
 		value,
 	} );
diff --git a/packages/components/src/toggle-group-control/toggle-group-control/component.tsx b/packages/components/src/toggle-group-control/toggle-group-control/component.tsx
index ebd4893e37..7a35c81a72 100644
--- a/packages/components/src/toggle-group-control/toggle-group-control/component.tsx
+++ b/packages/components/src/toggle-group-control/toggle-group-control/component.tsx
@@ -42,6 +42,7 @@ function UnconnectedToggleGroupControl(
 		onChange = noop,
 		size = 'default',
 		value,
+		defaultValue,
 		children,
 		...otherProps
 	} = useContextSystem( props, 'ToggleGroupControl' );
@@ -81,6 +82,7 @@ function UnconnectedToggleGroupControl(
 				ref={ forwardedRef }
 				size={ size }
 				value={ value }
+				defaultValue={ defaultValue }
 			/>
 		</BaseControl>
 	);

Using undefined in controlled mode

The fact that onChange fires with undefined as an argument when de-selecting an option can cause some unexpected behaviour if that undefined value is passed to ToggleGroupControl as the value prop, since that would basically cause the component to switch from controlled to uncontrolled mode (useControlledValue short-circuits to uncontrolled mode).

Probably the most sensible solution here is to make sure that, when the component is used in controlled mode, we pass an empty string instead of undefined

Unit tests

In the light of the above, I'm also not sure that they way unit tests are currently written is the correct way the component should be used. In the tests, the component should:

  • either be fully controlled (i.e. setting the value when onChange fires)
  • or be fully uncontrolled (and make use of the defaultValue prop when necessary)
Example uncontrolled (requires the diff that adds support for `defaultValue`)
diff --git a/packages/components/src/toggle-group-control/test/index.tsx b/packages/components/src/toggle-group-control/test/index.tsx
index 841ca8fd6b..b9e9b451f4 100644
--- a/packages/components/src/toggle-group-control/test/index.tsx
+++ b/packages/components/src/toggle-group-control/test/index.tsx
@@ -209,7 +209,7 @@ describe( 'ToggleGroupControl', () => {
 
 				render(
 					<ToggleGroupControl
-						value="rigas"
+						defaultValue="rigas"
 						label="Test"
 						onChange={ mockOnChange }
 						isDeselectable
Example controlled (notice the fallback to `''`)
diff --git a/packages/components/src/toggle-group-control/test/index.tsx b/packages/components/src/toggle-group-control/test/index.tsx
index 841ca8fd6b..4e286183ad 100644
--- a/packages/components/src/toggle-group-control/test/index.tsx
+++ b/packages/components/src/toggle-group-control/test/index.tsx
@@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event';
 /**
  * WordPress dependencies
  */
+import { useState } from '@wordpress/element';
 import { formatLowercase, formatUppercase } from '@wordpress/icons';
 
 /**
@@ -17,6 +18,7 @@ import {
 	ToggleGroupControlOption,
 	ToggleGroupControlOptionIcon,
 } from '../index';
+import type { ToggleGroupControlProps } from '../types';
 
 function getWrappingPopoverElement( element: HTMLElement ) {
 	return element.closest( '.components-popover' );
@@ -201,21 +203,39 @@ describe( 'ToggleGroupControl', () => {
 		} );
 
 		describe( 'isDeselectable = true', () => {
-			it( 'should be deselectable', async () => {
+			const ControlledToggleGroupControl = ( {
+				onChange,
+			}: Pick< ToggleGroupControlProps, 'onChange' > ) => {
+				const [ value, setValue ] =
+					useState< ToggleGroupControlProps[ 'value' ] >( 'rigas' );
+
+				const handleChange: ToggleGroupControlProps[ 'onChange' ] = (
+					newValue
+				) => {
+					setValue( newValue );
+					onChange?.( newValue );
+				};
+
+				return (
+					<ToggleGroupControl
+						value={ value ?? '' }
+						label="Test"
+						onChange={ handleChange }
+						isDeselectable
+					>
+						<ToggleGroupControlOption value="rigas" label="R" />
+						<ToggleGroupControlOption value="jack" label="J" />
+					</ToggleGroupControl>
+				);
+			};
+			it.only( 'should be deselectable', async () => {
 				const mockOnChange = jest.fn();
 				const user = userEvent.setup( {
 					advanceTimers: jest.advanceTimersByTime,
 				} );
 
 				render(
-					<ToggleGroupControl
-						value="rigas"
-						label="Test"
-						onChange={ mockOnChange }
-						isDeselectable
-					>
-						{ options }
-					</ToggleGroupControl>
+					<ControlledToggleGroupControl onChange={ mockOnChange } />
 				);
 
 				await user.click(

@mirka , do these conclusions make sense to you? I hope I didn't get too deep in this 🐰 🕳️

@chad1008
Copy link
Contributor Author

chad1008 commented Dec 5, 2022

Thank you so much for digging into that @ciampo! I'll await @mirka's thoughts on your full conclusions, but my initial thought thought is that I also think it makes sense to pass an empty string instead of undefined (side note, is it wrong that I'm this excited that we had the same thought there? 😆

In addition to that, a quick question for my own edification - you've presented two options on the unit test (fully controlled vs. fully uncontrolled). Would it make sense to test both, or is that overkill?

@ciampo
Copy link
Contributor

ciampo commented Jan 3, 2023

I'll await @mirka's thoughts on your full conclusion

Yup, let's wait for Lena's thoughts!

you've presented two options on the unit test (fully controlled vs. fully uncontrolled). Would it make sense to test both, or is that overkill?

I would personally like to test both controlled and uncontrolled, as testing for both behaviours can help us flag bugs and inconsistencies (like in this PR).

@mirka
Copy link
Member

mirka commented Jan 4, 2023

My thoughts are basically this — the most important point being that I'm reluctant to adopt the "pass empty string in lieu of undefined" pattern. Resetting controls to undefined is a very common use case in the Gutenberg app, and not all control values are string types. I'm hoping we can use some other logic for determining whether it's in uncontrolled mode, for example checking for value === previousValue.

I agree that our tests should ideally cover both controlled/uncontrolled. In this particular PR though, I'm also fine with prioritizing landing just the eslintrc update with minimal changes, and defer any large component/test changes to a separate issue so it can be evaluated/prioritized separately.

@ciampo
Copy link
Contributor

ciampo commented Jan 9, 2023

My gut tells me that we should respect the convention around passing undefined (equivalent to not setting a value for the prop altogether) to denote uncontrolled components. I'm afraid that using our custom convention may result in poor devX (this would a pattern that other devs are not used to) and potentially unexpected/buggy runtime behaviour.

But that raises the question about how to represent correctly an "empty but controlled" value for a given prop: passing an empty string may cut it for props with type string, but what about other types? My instinct here would be use null, since it's type-agnostic.

As discussed, probably the best next move is to open a separate issue where we discuss "controlled" vs "uncontrolled" strategy for the package.

@chad1008
Copy link
Contributor Author

I agree with Marco in that a broader conversation sounds like it's in order before we move forward on this PR - I'll leave it as is for now, and revisit when we have a clearer picture of how we want to proceed with the controlled/uncontrolled distinction at the package level.

@ciampo
Copy link
Contributor

ciampo commented Jan 26, 2023

Opened #47473 to discuss separately

@ciampo
Copy link
Contributor

ciampo commented Jan 27, 2023

Edit — I got ahead of myself, it looks like there's more to that conversation. Let's probably wait a little bit more

@ciampo
Copy link
Contributor

ciampo commented Dec 22, 2023

@chad1008 is this PR still relevant?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Feature] Component System WordPress component system [Package] Components /packages/components [Type] Code Quality Issues or PRs that relate to code quality
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants