Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/confirm-dialog-host-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

ConfirmationDialog: `useConfirm`/`confirm` now removes its host element from `document.body` after the dialog is closed, and uses a fresh host element per call, so the empty container no longer lingers or leaks into other components and tests
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {render, fireEvent} from '@testing-library/react'
import {render, fireEvent, waitFor} from '@testing-library/react'
import {describe, it, expect, vi} from 'vitest'
import type React from 'react'
import {useCallback, useRef, useState} from 'react'
Expand Down Expand Up @@ -310,4 +310,30 @@ describe('ConfirmationDialog', () => {
})

implementsClassName(ConfirmationDialog, dialogClasses.Dialog)

describe('useConfirm', () => {
it('removes the host element from the document body when the dialog is closed', async () => {
const {getByText, getByRole} = render(<ShorthandHookFromActionMenu />)

fireEvent.click(getByText('Show menu'))

// Capture <body> children after the menu opens so we can reliably detect the confirm() host element
const bodyChildrenBeforeDialog = Array.from(document.body.children)

fireEvent.click(getByText('Show dialog'))

expect(getByRole('alertdialog')).toBeInTheDocument()

const hostElement = Array.from(document.body.children).find(el => !bodyChildrenBeforeDialog.includes(el))
if (!hostElement) throw new Error('Expected confirm() to append a host element to <body>')

fireEvent.click(getByRole('button', {name: 'Secondary'}))

// After closing, neither the dialog nor its host element should linger in the DOM
await waitFor(() => {
expect(document.querySelector('[role="alertdialog"]')).toBeNull()
expect(hostElement.isConnected).toBe(false)
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -138,16 +138,16 @@ export const ConfirmationDialog: React.FC<React.PropsWithChildren<ConfirmationDi
)
}

let hostElement: Element | null = null
export type ConfirmOptions = Omit<ConfirmationDialogProps, 'onClose'> & {content: React.ReactNode}
async function confirm(options: ConfirmOptions): Promise<boolean> {
const {content, ...confirmationDialogProps} = options
return new Promise(resolve => {
hostElement ||= document.createElement('div')
if (!hostElement.isConnected) document.body.append(hostElement)
const hostElement = document.createElement('div')
document.body.append(hostElement)
const root = createRoot(hostElement)
const onClose: ConfirmationDialogProps['onClose'] = gesture => {
root.unmount()
hostElement.remove()
if (gesture === 'confirm') {
resolve(true)
} else {
Expand Down
Loading