From a10024fbd8b35dc72a7bfa30af3aef91e9b1b8c2 Mon Sep 17 00:00:00 2001 From: James Pine Date: Mon, 30 Mar 2026 21:20:05 -0700 Subject: [PATCH] fix: clean up scroll effect timers and fix disabled+selected card toggle - Add cleanup for requestAnimationFrame and setTimeout in scroll effect to prevent stale DOM writes on unmount or rapid selection changes - Fix disabled+selected card click: bounce the selection to re-trigger the engine auto-switch instead of deselecting Co-Authored-By: Claude Opus 4.6 (1M context) --- app/src/components/VoiceProfiles/ProfileCard.tsx | 6 ++++++ app/src/components/VoiceProfiles/ProfileList.tsx | 11 +++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index c55382d5..b3d5ffee 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -41,6 +41,12 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { const isSelected = selectedProfileId === profile.id; const handleSelect = () => { + // If disabled but already selected, bounce the selection to re-trigger engine auto-switch + if (disabled && isSelected) { + setSelectedProfileId(null); + setTimeout(() => setSelectedProfileId(profile.id), 0); + return; + } setSelectedProfileId(isSelected ? null : profile.id); }; diff --git a/app/src/components/VoiceProfiles/ProfileList.tsx b/app/src/components/VoiceProfiles/ProfileList.tsx index 51d4469d..3c18a296 100644 --- a/app/src/components/VoiceProfiles/ProfileList.tsx +++ b/app/src/components/VoiceProfiles/ProfileList.tsx @@ -20,17 +20,20 @@ export function ProfileList() { // Scroll to the selected profile after engine/sort changes useEffect(() => { if (!selectedProfileId) return; - // Wait a frame for the DOM to update after re-sort - requestAnimationFrame(() => { + let timeoutId: ReturnType | null = null; + const rafId = requestAnimationFrame(() => { const el = cardRefs.current.get(selectedProfileId); if (!el) return; // Temporarily apply scroll-margin so it doesn't land flush at the top el.style.scrollMarginTop = '180px'; el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); - // Clean up after scroll completes - setTimeout(() => { el.style.scrollMarginTop = ''; }, 500); + timeoutId = setTimeout(() => { el.style.scrollMarginTop = ''; }, 500); }); + return () => { + cancelAnimationFrame(rafId); + if (timeoutId) clearTimeout(timeoutId); + }; }, [selectedProfileId, selectedEngine]); if (isLoading) {