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

NavigationToggle: wait for tooltip positioning in unit test #45587

Closed
wants to merge 3 commits into from

Conversation

jsnajdr
Copy link
Member

@jsnajdr jsnajdr commented Nov 7, 2022

Fixes the unit test for NavigationToggle which triggers the "update not wrapped in act()" warning after React 18 upgrade, as in #45235. NavigationToggle is this site icon button in the top-left corner that opens the nav sidebar:

Screenshot 2022-11-07 at 7 32 28

It has a not-entirely-desired behavior (which is going to stay for a while though, see #37265) where it grabs focus on mount, and focus leads to displaying the "Toggle navigation" tooltip. The tooltip gets positioned asynchronously, and a correct unit test needs to wait for it to finish, otherwise the positioning update will trigger the act() warning.

This PR implement the Right Solution™ to that problem, one which I outlined in #45235 (comment): wait for the positioning DOM changes with await waitFor() after render, and proceed with the test only after the positioning is done. See the isPopover helper. And it works! The tests start passing in the React 18 migration branch, and also please notice the snapshot changes: the component-popover elements now have the final positioning styles. We're indeed testing the final state.

TODO for follow-up PRs: the isPopover helper should be improved, so that expect calls that use it are as ergonomic as possible (suggestions welcome) and extracted into a common helper shared by other tests.

@jsnajdr jsnajdr self-assigned this Nov 7, 2022
@codesandbox
Copy link

codesandbox bot commented Nov 7, 2022

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

@github-actions
Copy link

github-actions bot commented Nov 7, 2022

Size Change: +5.51 kB (0%)

Total Size: 1.29 MB

Filename Size Change
build/block-editor/index.min.js 175 kB +4.83 kB (+3%)
build/block-editor/style-rtl.css 15.8 kB +72 B (0%)
build/block-editor/style.css 15.8 kB +69 B (0%)
build/block-library/blocks/navigation/editor-rtl.css 2.1 kB +64 B (+3%)
build/block-library/blocks/navigation/editor.css 2.1 kB +64 B (+3%)
build/block-library/blocks/navigation/style-rtl.css 2.2 kB +12 B (+1%)
build/block-library/blocks/navigation/style.css 2.19 kB +12 B (+1%)
build/block-library/blocks/query/editor-rtl.css 440 B +1 B (0%)
build/block-library/blocks/query/editor.css 440 B +1 B (0%)
build/block-library/editor-rtl.css 11.3 kB +61 B (+1%)
build/block-library/editor.css 11.3 kB +60 B (+1%)
build/block-library/index.min.js 193 kB +155 B (0%)
build/block-library/style-rtl.css 12.4 kB +14 B (0%)
build/block-library/style.css 12.4 kB +11 B (0%)
build/components/index.min.js 203 kB -31 B (0%)
build/components/style-rtl.css 11.3 kB +51 B (0%)
build/components/style.css 11.4 kB +51 B (0%)
build/edit-post/index.min.js 34.1 kB +19 B (0%)
build/edit-post/style-rtl.css 7.31 kB -15 B (0%)
build/edit-post/style.css 7.3 kB -18 B (0%)
build/edit-site/index.min.js 58.1 kB +28 B (0%)
ℹ️ View Unchanged
Filename Size
build/a11y/index.min.js 982 B
build/annotations/index.min.js 2.76 kB
build/api-fetch/index.min.js 2.26 kB
build/autop/index.min.js 2.14 kB
build/blob/index.min.js 475 B
build/block-directory/index.min.js 7.09 kB
build/block-directory/style-rtl.css 990 B
build/block-directory/style.css 991 B
build/block-editor/default-editor-styles-rtl.css 378 B
build/block-editor/default-editor-styles.css 378 B
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 126 B
build/block-library/blocks/audio/theme.css 126 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 84 B
build/block-library/blocks/avatar/style.css 84 B
build/block-library/blocks/block/editor-rtl.css 161 B
build/block-library/blocks/block/editor.css 161 B
build/block-library/blocks/button/editor-rtl.css 482 B
build/block-library/blocks/button/editor.css 482 B
build/block-library/blocks/button/style-rtl.css 532 B
build/block-library/blocks/button/style.css 532 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.57 kB
build/block-library/blocks/cover/style.css 1.55 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 126 B
build/block-library/blocks/embed/theme.css 126 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 253 B
build/block-library/blocks/file/style.css 254 B
build/block-library/blocks/file/view.min.js 346 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 948 B
build/block-library/blocks/gallery/editor.css 950 B
build/block-library/blocks/gallery/style-rtl.css 1.53 kB
build/block-library/blocks/gallery/style.css 1.53 kB
build/block-library/blocks/gallery/theme-rtl.css 108 B
build/block-library/blocks/gallery/theme.css 108 B
build/block-library/blocks/group/editor-rtl.css 394 B
build/block-library/blocks/group/editor.css 394 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 327 B
build/block-library/blocks/html/editor.css 329 B
build/block-library/blocks/image/editor-rtl.css 880 B
build/block-library/blocks/image/editor.css 880 B
build/block-library/blocks/image/style-rtl.css 627 B
build/block-library/blocks/image/style.css 630 B
build/block-library/blocks/image/theme-rtl.css 126 B
build/block-library/blocks/image/theme.css 126 B
build/block-library/blocks/latest-comments/style-rtl.css 298 B
build/block-library/blocks/latest-comments/style.css 298 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 712 B
build/block-library/blocks/navigation-link/editor.css 711 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 296 B
build/block-library/blocks/navigation-submenu/editor.css 295 B
build/block-library/blocks/navigation/view-modal.min.js 2.78 kB
build/block-library/blocks/navigation/view.min.js 443 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 363 B
build/block-library/blocks/page-list/editor.css 363 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 73 B
build/block-library/blocks/post-excerpt/editor.css 73 B
build/block-library/blocks/post-excerpt/style-rtl.css 69 B
build/block-library/blocks/post-excerpt/style.css 69 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 315 B
build/block-library/blocks/post-featured-image/style.css 315 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 282 B
build/block-library/blocks/query-pagination/style.css 278 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/quote/style-rtl.css 213 B
build/block-library/blocks/quote/style.css 213 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 202 B
build/block-library/blocks/rss/editor.css 204 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 464 B
build/block-library/blocks/shortcode/editor.css 464 B
build/block-library/blocks/site-logo/editor-rtl.css 490 B
build/block-library/blocks/site-logo/editor.css 490 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 322 B
build/block-library/blocks/spacer/editor.css 322 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 505 B
build/block-library/blocks/table/editor.css 505 B
build/block-library/blocks/table/style-rtl.css 631 B
build/block-library/blocks/table/style.css 631 B
build/block-library/blocks/table/theme-rtl.css 175 B
build/block-library/blocks/table/theme.css 175 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 235 B
build/block-library/blocks/template-part/editor.css 235 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 87 B
build/block-library/blocks/verse/style.css 87 B
build/block-library/blocks/video/editor-rtl.css 691 B
build/block-library/blocks/video/editor.css 694 B
build/block-library/blocks/video/style-rtl.css 174 B
build/block-library/blocks/video/style.css 174 B
build/block-library/blocks/video/theme-rtl.css 126 B
build/block-library/blocks/video/theme.css 126 B
build/block-library/classic-rtl.css 162 B
build/block-library/classic.css 162 B
build/block-library/common-rtl.css 1.02 kB
build/block-library/common.css 1.02 kB
build/block-library/editor-elements-rtl.css 75 B
build/block-library/editor-elements.css 75 B
build/block-library/elements-rtl.css 54 B
build/block-library/elements.css 54 B
build/block-library/reset-rtl.css 478 B
build/block-library/reset.css 478 B
build/block-library/theme-rtl.css 713 B
build/block-library/theme.css 716 B
build/block-serialization-default-parser/index.min.js 1.12 kB
build/block-serialization-spec-parser/index.min.js 2.83 kB
build/blocks/index.min.js 49.9 kB
build/compose/index.min.js 12.2 kB
build/core-data/index.min.js 15.5 kB
build/customize-widgets/index.min.js 11.3 kB
build/customize-widgets/style-rtl.css 1.38 kB
build/customize-widgets/style.css 1.38 kB
build/data-controls/index.min.js 653 B
build/data/index.min.js 8.08 kB
build/date/index.min.js 32.1 kB
build/deprecated/index.min.js 507 B
build/dom-ready/index.min.js 324 B
build/dom/index.min.js 4.7 kB
build/edit-navigation/index.min.js 16.1 kB
build/edit-navigation/style-rtl.css 3.99 kB
build/edit-navigation/style.css 4 kB
build/edit-post/classic-rtl.css 546 B
build/edit-post/classic.css 547 B
build/edit-site/style-rtl.css 8.37 kB
build/edit-site/style.css 8.35 kB
build/edit-widgets/index.min.js 16.7 kB
build/edit-widgets/style-rtl.css 4.34 kB
build/edit-widgets/style.css 4.34 kB
build/editor/index.min.js 43.6 kB
build/editor/style-rtl.css 3.6 kB
build/editor/style.css 3.59 kB
build/element/index.min.js 4.68 kB
build/escape-html/index.min.js 537 B
build/experiments/index.min.js 868 B
build/format-library/index.min.js 6.95 kB
build/format-library/style-rtl.css 571 B
build/format-library/style.css 571 B
build/hooks/index.min.js 1.64 kB
build/html-entities/index.min.js 448 B
build/i18n/index.min.js 3.77 kB
build/is-shallow-equal/index.min.js 527 B
build/keyboard-shortcuts/index.min.js 1.78 kB
build/keycodes/index.min.js 1.83 kB
build/list-reusable-blocks/index.min.js 2.13 kB
build/list-reusable-blocks/style-rtl.css 835 B
build/list-reusable-blocks/style.css 835 B
build/media-utils/index.min.js 2.93 kB
build/notices/index.min.js 963 B
build/nux/index.min.js 2.06 kB
build/nux/style-rtl.css 732 B
build/nux/style.css 728 B
build/plugins/index.min.js 1.94 kB
build/preferences-persistence/index.min.js 2.22 kB
build/preferences/index.min.js 1.33 kB
build/primitives/index.min.js 944 B
build/priority-queue/index.min.js 1.58 kB
build/react-i18n/index.min.js 696 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.74 kB
build/reusable-blocks/index.min.js 2.21 kB
build/reusable-blocks/style-rtl.css 256 B
build/reusable-blocks/style.css 256 B
build/rich-text/index.min.js 10.6 kB
build/server-side-render/index.min.js 1.77 kB
build/shortcode/index.min.js 1.53 kB
build/style-engine/index.min.js 1.48 kB
build/token-list/index.min.js 644 B
build/url/index.min.js 3.61 kB
build/vendors/inert-polyfill.min.js 2.48 kB
build/vendors/react-dom.min.js 38.5 kB
build/vendors/react.min.js 4.34 kB
build/viewport/index.min.js 1.08 kB
build/warning/index.min.js 268 B
build/widgets/index.min.js 7.21 kB
build/widgets/style-rtl.css 1.18 kB
build/widgets/style.css 1.19 kB
build/wordcount/index.min.js 1.06 kB

compressed-size-action

Comment on lines 70 to 72
const siteIcon = screen.getByAltText( 'Site Icon' );

expect( siteIcon ).toBeVisible();
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if, instead of waiting for implementation details like isPopover which is harder to understand to end users, we could just auto-wait the results directly in the assertions?

await expect( screen.findByText( 'Toggle navigation' ) ).resolves.toBeVisible();

I've not tested this with React 18 though so not sure if it fixes the act() issue. This technique is used by Playwright and I think it makes a lot of sense in unit testing as well.

For reference, this is how it would like in Playwright.

await expect( page.getByText( 'Toggle navigation' ) ).toBeVisible();

Copy link
Member Author

Choose a reason for hiding this comment

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

we could just auto-wait the results directly in the assertions?

The example assertion you proposed is equivalent to:

expect( await screen.findText( 'Toggle navigation' ) ).toBeVisible();

and what it does is:

  1. wait for the "Toggle navigation" element to appear in the DOM. It doesn't have to appear immediately, we'll wait. And it doesn't need to be visible, even a display: none element will be successfully found and returned by findByText.
  2. check if the returned element is visible, and perform the check right now, synchronously. We have only one attempt: if the element is not visible, the check will fail. We'll not wait and try again, like findByText does when it doesn't find the element.

That means that if the tooltip gets initially rendered with display: none or opacity: 0, and is made visible only a bit later, the assertion will fail. We're not waiting for the element to get visible, we're waiting only for it to appear in the DOM.

The test author needs to be aware how exactly the UI evolves and write the right checks.

In our case, we don't want to wait for the tooltip to become visible, but to become positioned. We could write a custom matcher for that, and then write:

const tooltip = screen.getByText( 'Toggle navigation' );
await waitFor( () => expect( tooltip ).toBePositionedPopover() );

This is a combination of sync and async checks. The tooltip element is in the DOM right after render, we don't need to wait. But the positioning happens async a bit later, and we need to wait.

await expect( page.getByText( 'Toggle navigation' ) ).toBeVisible();

In the Testing Library language, this is a fully synchronous check. The await is redundant. getByText doesn't return a promise: it returns either the element it found or throws an error.

Copy link
Member

Choose a reason for hiding this comment

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

I see! Thanks for the explanation! I'm a bit confused though. Isn't the toBeVisible() assertion the only and last assertion in the test? Do we care if the tooltip is positioned correctly? I think we only care if the siteIcon is visible in this test case. Not to mention that the tooltip is sort of an unexpected side effect, or can even be considered a bug.

I guess a better solution, even though not perfect, would be to manually call cleanup() at the end of the test as shown in the doc. I'm aware of your original comment and that you mentioned that it's more like a "hack". However, compared to other solutions (manual act() and unrelated waitFor), I think the cleanup hack might be the best one that doesn't involve many implementation details. WDYT? I'm not sure if this hack even works in this case though 😅 .

In the Testing Library language, this is a fully synchronous check. The await is redundant. getByText doesn't return a promise: it returns either the element it found or throws an error.

The example I shared is not Testing Library's code, but Playwright's code. In Playwright, getByText returns a locator, which lazily locates the element. toBeVisible() also auto-waits and auto-retries until the assertion passes. Playwright also checks for actionability before performing the assertion. But I know that it's not the same as the Jest framework and is not really related to this issue though 😅 .

Copy link
Member Author

Choose a reason for hiding this comment

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

Isn't the toBeVisible() assertion the only and last assertion in the test? Do we care if the tooltip is positioned correctly? I think we only care if the siteIcon is visible in this test case. Not to mention that the tooltip is sort of an unexpected side effect, or can even be considered a bug.

Yes, the test actually doesn't care about the tooltip at all. But the tooltip is there anyway and it spawns an async "thread" that does the positioning. We need to have these threads under control, because if we don't, there are rogue threads from tests that have finished a long time ago which are still fetching or writing to the DOM or whatever. And that causes trouble.

It would help if the computePosition().then() thread was cancelled when the tooltip is unmounted, but that doesn't happen. I verified that it's still running long after the test finished.

This particular tooltip is indeed bug-like: we don't really want to show it on mount, only after closing the navigation menu. After #37314 is finished, the "focus on mount" code can be removed and the tooltip will never be rendered in these tests.

In Playwright, getByText returns a locator, which lazily locates the element.

Thanks for the example. I'm mostly unfamiliar with Playwright API. The API is almost identical to Testing Library, but Playwright is fully async, right? In Testing Library, there are both sync (getBySomething) and async (findBySomething) queries.

I think the cleanup hack might be the best one that doesn't involve many implementation details. WDYT?

I spent some time now carefully debugging the cleanup hack and figuring out how and why it works, and I don't like it at all 🙂 Seems to me that when it works, it works by pure accident.

First, the Testing Library itself registers an afterEach hook that runs cleanup. So it always runs anyway. If I call cleanup manually in my test, the entire difference is that it runs one or two microtasks earlier. That doesn't look like a reliable technique.

The computePosition async thread still runs even after cleanup. So, one way it might effectively work is that cleanup causes computePosition to throw, because, for example, it can't find a DOM element? Then the .then handler doesn't run and doesn't do the state update that triggers the "not wrapped in act" warning.

Another way the test suite avoids triggering the warning is by ending before computePosition resolves. I saw this during a debugging session: computePosition runs and returns results, but neither the .then nor the .catch handler is ever called. The test suite is finished, so Node.js process apparently decided to exit, probably by calling exit(0), without bother to finish executing the rogue threads.

Copy link
Member

Choose a reason for hiding this comment

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

It would help if the computePosition().then() thread was cancelled when the tooltip is unmounted, but that doesn't happen. I verified that it's still running long after the test finished.

Do you have a reference for this? I tried it with React 18 and it worked. It seems like it's also handled in floating-ui judging from their source code.

The API is almost identical to Testing Library, but Playwright is fully async, right? In Testing Library, there are both sync (getBySomething) and async (findBySomething) queries.

Yep, that's why I said that it's not really relevant here. I just wanted to throw it here for reference of how Playwright handles this kind of issue. 😅

If I call cleanup manually in my test, the entire difference is that it runs one or two microtasks earlier. That doesn't look like a reliable technique.

I think you're right, but I don't think this is an accident. I think it might be related to how Jest schedules the test too. I'm not sure if this is correct but it seems like the act warning would only happen on a "properly cleaned up test" if it's scheduling a microtask (promise). Because Jest's afterEach would queue another microtask (again, not sure), the cleanup function would happen after the state has already been updated.

However, for tests that actually care about the position, like the other test below (more on that later), we still need some way to properly wait for it to finish. await act(async () => {}) works, your toBePositioned() works too, either way is fine to me. I would recommend a more explicit way to wait and assert the end result though. Something like:

await waitFor(() => {
  expect( tooltip ).toHaveStyle( `
    left: 0px;
    top: 1px;
  ` );
});

It's very similar to toBePositioned, I think both are fine.


A little bit off-topic: I feel like the snapshot test at the end of the second test is out of place. The test's title doesn't imply anything about the tooltip's position and I don't think it's something we want to test either. I think we can just remove the snapshot test and replace it with explicit assertions if needed. If we do need to check for something that the snapshot is covering, then we should create another dedicated test for that and write explicit and atomic assertions for them.

In this case, given that the tooltip thing is a side-effect, I think we don't need to test that and we can just delete the snapshot. This means we can just use the cleanup trick for both tests in this file without having to introduce toBePositioned or other tricks as suggested above.

Copy link
Member Author

Choose a reason for hiding this comment

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

Because Jest's afterEach would queue another microtask (again, not sure), the cleanup function would happen after the state has already been updated.

Yes, I've been debugging why exactly the cleanup trick would work and you're exactly right. Jest runs the test in an async loop:

await runTest();
for ( const hook of hooks.afterEach ) {
  await runHook( hook );
}

and if there are enough registered afterEach hooks, it will take a few ticks to run. The jest-console package that verifies that the test didn't log any rubbish to console, it registers four.

At the same time, computePosition() runs an async loop, too:

for ( let i = 0; i < middlewares.length; i++ ) {
  await middleware[ i ]();
}

It's the same microtask race as the one in waitFor that we discussed with @tyxla. If computePosition() wins the race, there is the act() warning.

So, if the test is synchronous, i.e., it doesn't do any awaiting, we can solve the issue by calling the cleanup synchronously. That guarantees that computePosition() always loses the race even before it started.

For sync test we can use the cleanup solution. For other, more complex tests, we'll need to await the popover positioning.

I implemented the alternative solution in #45726, let's review and merge it instead. The work done in this PR, namely the custom matchers, will be reused elsewhere.

I feel like the snapshot test at the end of the second test is out of place.

That's true, we don't need to check the snapshot to achieve the test's goal, i.e., verifying that the button was rendered and displays the right site icon. I removed it.

Copy link
Member

Choose a reason for hiding this comment

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

I don't think it has anything to do with "racing" though. cleanup is a synchronous action, it internally unmounts the component and triggers any cleanup effect the component might register. The promise callback will still be fired (unless it uses AbortController which floating-ui doesn't), but setState won't be called to trigger the act warning. That is, if the component properly cleans up the effect after unmount, as every component should, calling cleanup or unmount synchronously should solve the act warning.

