diff --git a/package-lock.json b/package-lock.json index 7db0d43de0f..ef0df9fb381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,8 @@ "turbo": "^2.6.3", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", - "vitest": "^4.1.5" + "vitest": "^4.1.5", + "vitest-fail-on-console": "^0.10.1" }, "engines": { "node": ">=12", @@ -82,8 +83,8 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@primer/react": "38.21.1", - "@primer/styled-react": "1.0.6", + "@primer/react": "38.22.0", + "@primer/styled-react": "1.0.7", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.3", @@ -96,8 +97,8 @@ "name": "example-nextjs", "version": "0.0.0", "dependencies": { - "@primer/react": "38.21.1", - "@primer/styled-react": "1.0.6", + "@primer/react": "38.22.0", + "@primer/styled-react": "1.0.7", "next": "^16.1.7", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -139,8 +140,8 @@ "version": "0.0.0", "dependencies": { "@primer/octicons-react": "^19.21.0", - "@primer/react": "38.21.1", - "@primer/styled-react": "1.0.6", + "@primer/react": "38.22.0", + "@primer/styled-react": "1.0.7", "clsx": "^2.1.1", "next": "^16.1.7", "react": "^19.2.0", @@ -7072,6 +7073,10 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@primer/vitest-config": { + "resolved": "packages/vitest-config", + "link": true + }, "node_modules/@publint/pack": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.2.tgz", @@ -11664,7 +11669,6 @@ }, "node_modules/chalk": { "version": "5.4.1", - "dev": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -26684,6 +26688,20 @@ } } }, + "node_modules/vitest-fail-on-console": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/vitest-fail-on-console/-/vitest-fail-on-console-0.10.1.tgz", + "integrity": "sha512-Xjy2SpgND547qSy0s0zYVnh1G/WyGtdjAbi4PFV8mkYRmTq+6NzRUJYdc08BHrw7HJLpO2kMxHFB8PWn7FOVsg==", + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1" + }, + "peerDependencies": { + "@vitest/utils": ">=0.26.2", + "vite": ">=4.5.2", + "vitest": ">=0.26.2" + } + }, "node_modules/vitest/node_modules/@vitest/expect": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", @@ -27709,7 +27727,7 @@ }, "packages/react": { "name": "@primer/react", - "version": "38.21.1", + "version": "38.22.0", "license": "MIT", "dependencies": { "@github/mini-throttle": "^2.1.1", @@ -28082,7 +28100,7 @@ }, "packages/styled-react": { "name": "@primer/styled-react", - "version": "1.0.6", + "version": "1.0.7", "dependencies": { "@styled-system/css": "^5.1.5", "@styled-system/props": "^5.1.5", @@ -28099,7 +28117,7 @@ "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", "@primer/primitives": "10.x || 11.x", - "@primer/react": "^38.20.0", + "@primer/react": "^38.22.0", "@rollup/plugin-babel": "^6.1.0", "@storybook/react-vite": "^10.3.3", "@types/react": "18.3.11", @@ -28248,6 +28266,12 @@ "funding": { "url": "https://github.com/sponsors/isaacs" } + }, + "packages/vitest-config": { + "name": "@primer/vitest-config", + "dependencies": { + "vitest-fail-on-console": "^0.10.1" + } } } } diff --git a/package.json b/package.json index 08aee29e845..205c1f86672 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,8 @@ "turbo": "^2.6.3", "typescript": "^6.0.3", "typescript-eslint": "^8.59.1", - "vitest": "^4.1.5" + "vitest": "^4.1.5", + "vitest-fail-on-console": "^0.10.1" }, "overrides": { "zod-validation-error": "^4.0.0" diff --git a/packages/doc-gen/config/vitest/setup.js b/packages/doc-gen/config/vitest/setup.js new file mode 100644 index 00000000000..b94694272cf --- /dev/null +++ b/packages/doc-gen/config/vitest/setup.js @@ -0,0 +1 @@ +import '@primer/vitest-config/setup' diff --git a/packages/doc-gen/vitest.config.mts b/packages/doc-gen/vitest.config.mts index 7032fbbac62..2bfcc04ef53 100644 --- a/packages/doc-gen/vitest.config.mts +++ b/packages/doc-gen/vitest.config.mts @@ -6,5 +6,6 @@ export default defineConfig({ }, test: { environment: 'node', + setupFiles: ['config/vitest/setup.js'], }, }) diff --git a/packages/postcss-preset-primer/config/vitest/setup.js b/packages/postcss-preset-primer/config/vitest/setup.js new file mode 100644 index 00000000000..b94694272cf --- /dev/null +++ b/packages/postcss-preset-primer/config/vitest/setup.js @@ -0,0 +1 @@ +import '@primer/vitest-config/setup' diff --git a/packages/postcss-preset-primer/vitest.config.ts b/packages/postcss-preset-primer/vitest.config.ts index e28d08c4e78..b227e7de153 100644 --- a/packages/postcss-preset-primer/vitest.config.ts +++ b/packages/postcss-preset-primer/vitest.config.ts @@ -3,5 +3,6 @@ import {defineConfig} from 'vitest/config' export default defineConfig({ test: { environment: 'node', + setupFiles: ['config/vitest/setup.js'], }, }) diff --git a/packages/react/config/vitest/setup.js b/packages/react/config/vitest/setup.js new file mode 100644 index 00000000000..b94694272cf --- /dev/null +++ b/packages/react/config/vitest/setup.js @@ -0,0 +1 @@ +import '@primer/vitest-config/setup' diff --git a/packages/react/src/ActionBar/ActionBar.test.tsx b/packages/react/src/ActionBar/ActionBar.test.tsx index bce7afda03f..1fa0425d67b 100644 --- a/packages/react/src/ActionBar/ActionBar.test.tsx +++ b/packages/react/src/ActionBar/ActionBar.test.tsx @@ -1,5 +1,5 @@ import {describe, expect, it, afterEach, vi} from 'vitest' -import {render, screen, act} from '@testing-library/react' +import {render, screen, act, waitFor} from '@testing-library/react' import userEvent from '@testing-library/user-event' import React, {createRef, useState} from 'react' import ActionBar from './' @@ -82,6 +82,10 @@ describe('ActionBar', () => { }) }) +const waitForActionBarEffects = async () => { + await act(async () => {}) +} + describe('ActionBar Registry System', () => { it('should preserve order with deep nesting', () => { render( @@ -221,7 +225,9 @@ describe('ActionBar Registry System', () => { await user.click(screen.getByText('Increment')) } - expect(screen.getByRole('button', {name: 'Button 10'})).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByRole('button', {name: 'Button 10'})).toBeInTheDocument() + }) }) it('should handle zero-width scenarios gracefully', () => { @@ -356,7 +362,9 @@ describe('ActionBar.Menu returnFocusRef', () => { // Verify focus is returned to the returnFocusRef element const returnFocusTarget = screen.getByTestId('return-focus-target') - expect(document.activeElement).toEqual(returnFocusTarget) + await waitFor(() => { + expect(document.activeElement).toEqual(returnFocusTarget) + }) }) it('returns focus to returnFocusRef when menu item is selected', async () => { @@ -390,7 +398,9 @@ describe('ActionBar.Menu returnFocusRef', () => { // Verify focus is returned to the returnFocusRef element const returnFocusTarget = screen.getByTestId('return-focus-target') - expect(document.activeElement).toEqual(returnFocusTarget) + await waitFor(() => { + expect(document.activeElement).toEqual(returnFocusTarget) + }) }) it('returns focus to anchor button when returnFocusRef is not provided', async () => { @@ -414,34 +424,46 @@ describe('ActionBar.Menu returnFocusRef', () => { await user.keyboard('{Escape}') // Verify focus returns to the menu button (default behavior) - expect(document.activeElement).toEqual(menuButton) + await waitFor(() => { + expect(document.activeElement).toEqual(menuButton) + }) }) }) describe('ActionBar data-component attributes', () => { - it('renders ActionBar with data-component attribute', () => { + it('renders ActionBar with data-component attribute', async () => { const {container} = render( , ) + await waitFor(() => { + expect(screen.getByRole('toolbar')).toBeInTheDocument() + }) + await waitForActionBarEffects() + const actionBar = container.querySelector('[data-component="ActionBar"]') expect(actionBar).toBeInTheDocument() }) - it('renders ActionBar.IconButton with data-component attribute', () => { + it('renders ActionBar.IconButton with data-component attribute', async () => { const {container} = render( , ) + await waitFor(() => { + expect(screen.getByRole('toolbar')).toBeInTheDocument() + }) + await waitForActionBarEffects() + const iconButton = container.querySelector('[data-component="ActionBar"] [data-component="IconButton"]') expect(iconButton).toBeInTheDocument() }) - it('renders ActionBar.VerticalDivider with data-component attribute', () => { + it('renders ActionBar.VerticalDivider with data-component attribute', async () => { const {container} = render( @@ -450,11 +472,16 @@ describe('ActionBar data-component attributes', () => { , ) + await waitFor(() => { + expect(screen.getByRole('toolbar')).toBeInTheDocument() + }) + await waitForActionBarEffects() + const divider = container.querySelector('[data-component="ActionBar.VerticalDivider"]') expect(divider).toBeInTheDocument() }) - it('renders ActionBar.Group with data-component attribute', () => { + it('renders ActionBar.Group with data-component attribute', async () => { const {container} = render( @@ -464,17 +491,27 @@ describe('ActionBar data-component attributes', () => { , ) + await waitFor(() => { + expect(screen.getByRole('toolbar')).toBeInTheDocument() + }) + await waitForActionBarEffects() + const group = container.querySelector('[data-component="ActionBar.Group"]') expect(group).toBeInTheDocument() }) - it('renders ActionBar.Menu.IconButton with data-component attribute', () => { + it('renders ActionBar.Menu.IconButton with data-component attribute', async () => { render( , ) + await waitFor(() => { + expect(screen.getByRole('toolbar')).toBeInTheDocument() + }) + await waitForActionBarEffects() + const menuButton = screen.getByRole('button', {name: 'More options'}) expect(menuButton).toHaveAttribute('data-component', 'ActionBar.Menu.IconButton') }) diff --git a/packages/react/src/ActionMenu/ActionMenu.test.tsx b/packages/react/src/ActionMenu/ActionMenu.test.tsx index 09303b7923b..841e960d442 100644 --- a/packages/react/src/ActionMenu/ActionMenu.test.tsx +++ b/packages/react/src/ActionMenu/ActionMenu.test.tsx @@ -340,9 +340,7 @@ describe('ActionMenu', () => { const button = component.getByRole('button') const user = userEvent.setup() - await act(async () => { - await user.click(button) - }) + await user.click(button) expect(component.queryByRole('menu')).toBeInTheDocument() const menuItems = component.getAllByRole('menuitem') @@ -355,13 +353,11 @@ describe('ActionMenu', () => { await user.keyboard('{ArrowDown}') expect(menuItems[1]).toEqual(document.activeElement) - await act(async () => { - // TODO: Removed one ArrowDown to account for the focus trap starting at the second element - // await user.keyboard('{ArrowDown}') - await user.keyboard('{ArrowDown}') - await user.keyboard('{ArrowDown}') - await user.keyboard('{ArrowDown}') - }) + // TODO: Removed one ArrowDown to account for the focus trap starting at the second element + // await user.keyboard('{ArrowDown}') + await user.keyboard('{ArrowDown}') + await user.keyboard('{ArrowDown}') + await user.keyboard('{ArrowDown}') expect(menuItems[menuItems.length - 1]).toEqual(document.activeElement) // last elememt await user.keyboard('{ArrowDown}') @@ -796,7 +792,9 @@ describe('ActionMenu', () => { // The new anchor should have the same anchor-name re-applied, and the // overlay should still reference it via position-anchor. - expect(newAnchor.style.getPropertyValue('anchor-name')).toBe(initialAnchorName) + await waitFor(() => { + expect(newAnchor.style.getPropertyValue('anchor-name')).toBe(initialAnchorName) + }) expect(overlay.style.getPropertyValue('position-anchor')).toBe(initialPositionAnchor) }) }) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx index 255c771c6b3..ce8f2ba7987 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.test.tsx @@ -1,6 +1,6 @@ import {act, createRef, useCallback, useRef, useState} from 'react' import {describe, expect, it, vi} from 'vitest' -import {render} from '@testing-library/react' +import {render, waitFor} from '@testing-library/react' import {userEvent} from 'vitest/browser' import {AnchoredOverlay} from '../AnchoredOverlay' import {Button} from '../Button' @@ -577,7 +577,7 @@ describe('AnchoredOverlay CSS anchor positioning viewport handling', () => { }) describe('AnchoredOverlay anchor element replacement', () => { - it('should re-apply anchor-name to a new anchor DOM element when the overlay reopens', () => { + it('should re-apply anchor-name to a new anchor DOM element when the overlay reopens', async () => { function TestComponent() { const anchorRef = useRef(null) const [open, setOpen] = useState(true) @@ -633,6 +633,8 @@ describe('AnchoredOverlay anchor element replacement', () => { const newAnchor = baseElement.querySelector('[data-testid="anchor"]') as HTMLElement expect(newAnchor).not.toBe(initialAnchor) - expect(newAnchor.style.getPropertyValue('anchor-name')).toBe(anchorName) + await waitFor(() => { + expect(newAnchor.style.getPropertyValue('anchor-name')).toBe(anchorName) + }) }) }) diff --git a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx index e0050775587..a8a8a2c9e00 100644 --- a/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx +++ b/packages/react/src/AnchoredOverlay/AnchoredOverlay.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import {useCallback, useEffect, useState, type JSX} from 'react' +import {useCallback, useEffect, useRef, useState, type JSX} from 'react' import type {OverlayProps} from '../Overlay' import Overlay from '../Overlay' import type {FocusTrapHookSettings} from '../hooks/useFocusTrap' @@ -16,6 +16,7 @@ import classes from './AnchoredOverlay.module.css' import {clsx} from 'clsx' import {useFeatureFlag} from '../FeatureFlags' import {widthMap} from '../Overlay/Overlay' +import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect' interface AnchoredOverlayPropsWithAnchor { /** @@ -181,10 +182,23 @@ export const AnchoredOverlay: React.FC(null) + const lastExternalAnchorElementRef = useRef(null) + const [externalAnchorElementVersion, setExternalAnchorElementVersion] = useState(0) // eslint-disable-next-line react-hooks/refs if (anchorRef.current !== anchorElement) { setAnchorElement(anchorRef.current) } + useIsomorphicLayoutEffect(() => { + // When the anchor is rendered outside AnchoredOverlay (`renderAnchor === null`), + // React 19 can replace the DOM node while the overlay stays open without + // re-running the render-time ref sync above. Track that post-commit swap so + // the CSS anchor-positioning effects re-apply to the new external anchor. + if (renderAnchor !== null) return + if (anchorRef.current !== lastExternalAnchorElementRef.current) { + lastExternalAnchorElementRef.current = anchorRef.current + setExternalAnchorElementVersion(version => version + 1) + } + }) const [overlayRef, updateOverlayRef] = useRenderForcingRef() const [overlayElement, setOverlayElement] = useState(null) const anchorId = useId(externalAnchorId) @@ -274,21 +288,34 @@ export const AnchoredOverlay: React.FC { - if (!cssAnchorPositioning || !anchorElement) return - if (anchorElement.style.getPropertyValue('anchor-name')) return - anchorElement.style.setProperty('anchor-name', anchorName) + const currentAnchorElement = renderAnchor === null ? anchorRef.current : anchorElement + if (!cssAnchorPositioning || !currentAnchorElement) return + if (currentAnchorElement.style.getPropertyValue('anchor-name')) return + currentAnchorElement.style.setProperty('anchor-name', anchorName) + return () => { + if (currentAnchorElement.style.getPropertyValue('anchor-name') === anchorName) { + currentAnchorElement.style.removeProperty('anchor-name') + } + } + }, [anchorElement, anchorName, anchorRef, cssAnchorPositioning, externalAnchorElementVersion, renderAnchor]) + + useEffect(() => { + const currentAnchorElement = renderAnchor === null ? anchorRef.current : anchorElement + if (!shouldRenderAsPopover || !currentAnchorElement) return + currentAnchorElement.setAttribute('popovertarget', popoverId) return () => { - if (anchorElement.style.getPropertyValue('anchor-name') === anchorName) { - anchorElement.style.removeProperty('anchor-name') + if (currentAnchorElement.getAttribute('popovertarget') === popoverId) { + currentAnchorElement.removeAttribute('popovertarget') } } - }, [cssAnchorPositioning, anchorElement, anchorName]) + }, [anchorElement, anchorRef, externalAnchorElementVersion, popoverId, renderAnchor, shouldRenderAsPopover]) useEffect(() => { - if (!cssAnchorPositioning || !anchorElement) return + const currentAnchorElement = renderAnchor === null ? anchorRef.current : anchorElement + if (!cssAnchorPositioning || !currentAnchorElement) return const currentOverlay = overlayRef.current - const resolvedAnchorName = anchorElement.style.getPropertyValue('anchor-name') || anchorName + const resolvedAnchorName = currentAnchorElement.style.getPropertyValue('anchor-name') || anchorName let pendingPositionFrame: number | null = null if (open && currentOverlay) { @@ -299,7 +326,7 @@ export const AnchoredOverlay: React.FC { pendingPositionFrame = null const fallbackWidth = width ? parseInt(widthMap[width]) : parseInt(widthMap.small) - const result = getDefaultPosition(anchorElement, currentOverlay, fallbackWidth) + const result = getDefaultPosition(currentAnchorElement, currentOverlay, fallbackWidth) currentOverlay.setAttribute('data-align', result.horizontal) if (result.suggestedSide) { @@ -341,7 +368,18 @@ export const AnchoredOverlay: React.FC
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\(\.\.\.\):