Skip to content

Improve robustness of responsive tabs logic to avoid flaky sb tests#3017

Open
beaesguerra wants to merge 6 commits intomainfrom
reduce-flakiness-responsive-tabs
Open

Improve robustness of responsive tabs logic to avoid flaky sb tests#3017
beaesguerra wants to merge 6 commits intomainfrom
reduce-flakiness-responsive-tabs

Conversation

@beaesguerra
Copy link
Copy Markdown
Member

@beaesguerra beaesguerra commented Apr 20, 2026

Summary:

Previously, the Storybook interactions tests run in CI would be flaky for the ResponsiveNavigationTabs Toggle Icon interaction test. The test would sometimes fail because the layout wouldn't properly switch to the dropdown layout after the icons were toggled on. This was because we would check overflow when the tabs data changes with icons, rather than when the icons are rendered. To make it more robust, we check overflow once the tabs layout is rendered and when there are any changes to it or its descendants (using a MutationObserver).

Other improvements include:

  • Triggering the checkOverflow logic only when the width changes in the ResizeObserver to prevent additional calls
  • Fixing edge cases where additional container padding/margin could cause continuous layout changes

Issue: WB-2311

Test plan:

  1. Unit tests pass
  2. SB Interaction tests for ResponsiveTabs and ResponsiveNavigationTabs continue to pass (layout should change as expected based on triggers like changing the number of tabs, changing the tab labels, zoom level, if there are icons)
  • ?path=/story/packages-tabs-responsivetabs--interactive
  • ?path=/story/packages-tabs-responsivenavigationtabs--interactive

…ld be flaky for the ResponsiveNavigationTabs Toggle Icon sb interaction test. It was because we would check overflow when the tabs data changes with icons, rather than when the icons are rendered. To make it more robust, we check overflow once the tabs layout is rendered and when there are any mutations to it or its descendants
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 20, 2026

🦋 Changeset detected

Latest commit: 9fb2465

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@khanacademy/wonder-blocks-tabs Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 20, 2026

npm Snapshot: Published

🎉 Good news!! We've packaged up the latest commit from this PR (ad886d8) and published all packages with changesets to npm.

You can install the packages in frontend by running:

./dev/tools/deploy_wonder_blocks.js --tag="PR3017"

Packages can also be installed manually by running:

pnpm add @khanacademy/wonder-blocks-<package-name>@PR3017

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 20, 2026

Size Change: +73 B (+0.06%)

Total Size: 122 kB

📦 View Changed
Filename Size Change
packages/wonder-blocks-tabs/dist/es/index.js 5.64 kB +73 B (+1.31%)
ℹ️ View Unchanged
Filename Size
packages/eslint-plugin-wonder-blocks/dist/es/index.js 622 B
packages/wonder-blocks-accordion/dist/es/index.js 3 kB
packages/wonder-blocks-announcer/dist/es/index.js 2.43 kB
packages/wonder-blocks-badge/dist/es/index.js 2.02 kB
packages/wonder-blocks-banner/dist/es/index.js 2.01 kB
packages/wonder-blocks-birthday-picker/dist/es/index.js 1.91 kB
packages/wonder-blocks-breadcrumbs/dist/es/index.js 755 B
packages/wonder-blocks-button/dist/es/index.js 4.28 kB
packages/wonder-blocks-card/dist/es/index.js 1.08 kB
packages/wonder-blocks-cell/dist/es/index.js 2.18 kB
packages/wonder-blocks-clickable/dist/es/index.js 2.6 kB
packages/wonder-blocks-core/dist/es/index.js 2.59 kB
packages/wonder-blocks-data/dist/es/index.js 5.48 kB
packages/wonder-blocks-date-picker/dist/es/index.js 8.06 kB
packages/wonder-blocks-dropdown/dist/es/index.js 19.7 kB
packages/wonder-blocks-form/dist/es/index.js 6.3 kB
packages/wonder-blocks-grid/dist/es/index.js 1.24 kB
packages/wonder-blocks-icon-button/dist/es/index.js 4.01 kB
packages/wonder-blocks-icon/dist/es/index.js 1.91 kB
packages/wonder-blocks-labeled-field/dist/es/index.js 3.47 kB
packages/wonder-blocks-layout/dist/es/index.js 1.63 kB
packages/wonder-blocks-link/dist/es/index.js 1.53 kB
packages/wonder-blocks-modal/dist/es/index.js 7.36 kB
packages/wonder-blocks-pill/dist/es/index.js 1.31 kB
packages/wonder-blocks-popover/dist/es/index.js 4.36 kB
packages/wonder-blocks-progress-spinner/dist/es/index.js 1.48 kB
packages/wonder-blocks-search-field/dist/es/index.js 1.1 kB
packages/wonder-blocks-styles/dist/es/index.js 464 B
packages/wonder-blocks-switch/dist/es/index.js 1.55 kB
packages/wonder-blocks-testing-core/dist/es/index.js 3.25 kB
packages/wonder-blocks-testing/dist/es/index.js 978 B
packages/wonder-blocks-theming/dist/es/index.js 384 B
packages/wonder-blocks-timing/dist/es/index.js 1.37 kB
packages/wonder-blocks-tokens/dist/es/index.js 5.18 kB
packages/wonder-blocks-toolbar/dist/es/index.js 906 B
packages/wonder-blocks-tooltip/dist/es/index.js 6.02 kB
packages/wonder-blocks-typography/dist/es/index.js 1.57 kB

