From 677f41bcd86d066d382ade5f03b6c0c80ecc9de5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 03:14:33 -0700 Subject: [PATCH] fix: guard runtime pairing async state (#3597) --- .../settings/RuntimePairingUrlGenerator.tsx | 109 +++++++++++++----- 1 file changed, 77 insertions(+), 32 deletions(-) diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx index 1948a102e31..d3b7133026b 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx @@ -1,6 +1,10 @@ +/* eslint-disable max-lines -- Why: the generated URL cache, grant list, and + settings form stay together so revocation and cache invalidation remain + auditable. */ import { Check, Copy, Loader2, RefreshCw } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' +import { useMountedRef } from '@/hooks/useMountedRef' import type { RuntimeAccessGrant } from '../../../../shared/runtime-access-grants' import { Button } from '../ui/button' import { Input } from '../ui/input' @@ -112,6 +116,7 @@ export function RuntimePairingUrlGenerator({ const [isGeneratingPairing, setIsGeneratingPairing] = useState(false) const networkInterfaceLoadIdRef = useRef(0) const accessGrantLoadIdRef = useRef(0) + const mountedRef = useMountedRef() useEffect(() => { if (copiedTarget === null) { @@ -119,57 +124,71 @@ export function RuntimePairingUrlGenerator({ } const target = copiedTarget const timeout = window.setTimeout(() => { - setCopiedTarget((current) => (current === target ? null : current)) + if (mountedRef.current) { + setCopiedTarget((current) => (current === target ? null : current)) + } }, 1400) return () => window.clearTimeout(timeout) - }, [copiedTarget]) + }, [copiedTarget, mountedRef]) const loadRuntimeAccessGrants = useCallback( async (options: { showToastOnError?: boolean } = {}): Promise => { const loadId = accessGrantLoadIdRef.current + 1 accessGrantLoadIdRef.current = loadId - setIsLoadingAccessGrants(true) + if (mountedRef.current) { + setIsLoadingAccessGrants(true) + } try { const result = await window.api.mobile.listRuntimeAccessGrants() - if (loadId === accessGrantLoadIdRef.current) { + if (mountedRef.current && loadId === accessGrantLoadIdRef.current) { setRuntimeAccessGrants(result.grants) } } catch (error) { - if (loadId === accessGrantLoadIdRef.current && options.showToastOnError) { + if ( + mountedRef.current && + loadId === accessGrantLoadIdRef.current && + options.showToastOnError + ) { toast.error( error instanceof Error ? error.message : 'Failed to load shared access grants.' ) } } finally { - if (loadId === accessGrantLoadIdRef.current) { + if (mountedRef.current && loadId === accessGrantLoadIdRef.current) { setIsLoadingAccessGrants(false) } } }, - [] + [mountedRef] ) const loadNetworkInterfaces = useCallback( async (options: { showToastOnError?: boolean } = {}): Promise => { const loadId = networkInterfaceLoadIdRef.current + 1 networkInterfaceLoadIdRef.current = loadId - setRefreshingNetworkInterfaces(true) + if (mountedRef.current) { + setRefreshingNetworkInterfaces(true) + } try { const result = await window.api.mobile.listNetworkInterfaces() - if (loadId === networkInterfaceLoadIdRef.current) { + if (mountedRef.current && loadId === networkInterfaceLoadIdRef.current) { setNetworkInterfaces(result.interfaces) } } catch { - if (loadId === networkInterfaceLoadIdRef.current && options.showToastOnError) { + if ( + mountedRef.current && + loadId === networkInterfaceLoadIdRef.current && + options.showToastOnError + ) { toast.error('Failed to refresh network interfaces.') } } finally { - if (loadId === networkInterfaceLoadIdRef.current) { + if (mountedRef.current && loadId === networkInterfaceLoadIdRef.current) { setRefreshingNetworkInterfaces(false) } } }, - [] + [mountedRef] ) useEffect(() => { @@ -190,9 +209,11 @@ export function RuntimePairingUrlGenerator({ runtimePairingUrlCache.runtimePairingUrl = null runtimePairingUrlCache.webClientUrl = null runtimePairingUrlCache.runtimePairingDeviceId = null - setRuntimePairingUrl(null) - setWebClientUrl(null) - setRuntimePairingDeviceId(null) + if (mountedRef.current) { + setRuntimePairingUrl(null) + setWebClientUrl(null) + setRuntimePairingDeviceId(null) + } } const generateRuntimePairingUrl = async (): Promise => { @@ -205,21 +226,31 @@ export function RuntimePairingUrlGenerator({ }) if (!result.available) { clearGeneratedUrls() - toast.error('Runtime pairing is unavailable.') + if (mountedRef.current) { + toast.error('Runtime pairing is unavailable.') + } return } runtimePairingUrlCache.runtimePairingUrl = result.pairingUrl runtimePairingUrlCache.webClientUrl = result.webClientUrl runtimePairingUrlCache.runtimePairingDeviceId = result.deviceId - setRuntimePairingUrl(result.pairingUrl) - setWebClientUrl(result.webClientUrl) - setRuntimePairingDeviceId(result.deviceId) + if (mountedRef.current) { + setRuntimePairingUrl(result.pairingUrl) + setWebClientUrl(result.webClientUrl) + setRuntimePairingDeviceId(result.deviceId) + } await loadRuntimeAccessGrants() - toast.success(result.webClientUrl ? 'Generated web client URL.' : 'Generated pairing URL.') + if (mountedRef.current) { + toast.success(result.webClientUrl ? 'Generated web client URL.' : 'Generated pairing URL.') + } } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to generate pairing URL.') + if (mountedRef.current) { + toast.error(error instanceof Error ? error.message : 'Failed to generate pairing URL.') + } } finally { - setIsGeneratingPairing(false) + if (mountedRef.current) { + setIsGeneratingPairing(false) + } } } @@ -228,31 +259,45 @@ export function RuntimePairingUrlGenerator({ try { const result = await window.api.mobile.revokeRuntimeAccess({ deviceId: grant.deviceId }) if (!result.revoked) { - toast.error('Shared access was already revoked.') + if (mountedRef.current) { + toast.error('Shared access was already revoked.') + } await loadRuntimeAccessGrants() return } - setRuntimeAccessGrants((current) => - current.filter((entry) => entry.deviceId !== grant.deviceId) - ) + if (mountedRef.current) { + setRuntimeAccessGrants((current) => + current.filter((entry) => entry.deviceId !== grant.deviceId) + ) + } if (runtimePairingDeviceId === grant.deviceId) { clearGeneratedUrls() } - toast.success('Shared access revoked.') + if (mountedRef.current) { + toast.success('Shared access revoked.') + } } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to revoke shared access.') + if (mountedRef.current) { + toast.error(error instanceof Error ? error.message : 'Failed to revoke shared access.') + } } finally { - setRevokingGrantId(null) + if (mountedRef.current) { + setRevokingGrantId(null) + } } } const copyGeneratedUrl = async (target: 'web' | 'pairing', value: string): Promise => { try { await window.api.ui.writeClipboardText(value) - setCopiedTarget(target) - toast.success(target === 'web' ? 'Copied web client URL.' : 'Copied pairing URL.') + if (mountedRef.current) { + setCopiedTarget(target) + toast.success(target === 'web' ? 'Copied web client URL.' : 'Copied pairing URL.') + } } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to copy URL.') + if (mountedRef.current) { + toast.error(error instanceof Error ? error.message : 'Failed to copy URL.') + } } }