fix(macos): drop stale focus refreshes in the FDA nudge

refreshFullDiskAccessStatus() applied whichever getStatus() round-trip
resolved last. Rapid blur/focus puts several in flight, so an earlier
pre-grant 'unknown' landing after a newer 'granted' un-hid the card and
also wrote 'unknown' into the module-level session cache, re-nagging a
user who already has Full Disk Access for the rest of the session. The
adjacent FullDiskAccessSetupPrompt already guards this with a refresh
sequence; mirror it here.

Also unmount React roots in afterEach: clearing document.body left them
mounted, leaking each test's window focus listener into later tests.
This commit is contained in:
Brennan Benson
2026-07-26 14:36:36 -07:00
parent 2b7cdb278a
commit 5a0f71754a
2 changed files with 74 additions and 1 deletions
@@ -17,6 +17,10 @@ import {
shouldShowFullDiskAccessNudge
} from './FullDiskAccessNudge'
// Why: without this React skips its "update not wrapped in act" warnings, so a
// state update escaping act() in these async probe paths would pass unnoticed.
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
@@ -56,10 +60,13 @@ function fdaStatus(status: DeveloperPermissionStatus): DeveloperPermissionState[
return [{ id: 'full-disk-access', status }]
}
const mountedRoots: Root[] = []
async function renderNudge(): Promise<{ container: HTMLDivElement; root: Root }> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
mountedRoots.push(root)
await act(async () => {
root.render(
React.createElement(TooltipProvider, null, React.createElement(FullDiskAccessNudge))
@@ -73,7 +80,15 @@ async function renderNudge(): Promise<{ container: HTMLDivElement; root: Root }>
return { container, root }
}
afterEach(() => {
afterEach(async () => {
// Why: clearing document.body leaves the roots mounted, so their window focus
// listener survives and re-probes during later tests. Unmount them instead.
const roots = mountedRoots.splice(0)
await act(async () => {
for (const root of roots) {
root.unmount()
}
})
vi.restoreAllMocks()
document.body.innerHTML = ''
resetFullDiskAccessProbeForTests()
@@ -268,6 +283,56 @@ describe('FullDiskAccessNudge', () => {
expect(container.textContent).toBe('')
})
it('ignores a slow focus refresh that resolves after a newer one saw the grant', async () => {
setUserAgent(MAC_UA)
const pendingResolvers: ((states: DeveloperPermissionState[]) => void)[] = []
let deferGetStatus = false
installDeveloperPermissionsApi({
getStatus: () =>
deferGetStatus
? new Promise<DeveloperPermissionState[]>((resolve) => {
pendingResolvers.push(resolve)
})
: Promise.resolve(fdaStatus('unknown')),
request: async () => ({
id: 'full-disk-access',
status: 'unknown',
openedSystemSettings: true
})
})
const { container } = await renderNudge()
const openButton = Array.from(container.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Open System Settings')
)
await act(async () => {
openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
// Rapid blur/focus while the first round-trip is still in flight.
deferGetStatus = true
await act(async () => {
window.dispatchEvent(new Event('focus'))
window.dispatchEvent(new Event('focus'))
})
expect(pendingResolvers.length).toBe(2)
// The newer refresh observes the grant and hides the card.
await act(async () => {
pendingResolvers[1]?.(fdaStatus('granted'))
await Promise.resolve()
await Promise.resolve()
})
expect(container.textContent).toBe('')
// The older, slower refresh still reports the pre-grant status; it must not win.
await act(async () => {
pendingResolvers[0]?.(fdaStatus('unknown'))
await Promise.resolve()
await Promise.resolve()
})
expect(container.textContent).toBe('')
// The stale result must not poison the session cache a later mount reads either.
const remount = await renderNudge()
expect(remount.container.textContent).toBe('')
})
it('stops watching for the grant once the card is dismissed', async () => {
setUserAgent(MAC_UA)
const { getStatus } = installDeveloperPermissionsApi({
@@ -27,6 +27,7 @@ const DISMISS_KEY = 'orca.fullDiskAccessNudgeDismissed.v1'
let cachedFullDiskAccessStatus: DeveloperPermissionStatus | undefined
let fullDiskAccessProbed = false
let fullDiskAccessProbe: Promise<DeveloperPermissionStatus | undefined> | null = null
let fullDiskAccessRefreshSequence = 0
function getFullDiskAccessStatus(
states: readonly DeveloperPermissionState[]
@@ -62,9 +63,16 @@ function probeFullDiskAccessStatusOnce(): Promise<DeveloperPermissionStatus | un
// Why: a fresh, cache-bypassing probe used only on CTA return-focus so the card
// can hide the moment a Full Disk Access grant takes effect for this process.
function refreshFullDiskAccessStatus(): Promise<DeveloperPermissionStatus | undefined> {
// Why: rapid focus/blur puts several getStatus() round-trips in flight, and an
// earlier pre-grant result resolving last would un-hide the card and poison the
// session cache — same refresh-sequence guard as FullDiskAccessSetupPrompt.
const refreshId = ++fullDiskAccessRefreshSequence
return window.api.developerPermissions
.getStatus()
.then((states) => {
if (refreshId !== fullDiskAccessRefreshSequence) {
return cachedFullDiskAccessStatus
}
const next = getFullDiskAccessStatus(states)
rememberFullDiskAccessStatus(next)
return next