From d7247156fdf6b580768213c6f1096d6fb35141a3 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Fri, 12 Jun 2026 13:28:35 +0200 Subject: [PATCH 1/3] fix: Fix container-query driven layout oscillation caused by scrollbars --- pages/container-query-scrollbar.tsx | 46 ++++++++++++++++ src/internal/__tests__/breakpoints.test.ts | 52 +++++++++++++++++++ src/internal/breakpoints.ts | 35 +++++++++++-- .../use-container-breakpoints.ts | 10 ++-- 4 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 pages/container-query-scrollbar.tsx diff --git a/pages/container-query-scrollbar.tsx b/pages/container-query-scrollbar.tsx new file mode 100644 index 0000000000..18cc95a306 --- /dev/null +++ b/pages/container-query-scrollbar.tsx @@ -0,0 +1,46 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +import Grid from '~components/grid'; + +const ARTICLE = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut ' + + 'labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco ' + + 'laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in ' + + 'voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat ' + + 'non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis ' + + 'unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, ' + + 'eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt ' + + 'explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia ' + + 'consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, ' + + 'qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius ' + + 'modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.'; + +export default function GridScrollbarOscillationSimplePage() { + return ( + <> +

Grid scrollbar oscillation (AWSUI-62065)

+

+ Slowly resize the window width around 1120px. The window should be short enough that vertical + scrollbars just start to appear on the wider breakpoint. Side by side, the text column is squeezed and wraps + tall enough to show a scrollbar; stacked, it gets the full width, becomes shorter, and the scrollbar disappears + — flipping the layout back and forth. +

