diff --git a/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx b/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx
index a4cbe37aa14..bfb5efa34fe 100644
--- a/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx
+++ b/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx
@@ -1,5 +1,5 @@
import Breadcrumbs from '..'
-import {render as HTMLRender, screen, waitFor, within} from '@testing-library/react'
+import {act, render as HTMLRender, screen, waitFor, within} from '@testing-library/react'
import {describe, expect, it, vi} from 'vitest'
import userEvent from '@testing-library/user-event'
import {FeatureFlags} from '../../FeatureFlags'
@@ -227,30 +227,31 @@ describe('Breadcrumbs', () => {
)
expect(resizeCallback).toBeDefined()
+ const resize = resizeCallback!
// Initially should show overflow menu for >5 items
expect(screen.getByRole('button', {name: /more breadcrumb items/i})).toBeInTheDocument()
// Simulate a wide container resize
- if (resizeCallback) {
- resizeCallback([
+ act(() => {
+ resize([
{
contentRect: {width: 800, height: 40},
} as ResizeObserverEntry,
])
- }
+ })
// Should still have overflow menu for 6 items (>5 rule)
expect(screen.getByRole('button', {name: /more breadcrumb items/i})).toBeInTheDocument()
// Simulate a narrow container resize
- if (resizeCallback) {
- resizeCallback([
+ act(() => {
+ resize([
{
contentRect: {width: 250, height: 40},
} as ResizeObserverEntry,
])
- }
+ })
// Should maintain overflow menu for narrow container
expect(screen.getByRole('button', {name: /more breadcrumb items/i})).toBeInTheDocument()
@@ -287,6 +288,7 @@ describe('Breadcrumbs', () => {
)
expect(resizeCallback).toBeDefined()
+ const resize = resizeCallback!
// Initially should show overflow menu for >5 items
const menuButton = screen.getByRole('button', {name: /more breadcrumb items/i})
@@ -311,29 +313,29 @@ describe('Breadcrumbs', () => {
// Close menu by clicking outside
await user.click(document.body)
await waitFor(() => {
- expect
+ expect(menuButton).toHaveAttribute('aria-expanded', 'false')
})
// Simulate a very narrow container resize that would affect overflow calculation
- if (resizeCallback) {
- resizeCallback([
+ act(() => {
+ resize([
{
contentRect: {width: 200, height: 40},
} as ResizeObserverEntry,
])
- }
+ })
// Menu button should still be present
expect(screen.getByRole('button', {name: /more breadcrumb items/i})).toBeInTheDocument()
// Simulate a very wide container resize
- if (resizeCallback) {
- resizeCallback([
+ act(() => {
+ resize([
{
contentRect: {width: 1200, height: 40},
} as ResizeObserverEntry,
])
- }
+ })
// Menu button should still be present (7 items > 5)
expect(screen.getByRole('button', {name: /more breadcrumb items/i})).toBeInTheDocument()
@@ -490,7 +492,9 @@ describe('Breadcrumbs', () => {
const menuButton = screen.getByRole('button', {name: /more breadcrumb items/i})
// Focus the menu button
- menuButton.focus()
+ act(() => {
+ menuButton.focus()
+ })
expect(menuButton).toHaveFocus()
// Open menu with Enter key
@@ -505,7 +509,9 @@ describe('Breadcrumbs', () => {
await user.keyboard('{Escape}')
// Verify focus returns to button
- expect(menuButton).toHaveFocus()
+ await waitFor(() => {
+ expect(menuButton).toHaveFocus()
+ })
})
})
diff --git a/packages/react/src/CircleBadge/CircleBadge.tsx b/packages/react/src/CircleBadge/CircleBadge.tsx
index 0d106c78d50..388670c5de3 100644
--- a/packages/react/src/CircleBadge/CircleBadge.tsx
+++ b/packages/react/src/CircleBadge/CircleBadge.tsx
@@ -28,12 +28,19 @@ const sizeStyles = ({size, variant = 'medium'}: CircleBadgeProps
({as: Component = 'div', ...props}: CircleBadgeProps) => (
+const CircleBadge = ({
+ as: Component = 'div',
+ className,
+ inline,
+ size,
+ variant,
+ ...props
+}: CircleBadgeProps) => (
)
diff --git a/packages/react/src/CircleBadge/__snapshots__/CircleBadge.test.tsx.snap b/packages/react/src/CircleBadge/__snapshots__/CircleBadge.test.tsx.snap
index a8175b56391..746eb2d17e4 100644
--- a/packages/react/src/CircleBadge/__snapshots__/CircleBadge.test.tsx.snap
+++ b/packages/react/src/CircleBadge/__snapshots__/CircleBadge.test.tsx.snap
@@ -12,15 +12,12 @@ exports[`CircleBadge > respects the variant prop 1`] = `
`;
exports[`CircleBadge > uses the size prop to override the variant prop 1`] = `
`;
diff --git a/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx b/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx
index 42ad61a00cb..23992c6020e 100644
--- a/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx
+++ b/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx
@@ -1,4 +1,5 @@
-import {render, fireEvent} from '@testing-library/react'
+import {act, render, waitFor} from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
import {describe, it, expect, vi} from 'vitest'
import type React from 'react'
import {useCallback, useRef, useState} from 'react'
@@ -117,24 +118,36 @@ const LoadingStates = ({
)
}
+const waitForDialogLayout = async (getByRole: ReturnType['getByRole']) => {
+ await waitFor(() => {
+ expect(getByRole('alertdialog')).toHaveAttribute('data-footer-button-layout')
+ })
+ await act(async () => {
+ await new Promise(resolve => setTimeout(resolve, 0))
+ })
+}
+
describe('ConfirmationDialog', () => {
it('focuses the primary action when opened and the confirmButtonType is not set', async () => {
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
expect(getByRole('button', {name: 'Primary'})).toEqual(document.activeElement)
expect(getByRole('button', {name: 'Secondary'})).not.toEqual(document.activeElement)
})
it('focuses the primary action when opened and the confirmButtonType is not danger', async () => {
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
expect(getByRole('button', {name: 'Primary'})).toEqual(document.activeElement)
expect(getByRole('button', {name: 'Secondary'})).not.toEqual(document.activeElement)
})
it('focuses the secondary action when opened and the confirmButtonType is danger', async () => {
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
expect(getByRole('button', {name: 'Primary'})).not.toEqual(document.activeElement)
expect(getByRole('button', {name: 'Secondary'})).toEqual(document.activeElement)
})
@@ -142,8 +155,9 @@ describe('ConfirmationDialog', () => {
it('supports nested `focusTrap`s', async () => {
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show menu'))
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show menu'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
expect(getByRole('button', {name: 'Primary'})).toEqual(document.activeElement)
expect(getByRole('button', {name: 'Secondary'})).not.toEqual(document.activeElement)
@@ -153,7 +167,8 @@ describe('ConfirmationDialog', () => {
const testClassName = 'test-class-name'
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
const dialog = getByRole('alertdialog')
expect(dialog.classList.contains(testClassName)).toBe(true)
@@ -162,7 +177,8 @@ describe('ConfirmationDialog', () => {
it('accepts a width prop', async () => {
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
const dialog = getByRole('alertdialog')
expect(dialog.getAttribute('data-width')).toBe('large')
@@ -171,7 +187,8 @@ describe('ConfirmationDialog', () => {
it('accepts a height prop', async () => {
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
const dialog = getByRole('alertdialog')
expect(dialog.getAttribute('data-height')).toBe('small')
@@ -180,7 +197,8 @@ describe('ConfirmationDialog', () => {
it('focuses the confirm button even when dangerous if initialButtonFocus is confirm', async () => {
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
expect(getByRole('button', {name: 'Primary'})).toEqual(document.activeElement)
expect(getByRole('button', {name: 'Secondary'})).not.toEqual(document.activeElement)
@@ -190,7 +208,8 @@ describe('ConfirmationDialog', () => {
it('applies loading state to confirm button when confirmButtonLoading is true', async () => {
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
const confirmButton = getByRole('button', {name: 'Delete'})
const cancelButton = getByRole('button', {name: 'Cancel'})
@@ -202,7 +221,8 @@ describe('ConfirmationDialog', () => {
it('applies loading state to cancel button when cancelButtonLoading is true', async () => {
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
const confirmButton = getByRole('button', {name: 'Delete'})
const cancelButton = getByRole('button', {name: 'Cancel'})
@@ -214,7 +234,8 @@ describe('ConfirmationDialog', () => {
it('applies loading state to both buttons when both loading props are true', async () => {
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
const confirmButton = getByRole('button', {name: 'Delete'})
const cancelButton = getByRole('button', {name: 'Cancel'})
@@ -239,9 +260,11 @@ describe('ConfirmationDialog', () => {
,
)
+ await waitForDialogLayout(getByRole)
+
const confirmButton = getByRole('button', {name: 'Delete'})
- fireEvent.click(confirmButton)
+ await userEvent.click(confirmButton)
// onClose should not be called when button is loading
expect(mockOnClose).not.toHaveBeenCalled()
@@ -250,7 +273,8 @@ describe('ConfirmationDialog', () => {
it('shows loading spinner in confirm button when loading', async () => {
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
const confirmButton = getByRole('button', {name: 'Delete'})
@@ -258,12 +282,14 @@ describe('ConfirmationDialog', () => {
const spinner = confirmButton.querySelector('svg')
expect(spinner).toBeInTheDocument()
expect(confirmButton.contains(spinner)).toBe(true)
+ await waitForDialogLayout(getByRole)
})
it('shows loading spinner in cancel button when loading', async () => {
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
const cancelButton = getByRole('button', {name: 'Cancel'})
@@ -276,7 +302,8 @@ describe('ConfirmationDialog', () => {
it('maintains proper focus management when confirm button is loading', async () => {
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
const cancelButton = getByRole('button', {name: 'Cancel'})
@@ -287,7 +314,8 @@ describe('ConfirmationDialog', () => {
it('does not apply loading state when loading props are false', async () => {
const {getByText, getByRole} = render()
- fireEvent.click(getByText('Show dialog'))
+ await userEvent.click(getByText('Show dialog'))
+ await waitForDialogLayout(getByRole)
const confirmButton = getByRole('button', {name: 'Delete'})
const cancelButton = getByRole('button', {name: 'Cancel'})
diff --git a/packages/react/src/DataTable/__tests__/Table.test.tsx b/packages/react/src/DataTable/__tests__/Table.test.tsx
index d7c5d126472..180bfdeb863 100644
--- a/packages/react/src/DataTable/__tests__/Table.test.tsx
+++ b/packages/react/src/DataTable/__tests__/Table.test.tsx
@@ -219,7 +219,18 @@ describe('Table', () => {
})
describe('Table.Cell', () => {
- implementsClassName(Table.Cell, classes.TableCell)
+ implementsClassName(
+ props => (
+
+ ),
+ classes.TableCell,
+ )
it('should set the element to a when `scope` is defined', () => {
render(
diff --git a/packages/react/src/Dialog/Dialog.tsx b/packages/react/src/Dialog/Dialog.tsx
index 449b9659292..e7f2bdcba9a 100644
--- a/packages/react/src/Dialog/Dialog.tsx
+++ b/packages/react/src/Dialog/Dialog.tsx
@@ -283,7 +283,6 @@ const _Dialog = React.forwardRef(false)
- const [footerButtonLayout, setFooterButtonLayout] = useState<'scroll' | 'wrap'>('wrap')
const defaultedProps = {...props, title, subtitle, role, dialogLabelId, dialogDescriptionId}
const onBackdropClick = useCallback(
(e: SyntheticEvent) => {
@@ -361,8 +360,6 @@ const _Dialog = React.forwardRef= MIN_BODY_HEIGHT ? 'wrap' : 'scroll'
dialogElement.setAttribute('data-footer-button-layout', newLayout)
-
- setFooterButtonLayout(newLayout)
}, [hasFooter])
useResizeObserver(updateFooterButtonLayout, backdropRef)
@@ -400,7 +397,7 @@ const _Dialog = React.forwardRef
diff --git a/packages/react/src/LabelGroup/LabelGroup.test.tsx b/packages/react/src/LabelGroup/LabelGroup.test.tsx
index e9ef4ad7122..e2ec24818a6 100644
--- a/packages/react/src/LabelGroup/LabelGroup.test.tsx
+++ b/packages/react/src/LabelGroup/LabelGroup.test.tsx
@@ -1,5 +1,5 @@
import type React from 'react'
-import {render, waitFor} from '@testing-library/react'
+import {act, render, waitFor} from '@testing-library/react'
import {describe, it, expect, vi} from 'vitest'
import BaseStyles from '../BaseStyles'
import {LabelGroup, Label} from '..'
@@ -15,6 +15,20 @@ const AutoTruncateContainer: React.FC {
+ const originalResizeObserver = window.ResizeObserver
+ window.ResizeObserver = vi.fn(function () {
+ return {
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+ }
+ }) as unknown as typeof ResizeObserver
+ return () => {
+ window.ResizeObserver = originalResizeObserver
+ }
+}
+
describe('LabelGroup', () => {
implementsClassName(LabelGroup, classes.Container)
@@ -78,7 +92,9 @@ describe('LabelGroup', () => {
it('should expand all tokens into an overlay when overflowStyle="overlay"', async () => {
const user = userEvent.setup()
- const {getByLabelText, getByText} = render(
+ const restoreResizeObserver = mockResizeObserver()
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+ const {getByLabelText, getByText, unmount} = render(
@@ -94,6 +110,16 @@ describe('LabelGroup', () => {
await waitFor(() => getByLabelText('Close'))
expect(document.activeElement).toBe(getByLabelText('Close'))
+
+ await user.click(getByLabelText('Close'))
+ await waitFor(() => {
+ expect(getByText('+2').closest('button')).toHaveFocus()
+ })
+ act(() => {
+ unmount()
+ })
+ consoleError.mockRestore()
+ restoreResizeObserver()
})
it('should expand all tokens in place when overflowStyle="inline"', async () => {
diff --git a/packages/react/src/PageLayout/usePaneWidth.test.ts b/packages/react/src/PageLayout/usePaneWidth.test.ts
index 5ca90cb5995..133746c127c 100644
--- a/packages/react/src/PageLayout/usePaneWidth.test.ts
+++ b/packages/react/src/PageLayout/usePaneWidth.test.ts
@@ -717,12 +717,11 @@ describe('usePaneWidth', () => {
// Shrink viewport (crosses 1280 breakpoint, diff switches to 511)
vi.stubGlobal('innerWidth', 1000)
- // Fire resize - with throttle, first update happens immediately (if THROTTLE_MS passed)
- window.dispatchEvent(new Event('resize'))
-
// Since Date.now() starts at 0 and lastUpdateTime is 0, first update should happen immediately
// but it's in rAF, so we need to advance through rAF
await act(async () => {
+ // Fire resize - with throttle, first update happens immediately (if THROTTLE_MS passed)
+ window.dispatchEvent(new Event('resize'))
await vi.runAllTimersAsync()
})
@@ -753,11 +752,10 @@ describe('usePaneWidth', () => {
// Shrink viewport (crosses 1280 breakpoint, diff switches to 511)
vi.stubGlobal('innerWidth', 900)
- // Fire resize - with throttle, update happens via rAF
- window.dispatchEvent(new Event('resize'))
-
// Wait for rAF to complete
await act(async () => {
+ // Fire resize - with throttle, update happens via rAF
+ window.dispatchEvent(new Event('resize'))
await vi.runAllTimersAsync()
})
@@ -787,12 +785,12 @@ describe('usePaneWidth', () => {
// Clear mount calls
setPropertySpy.mockClear()
- // Fire resize events rapidly
vi.stubGlobal('innerWidth', 1100)
- window.dispatchEvent(new Event('resize'))
// With throttle, CSS should update immediately or via rAF
await act(async () => {
+ // Fire resize events rapidly
+ window.dispatchEvent(new Event('resize'))
await vi.runAllTimersAsync()
})
@@ -802,14 +800,13 @@ describe('usePaneWidth', () => {
// Clear for next test
setPropertySpy.mockClear()
- // Fire more resize events rapidly (within throttle window)
- for (let i = 0; i < 3; i++) {
- vi.stubGlobal('innerWidth', 1000 - i * 50)
- window.dispatchEvent(new Event('resize'))
- }
-
// Should schedule via rAF
await act(async () => {
+ // Fire more resize events rapidly (within throttle window)
+ for (let i = 0; i < 3; i++) {
+ vi.stubGlobal('innerWidth', 1000 - i * 50)
+ window.dispatchEvent(new Event('resize'))
+ }
await vi.runAllTimersAsync()
})
@@ -840,10 +837,10 @@ describe('usePaneWidth', () => {
// Shrink viewport (crosses 1280 breakpoint, diff switches to 511)
vi.stubGlobal('innerWidth', 800)
- window.dispatchEvent(new Event('resize'))
// After throttle (via rAF), state updated via startTransition
await act(async () => {
+ window.dispatchEvent(new Event('resize'))
await vi.runAllTimersAsync()
})
@@ -915,7 +912,9 @@ describe('usePaneWidth', () => {
// Fire resize
vi.stubGlobal('innerWidth', 1000)
- window.dispatchEvent(new Event('resize'))
+ act(() => {
+ window.dispatchEvent(new Event('resize'))
+ })
// Attribute should be applied immediately on first resize
expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(true)
@@ -923,7 +922,9 @@ describe('usePaneWidth', () => {
// Fire another resize event immediately (simulating continuous resize)
vi.stubGlobal('innerWidth', 900)
- window.dispatchEvent(new Event('resize'))
+ act(() => {
+ window.dispatchEvent(new Event('resize'))
+ })
// Attribute should still be present (containment stays on during continuous resize)
expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(true)
@@ -958,7 +959,9 @@ describe('usePaneWidth', () => {
// Fire resize
vi.stubGlobal('innerWidth', 1000)
- window.dispatchEvent(new Event('resize'))
+ act(() => {
+ window.dispatchEvent(new Event('resize'))
+ })
// Attribute should be applied
expect(refs.paneRef.current?.hasAttribute('data-dragging')).toBe(true)
@@ -996,9 +999,9 @@ describe('usePaneWidth', () => {
// Shrink viewport (crosses 1280 breakpoint, diff switches to 511)
vi.stubGlobal('innerWidth', 800)
- window.dispatchEvent(new Event('resize'))
await act(async () => {
+ window.dispatchEvent(new Event('resize'))
await vi.runAllTimersAsync()
})
diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx
index c1d71b154c7..32a3ade1f74 100644
--- a/packages/react/src/SelectPanel/SelectPanel.test.tsx
+++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx
@@ -44,11 +44,14 @@ export function getLiveRegion(): LiveRegionElement {
throw new Error('No live-region found')
}
-const renderWithProp = (element: React.ReactElement, flag?: boolean) => {
+const getFocusManagement = (flag?: boolean) => {
// true = 'use roving tabindex'
// false = 'use aria-activedescendant'
- const focusManagement = flag ? 'roving-tabindex' : 'active-descendant'
- return render(React.cloneElement(element, {_PrivateFocusManagement: focusManagement}))
+ return flag ? 'roving-tabindex' : 'active-descendant'
+}
+
+const renderWithProp = (element: React.ReactElement, flag?: boolean) => {
+ return render(React.cloneElement(element, {_PrivateFocusManagement: getFocusManagement(flag)}))
}
const items: SelectPanelProps['items'] = [
@@ -160,12 +163,11 @@ for (const usingRemoveActiveDescendant of [false, true]) {
it('should close the select panel when clicking outside of the select panel', async () => {
const user = userEvent.setup()
- renderWithProp(
+ render(
<>
-
+
>,
- usingRemoveActiveDescendant,
)
await user.click(screen.getByText('Select items'))
diff --git a/packages/react/src/deprecated/ActionList/Group.tsx b/packages/react/src/deprecated/ActionList/Group.tsx
index 8fb7c021ea4..19660aea81d 100644
--- a/packages/react/src/deprecated/ActionList/Group.tsx
+++ b/packages/react/src/deprecated/ActionList/Group.tsx
@@ -32,7 +32,7 @@ export interface GroupProps extends React.ComponentPropsWithoutRef<'div'> {
/**
* Collects related `Items` in an `ActionList`.
*/
-export function Group({header, items, ...props}: GroupProps): JSX.Element {
+export function Group({header, items, groupId: _groupId, ...props}: GroupProps): JSX.Element {
return (
{header && }
diff --git a/packages/react/src/experimental/Tabs/Tabs.test.tsx b/packages/react/src/experimental/Tabs/Tabs.test.tsx
index 8cf7183b0a1..f5100d59638 100644
--- a/packages/react/src/experimental/Tabs/Tabs.test.tsx
+++ b/packages/react/src/experimental/Tabs/Tabs.test.tsx
@@ -66,7 +66,6 @@ describe('Tabs', () => {
})
test('onValueChange is called when tab changes', async () => {
- const user = userEvent.setup()
const onValueChange = vi.fn()
render(
@@ -81,7 +80,7 @@ describe('Tabs', () => {
)
const tabB = screen.getByRole('tab', {name: 'Tab B'})
- await user.click(tabB)
+ fireEvent.mouseDown(tabB)
expect(onValueChange).toHaveBeenCalledWith({value: 'b'})
expect(onValueChange).toHaveBeenCalledTimes(1)
diff --git a/packages/react/src/hooks/__tests__/useMergedRefs.test.tsx b/packages/react/src/hooks/__tests__/useMergedRefs.test.tsx
index 329dd9e8908..911fa690974 100644
--- a/packages/react/src/hooks/__tests__/useMergedRefs.test.tsx
+++ b/packages/react/src/hooks/__tests__/useMergedRefs.test.tsx
@@ -1,5 +1,5 @@
import {render, renderHook} from '@testing-library/react'
-import React, {forwardRef, type RefObject} from 'react'
+import React, {forwardRef, version as reactVersion, type RefObject} from 'react'
import {describe, expect, it, vi} from 'vitest'
import {useMergedRefs} from '../useMergedRefs'
@@ -142,12 +142,23 @@ describe('useMergedRefs', () => {
expect(refA).toHaveBeenCalledWith('test')
expect(refB).toHaveBeenCalledWith('test')
- // React 19 will call cleanup function and not pass null
- cleanup()
-
- expect(refA).toHaveBeenCalledWith(null)
- expect(refB).not.toHaveBeenCalledWith(null)
- expect(cleanupRefB).toHaveBeenCalledOnce()
+ if (Number.parseInt(reactVersion, 10) >= 19) {
+ expect(cleanup).toBeDefined()
+ // React 19 will call cleanup function and not pass null
+ cleanup!()
+
+ expect(refA).toHaveBeenCalledWith(null)
+ expect(refB).not.toHaveBeenCalledWith(null)
+ expect(cleanupRefB).toHaveBeenCalledOnce()
+ } else {
+ // React 18 ignores callback ref cleanups and calls the ref with null instead
+ expect(cleanup).toBeUndefined()
+ combined.result.current(null)
+
+ expect(refA).toHaveBeenCalledWith(null)
+ expect(refB).toHaveBeenCalledWith(null)
+ expect(cleanupRefB).not.toHaveBeenCalled()
+ }
})
})
})
diff --git a/packages/react/src/hooks/useMergedRefs.ts b/packages/react/src/hooks/useMergedRefs.ts
index 4b3ed9ab661..da181caec3f 100644
--- a/packages/react/src/hooks/useMergedRefs.ts
+++ b/packages/react/src/hooks/useMergedRefs.ts
@@ -1,5 +1,7 @@
import type {ForwardedRef, Ref as StandardRef, MutableRefObject} from 'react'
-import {useCallback} from 'react'
+import {useCallback, version as reactVersion} from 'react'
+
+const supportsCallbackRefCleanup = Number.parseInt(reactVersion, 10) >= 19
/**
* Combine two refs of matching type (typically an external or forwarded ref and an internal `useRef` object or
@@ -37,15 +39,15 @@ export function useMergedRefs (refA: Ref, refB: Ref) {
const cleanupA = setRef(refA, value)
const cleanupB = setRef(refB, value)
- // Only works in React 19. In React 18, the cleanup function will be ignored and the ref will get called with
- // `null` which will be passed to each ref as expected.
- return () => {
- // For object refs and callback refs that don't return cleanups, we still need to pass `null` on cleanup
- if (cleanupA) cleanupA()
- else setRef(refA, null)
+ if (supportsCallbackRefCleanup) {
+ return () => {
+ // For object refs and callback refs that don't return cleanups, we still need to pass `null` on cleanup
+ if (cleanupA) cleanupA()
+ else setRef(refA, null)
- if (cleanupB) cleanupB()
- else setRef(refB, null)
+ if (cleanupB) cleanupB()
+ else setRef(refB, null)
+ }
}
},
[refA, refB],
diff --git a/packages/react/src/live-region/Announce.tsx b/packages/react/src/live-region/Announce.tsx
index dba8ddc7011..df030af287d 100644
--- a/packages/react/src/live-region/Announce.tsx
+++ b/packages/react/src/live-region/Announce.tsx
@@ -1,6 +1,6 @@
import {announceFromElement} from '@primer/live-region-element'
import type React from 'react'
-import {useEffect, useRef, useState, type ElementRef} from 'react'
+import {useEffect, useRef, type ElementRef} from 'react'
import {useEffectOnce} from '../internal/hooks/useEffectOnce'
import {useEffectCallback} from '../internal/hooks/useEffectCallback'
import type {PolymorphicProps} from '../utils/modern-polymorphic'
@@ -52,7 +52,7 @@ export function Announce(props: AnnouncePr
...rest
} = props
const ref = useRef>(null)
- const [previousAnnouncementText, setPreviousAnnouncementText] = useState(null)
+ const previousAnnouncementText = useRef(null)
const savedAnnouncement = useRef | null>(null)
const announce = useEffectCallback(() => {
const {current: element} = ref
@@ -73,7 +73,7 @@ export function Announce(props: AnnouncePr
return
}
- if (textContent === previousAnnouncementText) {
+ if (textContent === previousAnnouncementText.current) {
return
}
@@ -98,7 +98,7 @@ export function Announce(props: AnnouncePr
delayMs,
},
)
- setPreviousAnnouncementText(textContent)
+ previousAnnouncementText.current = textContent
})
// Announce the initial message, this is wrapped in `useEffectOnce` so that it
diff --git a/packages/react/vitest.config.browser.mts b/packages/react/vitest.config.browser.mts
index 6e931d4a364..b9d5f9b231c 100644
--- a/packages/react/vitest.config.browser.mts
+++ b/packages/react/vitest.config.browser.mts
@@ -47,7 +47,7 @@ export default defineConfig({
'src/__tests__/storybook.test.tsx',
],
include: ['src/**/*.test.?(c|m)[jt]s?(x)'],
- setupFiles: ['config/vitest/browser/setup.ts'],
+ setupFiles: ['config/vitest/setup.js', 'config/vitest/browser/setup.ts'],
css: {
include: [/.+/],
},
diff --git a/packages/react/vitest.config.mts b/packages/react/vitest.config.mts
index 47e47169abf..1aae80cef91 100644
--- a/packages/react/vitest.config.mts
+++ b/packages/react/vitest.config.mts
@@ -25,5 +25,6 @@ export default defineConfig({
name: '@primer/react (node)',
include: ['src/__tests__/exports.test.ts', 'src/__tests__/storybook.test.tsx'],
environment: 'node',
+ setupFiles: ['config/vitest/setup.js'],
},
})
diff --git a/packages/styled-react/config/vitest/setup.js b/packages/styled-react/config/vitest/setup.js
new file mode 100644
index 00000000000..b94694272cf
--- /dev/null
+++ b/packages/styled-react/config/vitest/setup.js
@@ -0,0 +1 @@
+import '@primer/vitest-config/setup'
diff --git a/packages/styled-react/src/__tests__/primer-react-deprecated.browser.test.tsx b/packages/styled-react/src/__tests__/primer-react-deprecated.browser.test.tsx
index 4da7549e67a..3da29745cd6 100644
--- a/packages/styled-react/src/__tests__/primer-react-deprecated.browser.test.tsx
+++ b/packages/styled-react/src/__tests__/primer-react-deprecated.browser.test.tsx
@@ -4,7 +4,7 @@ import {Dialog, Octicon} from '../deprecated'
describe('@primer/react/deprecated', () => {
test('Dialog supports `sx` prop', () => {
- render()
+ render()
expect(window.getComputedStyle(screen.getByTestId('component')).backgroundColor).toBe('rgb(255, 0, 0)')
expect(screen.getByTestId('component').role).toBe('dialog')
})
diff --git a/packages/styled-react/vitest.config.browser.ts b/packages/styled-react/vitest.config.browser.ts
index 098df99f86b..f634e1eba3a 100644
--- a/packages/styled-react/vitest.config.browser.ts
+++ b/packages/styled-react/vitest.config.browser.ts
@@ -7,6 +7,7 @@ export default defineConfig({
plugins: [react()],
define: {
__DEV__: true,
+ 'process.env.CI': JSON.stringify(process.env.CI),
},
resolve: {
alias: [
@@ -27,7 +28,7 @@ export default defineConfig({
test: {
name: '@primer/styled-react (browser)',
include: ['src/**/*.browser.test.?(c|m)[jt]s?(x)'],
- setupFiles: ['config/vitest/browser/setup.ts'],
+ setupFiles: ['config/vitest/setup.js', 'config/vitest/browser/setup.ts'],
browser: {
provider: playwright(),
enabled: true,
diff --git a/packages/styled-react/vitest.config.ts b/packages/styled-react/vitest.config.ts
index 3b3a6e4c3d0..86f79d9f42a 100644
--- a/packages/styled-react/vitest.config.ts
+++ b/packages/styled-react/vitest.config.ts
@@ -5,5 +5,6 @@ export default defineConfig({
name: '@primer/styled-react (node)',
environment: 'node',
exclude: ['src/**/*.browser.test.?(c|m)[jt]s?(x)'],
+ setupFiles: ['config/vitest/setup.js'],
},
})
diff --git a/packages/vitest-config/package.json b/packages/vitest-config/package.json
new file mode 100644
index 00000000000..79ce4aa2386
--- /dev/null
+++ b/packages/vitest-config/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@primer/vitest-config",
+ "private": true,
+ "type": "module",
+ "exports": {
+ "./setup": "./setup.js"
+ },
+ "dependencies": {
+ "vitest-fail-on-console": "^0.10.1"
+ }
+}
diff --git a/packages/vitest-config/setup.js b/packages/vitest-config/setup.js
new file mode 100644
index 00000000000..50c75a5acae
--- /dev/null
+++ b/packages/vitest-config/setup.js
@@ -0,0 +1,29 @@
+import failOnConsole from 'vitest-fail-on-console'
+
+if (process.env.CI === 'true') {
+ failOnConsole({
+ allowMessage: message => {
+ return [
+ /Found duplicate ".+" slot\. Only the first will be rendered\./,
+ /A choice group must be labelled using a `CheckboxOrRadioGroup\.Label` child/,
+ /A radio input must have a `name` attribute\./,
+ /The input field with the id .+ MUST have a FormControl\.Label child\./,
+ /React does not recognize the `leadingVisual` prop on a DOM element\./,
+ /The component must have a child component\./,
+ /The above error occurred in the <.+> component:/,
+ /The `Tooltip` component expects a single React element that contains interactive content\./,
+ /Use the `aria-label` or `aria-labelledby` prop to provide an accessible label/,
+ /Uncaught Invariant Violation:/,
+ /validateDOMNesting\(\.\.\.\): |