The act warning might still happen in between assertions and other updates though, which will be a different issue to tackle, and manual waiting might be required as you suggested. That will depend on whether we care about the updated position, and we can just mock out the library (if we don't) or write explicit assertions about the updated position (if we care). Either way, I think introducing this toBePostioned() helper might be a little early. We can wait and see until there're more such cases. :)

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't think it has anything to do with "racing" though.

There is a race, not in the cleanup() function itself, but in the async loop that executes the afterEach hooks. Look at the two async loops I described in the previous comment. One of them runs the afterEach hooks, the other executes the computePosition middlewares and finally calls setState.

It can happen that the setState calls runs on a still-mounted component, because the afterEach loop didn't get to call cleanup() yet. That's why we need to call cleanup() synchronously inside the test body if we want it to run reliably.

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, what I mean is that we don't have to care about the race as test authors if we use the cleanup trick. Maybe there're some edge cases that it still matters though!

@tyxla
Copy link
Member

tyxla commented Nov 8, 2022

Hey, @jsnajdr thanks for this work! Really nice catch that it takes more time for the popovers to get positioned and we've been storing obsolete snapshots as a consequence.

I've just pushed a commit that does a few things:

  • Registers a custom toBePositioned jest matcher. I believe it might solve some of @kevin940726's concerns and make the assertions a bit more readable and intuitive. It adds on top of what was there before and adds a few more checks, like ensuring that the element is in the document and so on, in the same style of the jest-dom matchers. Ideally, if we decide to move with this, we should move that matcher to a more central location so it could be reused.
  • In the assertion, we're using parentElement to specifically target the popover wrapper element, since it shouldn't the job of the matcher to search for an element in the DOM tree.
  • We're increasing the waitFor() timeout since I noticed that the default 1000ms isn't always enough for the positioning to occur. This is actually the missing reagent for the tests to pass.