+ + +
+ {ARTICLE} +
+
+ Summary — a short side column +
+
+ + ); +} diff --git a/src/internal/__tests__/breakpoints.test.ts b/src/internal/__tests__/breakpoints.test.ts index c821504196..2fb902fb11 100644 --- a/src/internal/__tests__/breakpoints.test.ts +++ b/src/internal/__tests__/breakpoints.test.ts @@ -26,6 +26,58 @@ describe('getMatchingBreakpoint', () => { it('returns "default" if the filter is an empty array', () => { expect(getMatchingBreakpoint(1000, [])).toBe('default'); }); + + describe('previousBreakpoint', () => { + // xs boundary is 688px. The dead-band is +/- 20px around it: [668, 708]. + it('does not switch down when the width shrinks slightly below the boundary', () => { + // Width drops to 680 (e.g. a 17px scrollbar appeared) but we were already at "xs". + expect(getMatchingBreakpoint(680, undefined, 'xs')).toBe('xs'); + }); + + it('does not switch up when the width grows slightly above the boundary', () => { + // Width rises to 695 but we were at "xxs"; still within the dead-band. + expect(getMatchingBreakpoint(695, undefined, 'xxs')).toBe('xxs'); + }); + + it('switches down once the width clears the boundary by more than the margin', () => { + expect(getMatchingBreakpoint(665, undefined, 'xs')).toBe('xxs'); + }); + + it('switches up once the width clears the boundary by more than the margin', () => { + expect(getMatchingBreakpoint(710, undefined, 'xxs')).toBe('xs'); + }); + + it('uses nominal thresholds when no previous breakpoint is provided', () => { + expect(getMatchingBreakpoint(680)).toBe('xxs'); + expect(getMatchingBreakpoint(695)).toBe('xs'); + expect(getMatchingBreakpoint(680, undefined, null)).toBe('xxs'); + }); + + it('prevents oscillation across a scrollbar-width toggle near a boundary', () => { + const scrollbar = 17; + // Start just above the xs boundary with no scrollbar. + const wideWidth = 695; + const narrowWidth = wideWidth - scrollbar; // 678, scrollbar present + + // First (initial) resolution uses nominal thresholds -> "xs". + let breakpoint = getMatchingBreakpoint(wideWidth); + expect(breakpoint).toBe('xs'); + + // Scrollbar appears, width shrinks: stickiness keeps us at "xs". + breakpoint = getMatchingBreakpoint(narrowWidth, undefined, breakpoint); + expect(breakpoint).toBe('xs'); + + // Scrollbar disappears, width grows back: still "xs". Stable, no flip-flop. + breakpoint = getMatchingBreakpoint(wideWidth, undefined, breakpoint); + expect(breakpoint).toBe('xs'); + }); + + it('applies stickiness together with a breakpoint filter', () => { + // "xs" filtered out -> nominal would fall back to "xxs"; with previous "xs" stays sticky is + // not possible (filtered), so it resolves within the allowed set. + expect(getMatchingBreakpoint(680, ['default', 'xxs', 's'], 'xxs')).toBe('xxs'); + }); + }); }); describe('matchBreakpointMapping', () => { diff --git a/src/internal/breakpoints.ts b/src/internal/breakpoints.ts index 62c3338b0f..130ebb6293 100644 --- a/src/internal/breakpoints.ts +++ b/src/internal/breakpoints.ts @@ -30,15 +30,44 @@ export function matchBreakpointMapping(subset: Partial> return null; } +/** + * The width (in px) by which a measured container can switch into a breakpoint boundary without + * actually triggering that breakpoint switch. This makes each breakpoint edge "sticky", making + * you have to travel a little further into the breakpoint to "lose" the previous one. + * + * When a JS-resolved breakpoint sits within a scrollbar-width of a boundary, switching the layout + * can grow/shrink the page enough to toggle the viewport scrollbar, which in turn changes the + * measured width and flips the breakpoint back — an infinite layout loop (see AWSUI-62065). + */ +const BREAKPOINT_SWITCH_OFFSET = 20; + /** * Get the named breakpoint for a provided width, optionally filtering to a subset of breakpoints. */ export function getMatchingBreakpoint( width: number, - breakpointFilter?: T + breakpointFilter?: T, + previousBreakpoint?: Breakpoint | null ): T[number] | 'default' { - for (const [breakpoint, breakpointWidth] of BREAKPOINT_MAPPING) { - if (width > breakpointWidth && (!breakpointFilter || breakpointFilter.indexOf(breakpoint) !== -1)) { + const previousBreakpointIndex = + previousBreakpoint === undefined || previousBreakpoint === null + ? -1 + : BREAKPOINTS_DESCENDING.indexOf(previousBreakpoint); + + for (let i = 0; i < BREAKPOINT_MAPPING.length; i++) { + const [breakpoint, breakpointWidth] = BREAKPOINT_MAPPING[i]; + if (breakpointFilter && breakpointFilter.indexOf(breakpoint) === -1) { + continue; + } + // Based on BREAKPOINT_SWITCH_OFFSET, we either shrink or grow the breakpoint value we match against + // depending on whether the previous breakpoint was above or below the matched one. This enables the + // "sticky" behavior that makes the user have to resize the element further into a breakpoint boundary + // to actually switch the breakpoint. + let stickyBreakpointWidth = breakpointWidth; + if (previousBreakpointIndex !== -1) { + stickyBreakpointWidth += previousBreakpointIndex <= i ? -BREAKPOINT_SWITCH_OFFSET : BREAKPOINT_SWITCH_OFFSET; + } + if (width > stickyBreakpointWidth) { return breakpoint; } } diff --git a/src/internal/hooks/container-queries/use-container-breakpoints.ts b/src/internal/hooks/container-queries/use-container-breakpoints.ts index 18c5d66b6e..75a7e129a3 100644 --- a/src/internal/hooks/container-queries/use-container-breakpoints.ts +++ b/src/internal/hooks/container-queries/use-container-breakpoints.ts @@ -16,9 +16,9 @@ import { Breakpoint, getMatchingBreakpoint } from '../../breakpoints'; export function useContainerBreakpoints(triggers?: T) { // triggers.join() gives us a stable value to use for the dependencies argument const triggersDep = triggers?.join(); - // eslint-disable-next-line react-hooks/exhaustive-deps - return useContainerQuery(rect => getMatchingBreakpoint(rect.contentBoxWidth, triggers), [triggersDep]) as [ - 'default' | T[number] | null, - React.Ref, - ]; + return useContainerQuery( + (rect, previousBreakpoint) => getMatchingBreakpoint(rect.contentBoxWidth, triggers, previousBreakpoint), + // eslint-disable-next-line react-hooks/exhaustive-deps + [triggersDep] + ) as ['default' | T[number] | null, React.Ref]; } From 9e2618dfb1cc3e835cd72d2f6f35c0b0007a3fcd Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Tue, 16 Jun 2026 05:45:38 +0200 Subject: [PATCH 2/3] Rename dev page. --- ...ner-query-scrollbar.tsx => container-query-scrollbar.page.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pages/{container-query-scrollbar.tsx => container-query-scrollbar.page.tsx} (100%) diff --git a/pages/container-query-scrollbar.tsx b/pages/container-query-scrollbar.page.tsx similarity index 100% rename from pages/container-query-scrollbar.tsx rename to pages/container-query-scrollbar.page.tsx From 75495461a777daf37e70b7c630b308003dae1d81 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Tue, 16 Jun 2026 13:58:51 +0200 Subject: [PATCH 3/3] Remove background colors from generated test page to pass a11y. --- pages/container-query-scrollbar.page.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pages/container-query-scrollbar.page.tsx b/pages/container-query-scrollbar.page.tsx index 18cc95a306..12b61f7a4f 100644 --- a/pages/container-query-scrollbar.page.tsx +++ b/pages/container-query-scrollbar.page.tsx @@ -34,12 +34,8 @@ export default function GridScrollbarOscillationSimplePage() { { colspan: { default: 12, m: 6 } }, // short side column ]} > -
- {ARTICLE} -
-
- Summary — a short side column -
+
{ARTICLE}
+
Summary — a short side column
);