fix(web): debounce devicechange handler and gate listener on baseline seeding

This commit is contained in:
Jannis Braun
2026-05-03 01:05:37 +02:00
parent d992b2bb41
commit 674d011d3c
@@ -113,6 +113,12 @@ export function AppLayout() {
const TOAST_DEBOUNCE_MS = 1000; const TOAST_DEBOUNCE_MS = 1000;
const TOAST_DEDUPE_WINDOW_MS = 30_000; const TOAST_DEDUPE_WINDOW_MS = 30_000;
let pendingDebounceTimer: ReturnType<typeof setTimeout> | null = null; let pendingDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Handler-level debounce: collapses devicechange storms (BT re-pair, USB
// hub enumeration can fire 5-10x/sec) into a single prune + reacquire pass.
// The toast logic has its own longer debounce (TOAST_DEBOUNCE_MS) layered
// on top so user-visible toasts collapse storms even more aggressively.
let pendingHandlerTimer: ReturnType<typeof setTimeout> | null = null;
const HANDLER_DEBOUNCE_MS = 250;
const seedBaseline = async () => { const seedBaseline = async () => {
try { try {
@@ -172,22 +178,35 @@ export function AppLayout() {
} }
}; };
const handler = async () => { const handler = () => {
await prune(); if (pendingHandlerTimer) clearTimeout(pendingHandlerTimer);
await reacquireLiveStream(); pendingHandlerTimer = setTimeout(async () => {
// Debounce toast logic so a single plug event that emits multiple await prune();
// devicechange fires (Bluetooth re-pair storms) collapses to one toast. await reacquireLiveStream();
if (pendingDebounceTimer) clearTimeout(pendingDebounceTimer); if (pendingDebounceTimer) clearTimeout(pendingDebounceTimer);
pendingDebounceTimer = setTimeout(() => { handleNewDeviceToasts(); }, TOAST_DEBOUNCE_MS); pendingDebounceTimer = setTimeout(() => { handleNewDeviceToasts(); }, TOAST_DEBOUNCE_MS);
}, HANDLER_DEBOUNCE_MS);
}; };
// Seed baseline so the first event after mount doesn't false-positive every // Seed baseline so the first event after mount doesn't false-positive every
// existing device as "new". // existing device as "new". Register the listener only AFTER baseline is
seedBaseline().then(() => prune()); // seeded so the first real event compares against a populated set.
navigator.mediaDevices.addEventListener('devicechange', handler); let listenerRegistered = false;
let cancelled = false;
seedBaseline()
.then(() => prune())
.then(() => {
if (cancelled) return;
navigator.mediaDevices.addEventListener('devicechange', handler);
listenerRegistered = true;
});
return () => { return () => {
cancelled = true;
if (pendingHandlerTimer) clearTimeout(pendingHandlerTimer);
if (pendingDebounceTimer) clearTimeout(pendingDebounceTimer); if (pendingDebounceTimer) clearTimeout(pendingDebounceTimer);
navigator.mediaDevices.removeEventListener('devicechange', handler); if (listenerRegistered) {
navigator.mediaDevices.removeEventListener('devicechange', handler);
}
}; };
}, []); }, []);