compressed-size-action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 20, 2026

A new build was pushed to Chromatic! 🚀

https://5e1bf4b385e3fb0020b7073c-hljyvzdsmn.chromatic.com/

Chromatic results:

Metric Total
Captured snapshots 29
Tests with visual changes 0
Total stories 835
Inherited (not captured) snapshots [TurboSnap] 433
Tests on the build 462

…s of responsive logic in ResponsiveTabs and ResponsiveNavigationTabs by using a MutationObserver to trigger when we should check for overflow to decide if a dropdown or horizontal tabs layout should be used
… rendering cycle when the container has padding/margin
@beaesguerra beaesguerra force-pushed the reduce-flakiness-responsive-tabs branch from 418dfc5 to 9fb2465 Compare April 20, 2026 23:08
@beaesguerra
Copy link
Copy Markdown
Member Author

@claude review once

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

LGTM — clean, well-reasoned fix for the icon rendering race condition.

Extended reasoning...

Overview

The PR touches a single hook file () plus a changeset entry. The changes address a race condition where overflow was measured before icons had finished rendering, causing flaky Storybook interaction tests.

Changes

Three focused improvements:

  1. MutationObserver: A new wires up a on (the horizontal-tabs scrollable wrapper). This defers the overflow check until after DOM mutations settle (icons mounted, labels changed, tabs added/removed), which is exactly when the measurement is meaningful.
  2. containerWidthRef deduplication: The now skips if the container width hasn't changed, avoiding spurious re-checks on non-width resize events.
  3. tabsWidthRef gap fix: The stored threshold now accounts for any gap (padding/margin) between the container and the scrollable element, preventing the infinite layout-flip edge case described in the changeset.

Security Risks

None. This is entirely UI layout measurement logic with no auth, data handling, or external inputs involved.

Level of Scrutiny

Low. The change is contained to a single internal hook, the logic is well-commented and mechanically sound, no visual regressions (Chromatic: 0 changed snapshots), and the bundle delta is negligible (+73 B). The lifecycle is correctly managed — the observer is disconnected on cleanup and the effect re-runs appropriately when (which captures ) changes.

Other Factors

No bugs were flagged by automated review. The fix directly matches the described root cause (measuring before icons render). No outstanding reviewer comments.

Comment on lines +92 to +97
// Store the container width needed to show tabs without
// overflow. Accounts for any gap (padding/margin) between
// the container and the scrollable element.
tabsWidthRef.current =
scrollableWrapper.scrollWidth +
(container.clientWidth - scrollableWrapper.clientWidth);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This wasn't related to the test flakiness, but an improvement to avoid an edge case where the layout constantly changes between the dropdown and horizontal tabs layout when the root container has padding/margin. It now accounts for additional spacing between the container and scrollable element

