test(dev): tighten confirmation dialog boundary guard

This commit is contained in:
Neil
2026-08-02 00:56:22 -07:00
parent ff058d5f03
commit 648b00ae3c
2 changed files with 3 additions and 45 deletions
@@ -1,10 +1,6 @@
import { createContext, useContext } from 'react'
// Why: the context stays in a component-free module so React Fast Refresh never
// rebuilds it. A module exporting both a component and this hook can never be a
// refresh boundary, so Vite applies its updates in two waves under different
// `?t=` stamps; importers refreshed in different waves then hold different
// context objects and consumers throw below until a full reload.
// Keep the context component-free so Fast Refresh preserves its identity.
export type ConfirmationDialogOptions = {
title: string
@@ -8,53 +8,15 @@ import * as providerModule from '@/components/confirmation-dialog'
import { ConfirmationDialogProvider } from '@/components/confirmation-dialog'
import { useConfirmationDialog } from '@/components/confirmation-dialog-context'
// Why: crash reports 42d63029 / 5ee3b8b6. A module exporting both the provider and the
// hook is not a Fast Refresh boundary, so Vite applied its updates in two passes under two
// `?t=` stamps, `createContext` ran twice, and the provider published a different context
// object than `ChecksPanel` read.
//
// Two earlier versions of this test were evaded, both by approximating the runtime instead
// of asking it. Matching source text missed a re-export of the hook; a hand-rolled `^[A-Z]`
// name check called `export class ConfirmationDialogQueue {}` a component when the runtime
// rejects it. Hence the real predicate below rather than a third approximation.
// react-refresh ships no types; take the one binding this needs.
const { isLikelyComponentType } = createRequire(import.meta.url)('react-refresh/runtime') as {
isLikelyComponentType: (value: unknown) => boolean
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
Object.prototype.toString.call(value) === '[object Object]' &&
((value as object).constructor === Object || (value as object).constructor === undefined)
)
}
// Mirrors `isCompoundComponent` in @vitejs/plugin-react's refresh-runtime, which accepts a
// plain object whose every member is a component (`export const Dialog = { Root, Trigger }`).
// The runtime's third accept term, `prevExports[key] === nextExports[key]`, is deliberately
// left out: it holds only for exports whose identity survives a re-execution, which nothing
// but a primitive does, and an unchanged primitive breaks the boundary the moment it changes.
function isRefreshSafeExport(value: unknown): boolean {
if (isLikelyComponentType(value)) {
return true
}
if (!isPlainObject(value)) {
return false
}
return Object.values(value).every((member) => isLikelyComponentType(member))
}
function unsafeExports(moduleNamespace: object): string[] {
return Object.entries(moduleNamespace)
.filter(([, value]) => !isRefreshSafeExport(value))
.map(([name]) => name)
}
describe('confirmation dialog Fast Refresh boundary', () => {
it('exports nothing from the provider module that invalidates the refresh boundary', () => {
expect(Object.keys(providerModule).length).toBeGreaterThan(0)
expect(unsafeExports(providerModule)).toEqual([])
expect(Object.keys(providerModule)).toEqual(['ConfirmationDialogProvider'])
expect(isLikelyComponentType(providerModule.ConfirmationDialogProvider)).toBe(true)
})
it('keeps the context and hook in a component-free module', () => {