fix: patch memory leaks in event hooks and strengthen type guard

Fix three bugs found during code review:

1. useTauriEvent/useTauriEvents: If the component unmounts before the
   async listen() promise resolves, the unlisten function was lost,
   leaking the event subscription. Added a cancelled flag to call
   unlisten immediately when the promise resolves after cleanup.

2. useTauriEvents: The handlers object was used directly as a useEffect
   dependency, causing re-subscription on every render when callers
   pass an inline object literal. Replaced with a useRef for handler
   stability and a derived eventNames string as the dependency.

3. isMcError type guard: Only checked property existence via 'in'
   operator, not property types. An object with wrong-typed properties
   (e.g. code: 42) would pass the guard. Now validates that code and
   message are strings and recoverable is boolean.

4. AppShell global shortcut listener: Same race condition as (1), plus
   missing .catch() on the listen promise could produce unhandled
   rejections.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 10:12:20 -05:00
parent 378a173084
commit 29b44f1b4c
3 changed files with 69 additions and 44 deletions

View File

@@ -63,12 +63,12 @@ export interface McError {
/** Type guard to check if an error is a structured McError */
export function isMcError(err: unknown): err is McError {
if (typeof err !== "object" || err === null) return false;
const obj = err as Record<string, unknown>;
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
"message" in err &&
"recoverable" in err
typeof obj.code === "string" &&
typeof obj.message === "string" &&
typeof obj.recoverable === "boolean"
);
}
@@ -156,6 +156,10 @@ export function computeStaleness(updatedAt: string | null): Staleness {
if (!updatedAt) return "normal";
const ageMs = Date.now() - new Date(updatedAt).getTime();
// Guard against invalid date strings (NaN propagates through arithmetic)
if (Number.isNaN(ageMs)) return "normal";
const ageDays = ageMs / (1000 * 60 * 60 * 24);
if (ageDays < 1) return "fresh";