diff --git a/pages/container-query-scrollbar.page.tsx b/pages/container-query-scrollbar.page.tsx
new file mode 100644
index 0000000000..12b61f7a4f
--- /dev/null
+++ b/pages/container-query-scrollbar.page.tsx
@@ -0,0 +1,42 @@
+// 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..3ce11c3e7b 100644
--- a/src/internal/__tests__/breakpoints.test.ts
+++ b/src/internal/__tests__/breakpoints.test.ts
@@ -1,6 +1,11 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { getBreakpointValue, getMatchingBreakpoint, matchBreakpointMapping } from '../breakpoints';
+import { browserScrollbarSize } from '../utils/browser-scrollbar-size';
+
+jest.mock('../utils/browser-scrollbar-size', () => ({
+ browserScrollbarSize: jest.fn(() => ({ width: 0 })),
+}));
describe('getMatchingBreakpoint', () => {
it('returns the correct breakpoint value', () => {
@@ -26,6 +31,66 @@ describe('getMatchingBreakpoint', () => {
it('returns "default" if the filter is an empty array', () => {
expect(getMatchingBreakpoint(1000, [])).toBe('default');
});
+
+ describe('previousBreakpoint', () => {
+ beforeEach(() => {
+ jest.mocked(browserScrollbarSize).mockReturnValue({ width: 20, height: -1 });
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ // xs boundary is 688px. Assuming a dead-band of +/- 20px: [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..15ae80f748 100644
--- a/src/internal/breakpoints.ts
+++ b/src/internal/breakpoints.ts
@@ -1,5 +1,8 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
+
+import { browserScrollbarSize } from './utils/browser-scrollbar-size';
+
export type Breakpoint = 'default' | 'xxs' | 'xs' | 's' | 'm' | 'l' | 'xl';
const BREAKPOINT_MAPPING: [Breakpoint, number][] = [
@@ -35,10 +38,33 @@ export function matchBreakpointMapping(subset: Partial>
*/
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)) {
+ // 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 breakpointSwitchOffset = browserScrollbarSize().width;
+
+ 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 breakpointSwitchOffset, 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 ? -breakpointSwitchOffset : breakpointSwitchOffset;
+ }
+ 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];
}