Let me know what you think.

@jsnajdr
Copy link
Member Author

jsnajdr commented Nov 9, 2022

In the assertion, we're using parentElement to specifically target the popover wrapper element, since it shouldn't the job of the matcher to search for an element in the DOM tree.

I think it's more ergonomic if the matcher finds the .components-popover parent element for us. When I write

expect( screen.getByText( 'toggle' ) ).toBePositioned()

what I mean by it is "find a popover that contains the 'toggle' text and check if it's positioned". If that popover is like

<Tooltip><b>toggle</b> navigation</Tooltip>

then .parentElement is not the popover yet, I need to go two levels up. And it would be nice if someone did this job for me. I thought that .closest( '.components-popover' ) does that quite well.

We're increasing the waitFor() timeout since I noticed that the default 1000ms isn't always enough for the positioning to occur.

Oh no, this is just another random coincidence. There is no waiting or timeouts involved, just promise chains, where all promises resolve immediately. Like Promise.resolve().then().then().then(). Internally, waitFor is an async loop like this:

while ( ! finished ) {
  advanceTimers(); // synchronously advance fake timers by 50ms and run their handlers
  checkCallback(); // check the `waitFor` callback after running the timers
  await Promise.resolve(); // do a microtask tick so that promise handlers can run
}

With the default 1000 timeout this loop runs 20 times before giving up, with 2000 it runs 40 times. Advancing timers by 50ms in each iteration. Because there are no timers to advance, it does nothing else but run 20/40 microtask ticks.

