brackt/app/hooks/useDraftNotifications.ts
Chris Parsons ca2fd288ab
perf: fix draft room lag from excessive re-renders and listener leaks (#54)
Primary fix: setTeamTimers now bails out with `return prev` when the
value hasn't changed, preventing a full DraftRoom re-render on every
1-second timer tick (was 33% of profiler samples).

Memoization: wrap AvailableParticipantsSection, TeamsDraftedGrid,
QueueSection, SidebarRecentPicks, and DraftGridSection in React.memo
so timer ticks don't cascade into heavy components that don't use
timer state.

Stable refs: wrap nine action handlers in useCallback and extract two
inline arrow functions from props objects so memo() comparisons
actually bail out. Memoize the { numFlexPicks } object passed to
TeamsDraftedGrid.

socketVersion: expose an incrementing counter from useDraftSocket so
the socket handler effect re-registers on socket recreation.

Async cleanup: add abort flag + in-flight guard to the visibilitychange
JWT refresh handler to prevent concurrent executions and stale state
updates after unmount. Add abort flag to useDraftNotifications
permissions.query() to prevent dangling onchange if unmounted
mid-promise.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 13:18:47 -08:00

136 lines
4 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
export type NotificationMode = "my_turn" | "all_picks";
function getEnabledKey(userId: string, seasonId: string) {
return `draftNotifications-${userId}-${seasonId}`;
}
function getModeKey(userId: string, seasonId: string) {
return `draftNotificationMode-${userId}-${seasonId}`;
}
export function useDraftNotifications(seasonId: string, userId: string) {
const [permissionState, setPermissionState] = useState<
NotificationPermission | "unsupported"
>("unsupported");
const [enabled, setEnabledState] = useState(false);
const [mode, setModeState] = useState<NotificationMode>("my_turn");
// Check browser support, restore persisted preferences, and watch for permission changes
useEffect(() => {
if (typeof window === "undefined" || !("Notification" in window)) {
return;
}
setPermissionState(Notification.permission);
// Restore preferences from localStorage only if permission is granted
if (Notification.permission === "granted") {
const storedEnabled = localStorage.getItem(getEnabledKey(userId, seasonId));
if (storedEnabled === "true") {
setEnabledState(true);
}
}
const storedMode = localStorage.getItem(getModeKey(userId, seasonId));
if (storedMode === "my_turn" || storedMode === "all_picks") {
setModeState(storedMode);
}
// Watch for the user revoking/granting permission in browser settings.
// Use an abort flag so that if the component unmounts before the promise
// resolves, the cleanup doesn't fail to clear onchange (permissionStatus
// would still be null at that point without the flag).
let aborted = false;
let permissionStatus: PermissionStatus | null = null;
navigator.permissions
.query({ name: "notifications" })
.then((status) => {
if (aborted) return;
permissionStatus = status;
status.onchange = () => {
setPermissionState(status.state as NotificationPermission);
// If permission was revoked, disable notifications
if (status.state !== "granted") {
setEnabledState(false);
}
};
})
.catch(() => {
// Permissions API not available in all environments; silently ignore
});
return () => {
aborted = true;
if (permissionStatus) {
permissionStatus.onchange = null;
}
};
}, [seasonId, userId]);
const setEnabled = useCallback(
async (value: boolean) => {
if (typeof window === "undefined" || !("Notification" in window)) return;
if (value) {
// Request permission if not yet granted
if (Notification.permission === "default") {
const result = await Notification.requestPermission();
setPermissionState(result);
if (result !== "granted") {
return;
}
} else if (Notification.permission === "denied") {
return;
}
setEnabledState(true);
localStorage.setItem(getEnabledKey(userId, seasonId), "true");
} else {
setEnabledState(false);
localStorage.setItem(getEnabledKey(userId, seasonId), "false");
}
},
[userId, seasonId]
);
const setMode = useCallback(
(value: NotificationMode) => {
setModeState(value);
localStorage.setItem(getModeKey(userId, seasonId), value);
},
[userId, seasonId]
);
const sendNotification = useCallback(
(title: string, body: string) => {
if (
!enabled ||
typeof window === "undefined" ||
!("Notification" in window) ||
Notification.permission !== "granted" ||
typeof document === "undefined" ||
!document.hidden
) {
return;
}
const n = new Notification(title, {
body,
tag: `draft-${seasonId}`,
});
n.onclick = () => window.focus();
},
[enabled, seasonId]
);
return {
permissionState,
enabled,
setEnabled,
mode,
setMode,
sendNotification,
};
}