From 1e46e58df989764642c10421e8ffc8c3ac32a483 Mon Sep 17 00:00:00 2001 From: Tarun Vashishtha Date: Wed, 28 Oct 2026 19:11:48 +0000 Subject: [PATCH 1/3] fix(Tabs): update overflow when the tab container resizes Tabs only listened for window resize events, so scroll buttons and horizontal overflow did not update when the tabs' container changed size without the window resizing (e.g. a drawer or sidebar opening). Observe the tab list with ResizeObserver via the shared getResizeObserver helper, which falls back to window resize when ResizeObserver is unavailable, and stop observing on unmount. --- .../react-core/src/components/Tabs/Tabs.tsx | 12 ++++-------- .../components/Tabs/__tests__/Tabs.test.tsx | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/react-core/src/components/Tabs/Tabs.tsx b/packages/react-core/src/components/Tabs/Tabs.tsx index cd34bd7228a..5aa6ba82518 100644 --- a/packages/react-core/src/components/Tabs/Tabs.tsx +++ b/packages/react-core/src/components/Tabs/Tabs.tsx @@ -18,6 +18,7 @@ import { TabsContextProvider } from './TabsContext'; import { OverflowTab, HorizontalOverflowPopperProps } from './OverflowTab'; import { Button } from '../Button'; import { getOUIAProps, OUIAProps, canUseDOM } from '../../helpers'; +import { getResizeObserver } from '../../helpers/resizeObserver'; import { SSRSafeIds } from '../../helpers/SSRSafeIds/SSRSafeIds'; import { GenerateId } from '../../helpers/GenerateId/GenerateId'; import linkAccentLength from '@patternfly/react-tokens/dist/esm/c_tabs_link_accent_length'; @@ -217,6 +218,7 @@ class Tabs extends Component { } scrollTimeout: NodeJS.Timeout = null; + observer: any = () => {}; static defaultProps: PickOptional = { activeKey: 0, @@ -417,9 +419,7 @@ class Tabs extends Component { componentDidMount() { if (!this.props.isVertical) { - if (canUseDOM) { - window.addEventListener('resize', this.handleResize, false); - } + this.observer = getResizeObserver(this.tabList.current, this.handleResize, true); this.direction = getLanguageDirection(this.tabList.current); // call the handle resize function to check if scroll buttons should be shown this.handleScrollButtons(); @@ -429,11 +429,7 @@ class Tabs extends Component { } componentWillUnmount() { - if (!this.props.isVertical) { - if (canUseDOM) { - window.removeEventListener('resize', this.handleResize, false); - } - } + this.observer(); clearTimeout(this.scrollTimeout); this.leftScrollButtonRef.current?.removeEventListener('transitionend', this.hideScrollButtons); } diff --git a/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx b/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx index 3aa1d847bd4..d49e9d75e69 100644 --- a/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx +++ b/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx @@ -1006,3 +1006,22 @@ test('should not render tablist aria-label or aria-labelledby when neither is pr expect(asFragment()).toMatchSnapshot(); }); + +test('observes the tab list for resize and stops observing on unmount', () => { + const observe = jest.fn(); + const unobserve = jest.fn(); + const originalResizeObserver = (window as any).ResizeObserver; + (window as any).ResizeObserver = jest.fn(() => ({ observe, unobserve })); + + const { unmount } = render( + + Tab 1} /> + + ); + const tabList = screen.getByRole('tablist'); + expect(observe).toHaveBeenCalledWith(tabList); + + unmount(); + expect(unobserve).toHaveBeenCalledWith(tabList); + (window as any).ResizeObserver = originalResizeObserver; +}); From 2e57e50cfd45344665f881ed755aba8ccb7ed7db Mon Sep 17 00:00:00 2001 From: Tarun Vashishtha Date: Thu, 29 Oct 2026 09:31:16 +0000 Subject: [PATCH 2/3] fix(Tabs): skip resize handling after unmount The resize callback runs on the next animation frame, so a resize queued just before unmount could run against the removed tab list and throw. --- packages/react-core/src/components/Tabs/Tabs.tsx | 3 +++ .../src/components/Tabs/__tests__/Tabs.test.tsx | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/react-core/src/components/Tabs/Tabs.tsx b/packages/react-core/src/components/Tabs/Tabs.tsx index 5aa6ba82518..4315ed79f39 100644 --- a/packages/react-core/src/components/Tabs/Tabs.tsx +++ b/packages/react-core/src/components/Tabs/Tabs.tsx @@ -413,6 +413,9 @@ class Tabs extends Component { }; handleResize = () => { + if (!this.tabList.current) { + return; + } this.handleScrollButtons(); this.setAccentStyles(); }; diff --git a/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx b/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx index d49e9d75e69..b020b603cdb 100644 --- a/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx +++ b/packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx @@ -1010,8 +1010,12 @@ test('should not render tablist aria-label or aria-labelledby when neither is pr test('observes the tab list for resize and stops observing on unmount', () => { const observe = jest.fn(); const unobserve = jest.fn(); + let onResize: (entries: any[]) => void; const originalResizeObserver = (window as any).ResizeObserver; - (window as any).ResizeObserver = jest.fn(() => ({ observe, unobserve })); + (window as any).ResizeObserver = jest.fn((callback) => { + onResize = callback; + return { observe, unobserve }; + }); const { unmount } = render( @@ -1021,7 +1025,12 @@ test('observes the tab list for resize and stops observing on unmount', () => { const tabList = screen.getByRole('tablist'); expect(observe).toHaveBeenCalledWith(tabList); + // a resize queued just before unmount must not run against the removed tab list + const rafSpy = jest.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0); + onResize([{}]); unmount(); expect(unobserve).toHaveBeenCalledWith(tabList); + expect(() => rafSpy.mock.calls[0][0](0)).not.toThrow(); + rafSpy.mockRestore(); (window as any).ResizeObserver = originalResizeObserver; }); From 1eb40e96f75c7774fcd7b25d13629fa1afac921d Mon Sep 17 00:00:00 2001 From: Tarun Vashishtha Date: Thu, 29 Oct 2026 10:14:43 +0000 Subject: [PATCH 3/3] chore(tests): use Etc/GMT+5 instead of EST for the test timezone Newer Node.js releases no longer resolve the legacy "EST" zone, so the test script silently ran in UTC and the Timestamp tests failed in CI. Etc/GMT+5 is the canonical name for the same fixed UTC-5 offset. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 27224dd7002..1506322cc51 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "start": "yarn build && concurrently --kill-others \"yarn watch\" \"yarn workspace @patternfly/react-docs develop\"", "start:cypress": "lerna run cypress:open", "start:demo-app": "lerna run dev --scope=demo-app-ts --stream", - "test": "TZ=EST LC_ALL=en_US.UTF8 jest packages", + "test": "TZ=Etc/GMT+5 LC_ALL=en_US.UTF8 jest packages", "test:a11y": "lerna run test:a11y --stream", "test:integration": "yarn workspace @patternfly/react-integration test:integration", "uninstall": "find . -name node_modules -type d | xargs rm -rf",