In the Floating UI's computePosition function, there is another async loop:

for ( let i = 0; i < middleware.length; i++) {
  await middleware[i]();
}

In our case, this await loop takes exactly 39 microtask ticks to finish. That's why the waitFor loop also needs to wait for 39 ticks and it does that only with timeout: 2000. 🙂

That's how it works with React v17 and @testing-library/react v12. After upgrade to v18 and v13, respectively, the internals are apparently different and the default timeout: 1000 is enough.

@tyxla
Copy link
Member

tyxla commented Nov 9, 2022

I think it's more ergonomic if the matcher finds the .components-popover parent element for us.

I kinda disagree, this adds some confusion and reduces the control. In my mind, selecting an element and then performing an assertion on it are two separate things, and I find it counterintuitive that the matcher will attempt to find an element, although I've already provided one.

If you feel strongly about this, I can reconsider, but it would be a better idea if we make sure to include that fact somehow in the matcher name.

@jsnajdr
Copy link
Member Author

jsnajdr commented Nov 9, 2022

In my mind, selecting an element and then performing an assertion on it are two separate things

I agree, and won't mind if they are two separate functions, like:

expect( screen.findPopoverByText( 'toggle' ) ).toBePositioned();

findPopoverByText finds the .components-popover element, toBePositioned checks if a popover is positioned. Clear division of responsibilities.

