From 84c6002975ca108bfb956c9903adede87997093e Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:24:05 -0400 Subject: [PATCH] fix(react): use exact width for Sticky placeholder Sticky computed its placeholder width as scrollWidth + (offsetWidth - clientWidth). All three DOM properties are rounded to integers while the true layout width can be fractional (typically under browser zoom), so the placeholder could end up 1-2px wider than the content, making ScrollablePane show a phantom horizontal scrollbar that disappears after scrolling back to the top. When the content has no genuine horizontal overflow (scrollWidth <= clientWidth), use the exact fractional getBoundingClientRect().width instead. Content that really overflows keeps the previous computation. Red-first test locks both branches: fractional width is used when nothing overflows, the legacy sum when it does. Sticky and ScrollablePane suites green with no snapshot churn. Fixes #29383 --- ...-b08aa884-e1b1-4f54-a57f-be5896dd8655.json | 7 ++ .../src/components/Sticky/Sticky.test.tsx | 74 +++++++++++++++++++ .../react/src/components/Sticky/Sticky.tsx | 16 +++- 3 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 change/@fluentui-react-b08aa884-e1b1-4f54-a57f-be5896dd8655.json diff --git a/change/@fluentui-react-b08aa884-e1b1-4f54-a57f-be5896dd8655.json b/change/@fluentui-react-b08aa884-e1b1-4f54-a57f-be5896dd8655.json new file mode 100644 index 0000000000000..423b24af2c3d8 --- /dev/null +++ b/change/@fluentui-react-b08aa884-e1b1-4f54-a57f-be5896dd8655.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix(Sticky): compute placeholder width from getBoundingClientRect when content does not overflow horizontally, preventing a phantom horizontal scrollbar under browser zoom", + "packageName": "@fluentui/react", + "email": "144495202+AKnassa@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/react/src/components/Sticky/Sticky.test.tsx b/packages/react/src/components/Sticky/Sticky.test.tsx index 920bef9a904e7..846b3c38342b1 100644 --- a/packages/react/src/components/Sticky/Sticky.test.tsx +++ b/packages/react/src/components/Sticky/Sticky.test.tsx @@ -1,5 +1,10 @@ +import * as React from 'react'; +import { act, render } from '@testing-library/react'; import { Sticky } from './Sticky'; +import { StickyPositionType } from './Sticky.types'; +import { ScrollablePaneContext } from '../ScrollablePane/ScrollablePane.types'; import { isConformant } from '../../common/isConformant'; +import type { IScrollablePaneContext } from '../ScrollablePane/ScrollablePane.types'; describe('Sticky', () => { isConformant({ @@ -10,3 +15,72 @@ describe('Sticky', () => { disabledTests: ['component-handles-ref', 'component-has-root-ref', 'component-handles-classname'], }); }); + +describe('Sticky placeholder width', () => { + const createPaneContext = (): IScrollablePaneContext => ({ + scrollablePane: { + subscribe: jest.fn(), + unsubscribe: jest.fn(), + addSticky: jest.fn(), + removeSticky: jest.fn(), + updateStickyRefHeights: jest.fn(), + sortSticky: jest.fn(), + notifySubscribers: jest.fn(), + syncScrollSticky: jest.fn(), + }, + window: undefined, + }); + + const mockLayout = ( + element: Element, + metrics: { scrollWidth: number; clientWidth: number; offsetWidth: number; rectWidth: number }, + ): void => { + Object.defineProperty(element, 'scrollWidth', { configurable: true, value: metrics.scrollWidth }); + Object.defineProperty(element, 'clientWidth', { configurable: true, value: metrics.clientWidth }); + Object.defineProperty(element, 'offsetWidth', { configurable: true, value: metrics.offsetWidth }); + jest.spyOn(element, 'getBoundingClientRect').mockReturnValue({ width: metrics.rectWidth } as DOMRect); + }; + + const renderSticky = (): Sticky => { + const stickyRef = React.createRef(); + render( + + +
content
+
+
, + ); + return stickyRef.current!; + }; + + it('uses the exact bounding-rect width when content does not overflow horizontally', () => { + const sticky = renderSticky(); + const firstChild = sticky.nonStickyContent!.firstElementChild!; + // Fractional layout (e.g. browser zoom): true border-box width is 701.6px, but the rounded + // integer metrics report scrollWidth 700, clientWidth 700 and offsetWidth 702. + mockLayout(firstChild, { scrollWidth: 700, clientWidth: 700, offsetWidth: 702, rectWidth: 701.6 }); + Object.defineProperty(sticky.nonStickyContent!, 'offsetHeight', { configurable: true, value: 30 }); + + act(() => { + sticky.setState({ isStickyTop: true }); + }); + + // The placeholder must not exceed the true content width, otherwise ScrollablePane shows a + // phantom horizontal scrollbar (issue #29383). + expect(sticky.placeholder!.style.width).toBe('701.6px'); + expect(sticky.placeholder!.style.height).toBe('30px'); + }); + + it('keeps the scrollWidth-based width when content genuinely overflows horizontally', () => { + const sticky = renderSticky(); + const firstChild = sticky.nonStickyContent!.firstElementChild!; + mockLayout(firstChild, { scrollWidth: 800, clientWidth: 700, offsetWidth: 702, rectWidth: 701.6 }); + + act(() => { + sticky.setState({ isStickyTop: true }); + }); + + // scrollWidth + (offsetWidth - clientWidth) = 800 + (702 - 700) + expect(sticky.placeholder!.style.width).toBe('802px'); + }); +}); diff --git a/packages/react/src/components/Sticky/Sticky.tsx b/packages/react/src/components/Sticky/Sticky.tsx index be081a027d5c1..613f2c9b5edde 100644 --- a/packages/react/src/components/Sticky/Sticky.tsx +++ b/packages/react/src/components/Sticky/Sticky.tsx @@ -239,6 +239,7 @@ export class Sticky extends React.Component { // in the container, to get a horizontal scrollbar & be able to view the complete content of sticky component. if (this.nonStickyContent && this.nonStickyContent.firstElementChild) { height = this.nonStickyContent.offsetHeight; + const firstElementChild = this.nonStickyContent.firstElementChild; // What value should be substituted for placeholder width? // Assumptions: // 1. Content inside should always be wrapped in a single div. @@ -247,10 +248,17 @@ export class Sticky extends React.Component { // 3. scrollWidth of a parent is greater than or equal to max of scrollWidths of its children, and same holds // for children. // placeholder width should be computed in the best possible way to prevent overscroll/underscroll. - width = - this.nonStickyContent.firstElementChild.scrollWidth + - ((this.nonStickyContent.firstElementChild as HTMLElement).offsetWidth - - this.nonStickyContent.firstElementChild.clientWidth); + if (firstElementChild.scrollWidth <= firstElementChild.clientWidth) { + // The content does not overflow horizontally. scrollWidth, offsetWidth and clientWidth are all rounded to + // integers while the true layout width may be fractional (e.g. under browser zoom), so the rounded sum in + // the else-branch can be up to 1px wider or narrower than the content really is, which toggles a phantom + // horizontal scrollbar in ScrollablePane. Use the exact fractional border-box width instead. + width = firstElementChild.getBoundingClientRect().width; + } else { + width = + firstElementChild.scrollWidth + + ((firstElementChild as HTMLElement).offsetWidth - firstElementChild.clientWidth); + } } return { height,