diff --git a/src/renderer/src/components/mobile/relay-mint-failure-feedback.test.tsx b/src/renderer/src/components/mobile/relay-mint-failure-feedback.test.tsx new file mode 100644 index 00000000000..4dcc36258ce --- /dev/null +++ b/src/renderer/src/components/mobile/relay-mint-failure-feedback.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useSendRelayMintFailureFeedback } from './relay-mint-failure-feedback' +import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() } +})) + +const failure: MobileRelayMintFailure = { + code: 'relay_binding_failed', + stage: 'binding_failed', + message: 'Could not bind the relay session.' +} + +function SendButton(): React.JSX.Element { + const send = useSendRelayMintFailureFeedback() + return ( + + ) +} + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() +}) + +describe('useSendRelayMintFailureFeedback', () => { + it('drops repeat clicks while a send is in flight so one gh spawn and one report go out', async () => { + const user = userEvent.setup() + let resolveViewer: (value: null) => void = () => {} + const viewer = vi.fn(() => new Promise((resolve) => (resolveViewer = resolve))) + const submit = vi.fn(async () => ({ ok: true as const })) + vi.stubGlobal( + 'window', + Object.assign(window, { api: { gh: { viewer }, feedback: { submit } } }) + ) + + render() + const button = screen.getByRole('button', { name: 'send' }) + await user.click(button) + await user.click(button) + await user.click(button) + + expect(viewer).toHaveBeenCalledTimes(1) + resolveViewer(null) + await vi.waitFor(() => expect(submit).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/src/renderer/src/components/mobile/relay-mint-failure-feedback.ts b/src/renderer/src/components/mobile/relay-mint-failure-feedback.ts index 4e75415d237..c66babb4c1c 100644 --- a/src/renderer/src/components/mobile/relay-mint-failure-feedback.ts +++ b/src/renderer/src/components/mobile/relay-mint-failure-feedback.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import type { MobileRelayMintFailure } from '../../../../shared/mobile-relay-mint-failure' @@ -30,7 +30,16 @@ export function buildRelayMintFailureFeedback(args: RelayMintFailureFeedbackArgs export function useSendRelayMintFailureFeedback(): ( args: RelayMintFailureFeedbackArgs ) => Promise { + // Why: the button has no busy state and the send is a `gh` subprocess plus an + // HTTPS post, so repeat clicks would file duplicate reports and queue extra + // spawns behind the shared gh concurrency limit. + const inFlightRef = useRef(false) + return useCallback(async (args: RelayMintFailureFeedbackArgs): Promise => { + if (inFlightRef.current) { + return + } + inFlightRef.current = true try { // Why: identity is best-effort; a missing gh session must not block the report. const viewer = await window.api.gh.viewer().catch(() => null) @@ -57,6 +66,8 @@ export function useSendRelayMintFailureFeedback(): ( ) ) console.error('Failed to send relay pairing diagnostics:', error) + } finally { + inFlightRef.current = false } }, []) }