I'd just prefer to have a convenient findPopoverByText helper rather than traversing the DOM myself. After all, won't ESLint complain about the test looking at DOM nodes with .parentElement directly?

@tyxla
Copy link
Member

tyxla commented Nov 9, 2022

In my mind, selecting an element and then performing an assertion on it are two separate things

I agree, and won't mind if they are two separate functions, like:

expect( screen.findPopoverByText( 'toggle' ) ).toBePositioned();

findPopoverByText finds the .components-popover element, toBePositioned checks if a popover is positioned. Clear division of responsibilities.

I'd just prefer to have a convenient findPopoverByText helper rather than traversing the DOM myself. After all, won't ESLint complain about the test looking at DOM nodes with .parentElement directly?

I think we have an agreement 👍 I'm happy to have 2 separate functions with 2 separate responsibilities - one for retrieving the popover element and one for the matcher. You're right about the parentElement, as well. Let's do that!

@jsnajdr
Copy link
Member Author

jsnajdr commented Nov 9, 2022

After upgrade to v18 and v13, respectively, the internals are apparently different and the default timeout: 1000 is enough.

Tracked down the difference to this: as I wrote above, the pseudocode for the waitFor function is:

while ( ! finished ) {
  advanceTimers(); // synchronously advance fake timers by 50ms and run their handlers
  checkCallback(); // check the `waitFor` callback after running the timers
  await Promise.resolve(); // do a microtask tick so that promise handlers can run
}

