diff --git a/package.json b/package.json index a24daae6226..dd2d2c59ded 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 491c536773b..dbe19cf1e7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts b/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts index 7cf7140593a..37a28e21218 100644 --- a/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts +++ b/src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts @@ -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 { + 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([])