test(dev): classify boundary exports with the refresh runtime's own predicate

The hand-rolled `^[A-Z]` name check called `export class Foo {}` a component;
the runtime rejects any class whose prototype carries extra members, so that
shape restored the two-pass split undetected. Use react-refresh's exported
`isLikelyComponentType` and mirror `isCompoundComponent` instead of a third
approximation. react-refresh was already resolvable only via shamefully-hoist,
so it is now an explicit devDependency.
This commit is contained in:
Neil
2026-08-01 09:55:01 -07:00
parent 158b804520
commit ff058d5f03
3 changed files with 45 additions and 21 deletions
+1
View File
@@ -218,6 +218,7 @@
"react-dom": "^19.2.7",
"react-grab": "^0.1.33",
"react-markdown": "^10.1.0",
"react-refresh": "^0.18.0",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
+3
View File
@@ -324,6 +324,9 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.17)(react@19.2.7)
react-refresh:
specifier: ^0.18.0
version: 0.18.0
rehype-highlight:
specifier: ^7.0.2
version: 7.0.2
@@ -1,4 +1,5 @@
// @vitest-environment happy-dom
import { createRequire } from 'node:module'
import { createElement, type ReactNode } from 'react'
import { renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
@@ -7,39 +8,58 @@ 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. Asserting on the *module
// namespace* rather than source text is what makes re-exports and default exports
// visible here -- `export { useConfirmationDialog } from ...` reintroduces the crash
// and no amount of source-shape regex sees it.
function isComponentLike(value: unknown): boolean {
if (typeof value === 'function') {
return /^[A-Z]/.test(value.name)
}
if (typeof value === 'object' && value !== null) {
const tag = (value as { $$typeof?: symbol }).$$typeof
return tag === Symbol.for('react.forward_ref') || tag === Symbol.for('react.memo')
}
return false
// 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 nonComponentExports(moduleNamespace: object): string[] {
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]) => !isComponentLike(value))
.filter(([, value]) => !isRefreshSafeExport(value))
.map(([name]) => name)
}
describe('confirmation dialog Fast Refresh boundary', () => {
it('exports only components from the provider module', () => {
it('exports nothing from the provider module that invalidates the refresh boundary', () => {
expect(Object.keys(providerModule).length).toBeGreaterThan(0)
expect(nonComponentExports(providerModule)).toEqual([])
expect(unsafeExports(providerModule)).toEqual([])
})
it('keeps the context and hook in a component-free module', () => {
const components = Object.entries(contextModule)
.filter(([, value]) => isComponentLike(value))
.filter(([, value]) => isLikelyComponentType(value))
.map(([name]) => name)
expect(components).toEqual([])