that's the same both in v12 and v13 of Testing Library. However, in both version the await Promise.resolve() is actually more like:

await advanceTimersWrapper( () => Promise.resolve() );

and it's the advanceTimersWrapper implementation that differs. In v12, it's just a passthrough function fn => fn(). In v13, it uses act: fn => act( fn ).

In v12, the entire waitFor loop wound be wrapped in await act( loop ) but v13 doesn't do that, it wraps only the parts where updates really happen.

So, in v13 we have:

await act( () => Promise.resolve() );

Without act(), this would advance just by one microtask tick and you need to run it 39 times before you catch up with computePosition. But act() does some more waiting, it's like:

async function act( fn ) {
  await fn();
  while ( hasPendingUpdates() ) {
    flushPendingUpdates();
    await new Promise( r => setImmediate( r ) );
  }
}

It runs the fn callback, and then flushes the pending updates. But running these updates might have scheduled more updates etc. So it flushes again and again until the updates queue is really empty.

Here act doesn't advance just by the one microtask tick, but it also does a setImmediate, whose callback will run only after all the microtask queues are flushed. An async loop like:

for ( let i = 0; i < 1000; i++ ) {
  await Promise.resolve();
}

will schedule 1000 microtasks, and all these microtasks have higher priority than the setImmediate callback and will run before it. That's why the computePosition async loop needs just one waitFor loop iteration to finish instead of 39.