Comment on lines 65 to -121
@@ -89,8 +92,12 @@ export function useResponsiveLayout(
scrollableWrapper.clientWidth;

if (hasOverflow) {
// Store the width before switching
tabsWidthRef.current = scrollableWrapper.scrollWidth;
// Store the container width needed to show tabs without
// overflow. Accounts for any gap (padding/margin) between
// the container and the scrollable element.
tabsWidthRef.current =
scrollableWrapper.scrollWidth +
(container.clientWidth - scrollableWrapper.clientWidth);
setShowDropdown(true);
}
}
@@ -106,19 +113,13 @@ export function useResponsiveLayout(
}, [showDropdown, elementWithOverflowRef, containerRef]);

React.useEffect(() => {
// This effect handles the case where the length of tabs or the tabs
// labels change. This determines whether to switch to the dropdown or
// tabs layout.
// When tabsSignature changes and dropdown is shown, reset to horizontal tabs
// layout so we can re-measure with the new content (label, icon, number of tabs, etc).
// The MutationObserver handles checking for overflow once the new content
// is rendered.
if (showDropdown) {
// When tabsSignature changes and dropdown is shown, reset to
// tabs layout so we can re-measure and see if we can switch
// back
tabsWidthRef.current = null;
setShowDropdown(false);
} else {
// When tabsSignature changes and tabs layout is shown, check
// for overflow
checkOverflow();
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Previously, when tabsSignature changed when the tabs layout is shown, we would check for overflow. Now, this check for overflow happens in the MutationObserver so that we check for overflow after the DOM has updated

// Only check for overflow if the container width has changed
if (containerWidthRef.current !== container.clientWidth) {
containerWidthRef.current = container.clientWidth;
checkOverflow();
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not directly related to the SB test flakiness, but we can reduce the number of times we check for overflow by only checking when there is a change in width in the resize observer.

Previously, checkOverflow would also be called whenever the layout changed since the height of the dropdown and horizontal tabs layout were different, so the resize observer would always trigger the checking logic.

Comment on lines +161 to +169
const mutationObserver = new MutationObserver(() => {
checkOverflow();
});

mutationObserver.observe(element, {
childList: true,
subtree: true,
characterData: true,
});
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is where we set up the mutation observer so we check for overflow when a descendant has a change.

This is what should address the SB test flakiness since it will check for overflow after the icons are rendered

@beaesguerra beaesguerra marked this pull request as ready for review April 21, 2026 20:04
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@khan-actions-bot khan-actions-bot requested a review from a team April 21, 2026 20:05
@khan-actions-bot
Copy link
Copy Markdown
Contributor

Gerald

Required Reviewers
  • @Khan/wonder-blocks for changes to .changeset/brown-nails-walk.md, packages/wonder-blocks-tabs/src/hooks/use-responsive-layout.ts

Don't want to be involved in this pull request? Comment #removeme and we won't notify you of further changes.

Copy link
Copy Markdown
Member

@marcysutton marcysutton left a comment

Choose a reason for hiding this comment

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

This looks good to me! I do wonder whether there will be noticeable performance impacts of a MutationObserver in a useEffect. But if it fixes the test flakiness and doesn't visibly thrash the layout, seems like a decent trade-off!

@beaesguerra
Copy link
Copy Markdown
Member Author

This looks good to me! I do wonder whether there will be noticeable performance impacts of a MutationObserver in a useEffect. But if it fixes the test flakiness and doesn't visibly thrash the layout, seems like a decent trade-off!

@marcysutton The MutationObserver should only be triggered when the horizontal tabs layout changes descendant nodes (adds/removes tabs or adds/removes icons in tabs) or the tab labels change!

One thing to note is that the observer is watching the horizontal tabs element, rather than the container element (which contains both horizontal tabs and dropdown tabs). Because of this, the MutationObserver is set up and taken down when the layout changes so that we don't have to watch for unrelated changes when it is using the dropdown layout.

Realistically, most of the responsive tabs use cases are static, so the mutation observer is there to handle edge cases!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants