Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/confirm-dialog-host-cleanup.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import {render, fireEvent} from '@testing-library/react'
import {render, fireEvent, waitFor} from '@testing-library/react'
import {afterEach, beforeEach, describe, it, expect, vi} from 'vitest'
import type React from 'react'
import {useCallback, useRef, useState} from 'react'
Expand DownExpand Up@@ -327,4 +327,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)
})
})
})
})
13 changes: 4 additions & 9 deletions packages/react/src/ConfirmationDialog/useConfirm.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,23 +3,18 @@ import {createRoot} from 'react-dom/client'
import BaseStyles from '../BaseStyles'
import {ConfirmationDialog, type ConfirmationDialogProps} from './ConfirmationDialog'

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()
if (gesture === 'confirm') {
resolve(true)
} else {
resolve(false)
}
hostElement.remove()
resolve(gesture === 'confirm')
}
root.render(
React.createElement(
Expand Down
Loading