Conclusion: this PR doesn't have a chance to run reliably with React 17. Only React 18 and @testing-library/react 13 has good enough implementations of act and waitFor to run our test successfully. The PR can't be landed separately, it must be a part of the overall React 18 upgrade.

expect(
screen.getByText( 'Toggle navigation' ).parentElement
).toBePositioned(),
{ timeout: 2000 } // It might take more than a second to position the popover.
Copy link
Member Author

Choose a reason for hiding this comment

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

One little idea: maybe an { interval: 10 } option is better. It better expresses the intent: we don't really need to wait longer, we only need to check more often 🙂

Copy link
Member

Choose a reason for hiding this comment

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

Go for it 👍 Maybe the comment needs some improvements as well, to reflect that it's not about time but it's rather about the waitFor() loops ran under the hood.

Copy link
Member Author

Choose a reason for hiding this comment

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

Implemented, including a comment update. 👍

@jsnajdr jsnajdr marked this pull request as ready for review November 9, 2022 20:20
@tyxla
Copy link
Member

tyxla commented Nov 13, 2022

I think #45726 is an excellent alternative to this PR! I'd suggest going with that, and if we ever need the positioned matcher or the popover finder, we can always reuse that code.

@jsnajdr
Copy link
Member Author

jsnajdr commented Nov 13, 2022

I'd suggest going with that, and if we ever need the positioned matcher or the popover finder, we can always reuse that code.

I think we'll need the popover finder for any test that awaits something. Like a userEvent.click() or .findByRole(). Then we can't prevent the positioning from running and we must act() on it.

@jsnajdr
Copy link
Member Author

jsnajdr commented Dec 12, 2022

Closing as the alternative solution in #45726 got merged instead.

@jsnajdr jsnajdr closed this Dec 12, 2022
@jsnajdr jsnajdr deleted the update/navigation-toggle-test-tooltip-wait branch December 12, 2022 14:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants