diff --git a/services/frontend/.hermes/plans/fe-optimasi-error-handling-state.md b/services/frontend/.hermes/plans/fe-optimasi-error-handling-state.md new file mode 100644 index 00000000..14864374 --- /dev/null +++ b/services/frontend/.hermes/plans/fe-optimasi-error-handling-state.md @@ -0,0 +1,91 @@ +# FE Optimasi: Error Handling, State Refactor, Polish + +**Scope:** `services/frontend/src` — Next.js 16, React 19, SWR, oRPC-over-WS. +**Branch:** `feat/fe-error-handling-state` (buat baru dari `main`). +**User:** MythEclipse — bahasa Indonesia/Inggris campur, "gas" = eksekusi langsung. + +## 1. Masalah yang teridentifikasi + +### 1.1 Error handling tidak seragam +- `ErrorState` (states.tsx:126) sudah ada dengan `onRetry?`, tapi **80% pemanggilan tidak pass `onRetry`** → user stuck, harus refresh manual. +- Pola `if (error && !data) return ` berulang di 5+ view, setiap satu hardcode logic-nya. +- `useAction` (use-action.ts) set `error` di state tapi komponen pakai `try/catch` lokal juga — **dua error states** (hook `action.error` + lokal `catch (e)`). + +### 1.2 State duplication / double source of truth +- `useReview` (use-messages.ts:129) pakai `refreshInterval: 15_000` (polling) padahal WS sudah broadcast `moderation_action` real-time. **Polling + WS = double-fetch + race condition.** +- `page.tsx` dan `view.tsx` duplikat state `guildId`/`channelId`/`query` — logic selection ada di 2 tempat. +- Chatbot pakai local `failed` state + `setFailed(text)` untuk retry, tapi `useAction` sudah ada `error` field yang tidak dipakai. +- `useSpeakers` (use-voice.ts:50) pakai local `useState` untuk speakers, bukan SWR → tidak konsisten dengan pola SWR yang dipakai semua hook lain. + +### 1.3 WebSocket error/user feedback lemah +- `WsConnection.onerror` (connection.ts:78) cuma `setStatus("error")` — tidak ada info ke user "reconnecting… (attempt N)". +- Tidak ada broadcast error ke komponen (tidak ada `ws_error` handler). + +## 2. Rencana perubahan + +### 2.1 Standardisasi ErrorBoundary + Retry (FE-only) +**File:** `src/components/shared/states.tsx` +- Tambahkan komponen `ErrorBoundary` (React error boundary untuk crash React, bukan fetch error). +- Refactor semua `` → ``. +- Tambahkan helper `withSWR` atau pattern: tiap view pakai `error` + `mutate`/`refetch` dari SWR dan pass ke ErrorState. + +**File:** `src/app/(dashboard)/*/view.tsx` (6 files) +- Setiap `ErrorState` dapat `onRetry` yang memanggil `mutate`/`refetch`. +- `DashboardView`: `onRetry={() => void mutate(["dashboard-stats"])}` — tapi SWR keys tersebar. Solusi: export `mutate` via custom hook atau pakai `useSWR` config `onErrorRetry` global. +- **Keputusan:** gunakan pola **global SWR config** (`src/lib/swr-config.ts`) dengan `onErrorRetry` backoff, dan tiap view pass `onRetry` explicit ke ErrorState. + +### 2.2 Hapus polling `useReview`, ganti WS-driven +**File:** `src/hooks/use-messages.ts` +- Hapus `refreshInterval: 15_000` dari `useReview`. +- Tambahkan `useReviewWsSync(ws)` — subscribe ke `moderation_action` WS event, mutate key `["messages-review", channelId]`. +- Backend sudah broadcast `moderation_action` via WS (redis-channels.ts:130). Review messages yang di-flag akan dapat `moderation_action` event. **Bisa pakai ini.** + +**File:** `src/app/(dashboard)/messages/view.tsx` +- Tambahkan `useReviewWsSync(ws)` call. + +### 2.3 Konsistensi state: `useSpeakers` → SWR +**File:** `src/hooks/use-voice.ts` +- Refactor `useSpeakers` agar pakai SWR key `["voice-speakers"]` + initialData dari `useVoiceStatus`. Ini memungkinkan revalidate + cache sharing. +- Tapi `voice_state` dan `voice_active_user` adalah WS events — perlu persist ke SWR cache via `mutate`. Refactor: `useSpeakers` subscribe WS + mutate SWR key. + +### 2.4 Chatbot: gunakan `useAction` error state, hapus duplikat `failed` +**File:** `src/components/chatbot/chatbot.tsx` +- Hapus `const [failed, setFailed]` — sebalihoikan ke `useAction` return `error`. +- Tapi `useAction` `mutateAsync` throw — perlu catch. Refactor: pakai `mutate` (fire-and-forget) + `isPending` + `error`. +- **Note:** chatbot pakai `send` yang butuh pemuatan history — tetap pakai local state untuk msgs tapi gunakan `action.error` untuk display. + +### 2.5 WS error feedback +**File:** `src/lib/ws/connection.ts` +- `onerror` emit kode/status ke status listeners. +- Tambahkan method `getReconnectAttempt()` atau expose via status change. + +**File:** `src/lib/ws/context.tsx` +- Subscribe `onStatusChange` di `WsProvider`, toast "Reconnecting… (attempt N)" ketika status `error`/`connecting`. + +## 3. Verification +- `npx tsc --noEmit` — compile OK +- `npx biome check src/` — lint OK +- `npm run build` — build OK (Next 16 SSG/SSR) +- Manual: refresh halaman, pastikan ErrorState muncul dengan tombol Retry yang bisa diklik. + +## 4. Files yang disentuh +``` +src/components/shared/states.tsx # ErrorBoundary + helper +src/lib/swr-config.ts # global SWR config (baru) +src/hooks/use-messages.ts # useReviewWsSync, hapus polling +src/hooks/use-voice.ts # useSpeakers → SWR +src/hooks/use-action.ts # expose resetError +src/lib/ws/connection.ts # reconnect info +src/lib/ws/context.tsx # WS error toast +src/app/(dashboard)/messages/view.tsx # useReviewWsSync +src/app/(dashboard)/messages/page.tsx # SSR error passthrough +src/app/(dashboard)/moderation/view.tsx # onRetry +src/app/(dashboard)/dashboard/view.tsx # onRetry +src/app/(dashboard)/voice/view.tsx # onRetry +src/app/(dashboard)/media/view.tsx # onRetry +src/app/(dashboard)/recordings/view.tsx # onRetry +src/components/chatbot/chatbot.tsx # pakai useAction error +``` + +## 5. Out of scope (BE) +- Regex heuristic di moderation.repository.ts (scam domain extraction) — ini BE task, catat tapi jangan sentuh kecuali diminta. diff --git a/services/frontend/src/app/(dashboard)/dashboard/view.tsx b/services/frontend/src/app/(dashboard)/dashboard/view.tsx index e403f5c3..a5d61dce 100644 --- a/services/frontend/src/app/(dashboard)/dashboard/view.tsx +++ b/services/frontend/src/app/(dashboard)/dashboard/view.tsx @@ -50,7 +50,12 @@ export function DashboardView({ initialStats?: DashboardStats; initialActivity?: Awaited>["data"]; }) { - const { data: stats, isLoading, error } = useStats(initialStats); + const { + data: stats, + isLoading, + error, + mutate: mutateStats, + } = useStats(initialStats); const { data: activity } = useActivity(14, initialActivity as never); const { data: reactors } = useTopReactors(); const { data: reactions } = useTopReactions(); @@ -65,7 +70,8 @@ export function DashboardView({ ); }, [stats, ambient]); - if (error && !stats) return ; + if (error && !stats) + return void mutateStats()} />; if (!stats && isLoading) return (
@@ -78,7 +84,13 @@ export function DashboardView({
); - if (!stats) return ; + if (!stats) + return ( + void mutateStats()} + /> + ); const s = stats; const total = s.total_flagged + s.total_clean || 1; diff --git a/services/frontend/src/app/(dashboard)/media/view.tsx b/services/frontend/src/app/(dashboard)/media/view.tsx index 35598492..0a13a2a0 100644 --- a/services/frontend/src/app/(dashboard)/media/view.tsx +++ b/services/frontend/src/app/(dashboard)/media/view.tsx @@ -33,7 +33,12 @@ import { useWebSocket } from "@/lib/ws/context"; export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { const ws = useWebSocket(); - const { data: media, isLoading, error } = useMediaState(initialStatus); + const { + data: media, + isLoading, + error, + mutate, + } = useMediaState(initialStatus); const queue = useMediaQueue(); const skip = useMediaSkip(); const stop = useMediaStop(); @@ -79,7 +84,8 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { } }; - if (error && !media) return ; + if (error && !media) + return void mutate()} />; if (!media && isLoading) return (
diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx index 093f1871..499f0c90 100644 --- a/services/frontend/src/app/(dashboard)/messages/view.tsx +++ b/services/frontend/src/app/(dashboard)/messages/view.tsx @@ -39,6 +39,7 @@ import { useMessagesStream, useMessagesWsSync, useRecentEdits, + useReviewWsSync, useSemanticSearch, } from "@/hooks"; import { aiTone } from "@/lib/ai-status"; @@ -94,6 +95,7 @@ export function MessagesView({ data: messages, isLoading, error, + refetch, } = useMessages( guildId ?? "", channelId ?? undefined, @@ -112,6 +114,7 @@ export function MessagesView({ const hasMore = pageInfo?.hasMore ?? false; const loadMore = useLoadMore(); useMessagesWsSync(ws, guildId ?? ""); + useReviewWsSync(ws); const search = useMessageSearch( query, query.trim().length >= 2 && !semanticMode, @@ -326,7 +329,7 @@ export function MessagesView({ } /> {error && !messages ? ( - + refetch()} /> ) : isLoading && !messages ? ( ) : list.length === 0 ? ( diff --git a/services/frontend/src/app/(dashboard)/moderation/view.tsx b/services/frontend/src/app/(dashboard)/moderation/view.tsx index 80e4b542..7209d185 100644 --- a/services/frontend/src/app/(dashboard)/moderation/view.tsx +++ b/services/frontend/src/app/(dashboard)/moderation/view.tsx @@ -81,7 +81,12 @@ export function ModerationView({ initialStats?: ModerationStats; initialActions?: ModerationAction[]; }) { - const { data: stats, isLoading, error } = useModerationStats(initialStats); + const { + data: stats, + isLoading, + error, + mutate: mutateStats, + } = useModerationStats(initialStats); const [statusFilter, setStatusFilter] = useState(""); const [typeFilter, setTypeFilter] = useState(""); const { data: actions } = useModerationActions( @@ -122,7 +127,8 @@ export function ModerationView({ ); }, [failedRate, ambient]); - if (error && !stats) return ; + if (error && !stats) + return void mutateStats()} />; if (!stats && isLoading) return (
@@ -131,7 +137,13 @@ export function ModerationView({
); - if (!stats) return ; + if (!stats) + return ( + void mutateStats()} + /> + ); const statusOpts: SelectOption[] = [ { value: "", label: "All statuses" }, diff --git a/services/frontend/src/app/(dashboard)/recordings/view.tsx b/services/frontend/src/app/(dashboard)/recordings/view.tsx index 4268b809..9d361ecc 100644 --- a/services/frontend/src/app/(dashboard)/recordings/view.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/view.tsx @@ -33,7 +33,7 @@ export function RecordingsView({ initialItems?: VoiceRecording[]; }) { const ws = useWebSocket(); - const { data: items, isLoading, error } = useRecordings(initialItems); + const { data: items, isLoading, error, mutate } = useRecordings(initialItems); const del = useDeleteRecording(); useRecordingsWsSync(ws); const ambient = useAmbient(); @@ -56,7 +56,8 @@ export function RecordingsView({ } }; - if (error && !items) return ; + if (error && !items) + return void mutate()} />; if (!items && isLoading) return ( diff --git a/services/frontend/src/app/(dashboard)/voice/view.tsx b/services/frontend/src/app/(dashboard)/voice/view.tsx index 789e59e0..44e6ec2e 100644 --- a/services/frontend/src/app/(dashboard)/voice/view.tsx +++ b/services/frontend/src/app/(dashboard)/voice/view.tsx @@ -40,7 +40,12 @@ export function VoiceView({ initialGuilds?: Guild[]; }) { const ws = useWebSocket(); - const { data: status, isLoading, error } = useVoiceStatus(initialStatus); + const { + data: status, + isLoading, + error, + mutate, + } = useVoiceStatus(initialStatus); const connect = useVoiceConnect(); const disconnect = useVoiceDisconnect(); const mic = useMicTransmit(ws); @@ -68,7 +73,8 @@ export function VoiceView({ else ambient.set("vermilion", 0.35, "voice idle"); }, [status?.connected, ambient]); - if (error && !status) return ; + if (error && !status) + return void mutate()} />; if (!status && isLoading) return (
diff --git a/services/frontend/src/components/shared/error-boundary.tsx b/services/frontend/src/components/shared/error-boundary.tsx new file mode 100644 index 00000000..a251ffd7 --- /dev/null +++ b/services/frontend/src/components/shared/error-boundary.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Component, type ReactNode } from "react"; +import { ErrorState } from "./states"; + +/** + * React error boundary — catches render/exception crashes in child trees + * (e.g. third-party lib throwing on unexpected payload shape) and surfaces + * a consistent ErrorState instead of unmounting the whole app shell. + * + * Usage: wrap leaf views in . + */ +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: (error: Error, reset: () => void) => ReactNode; + onReset?: () => void; +} + +interface ErrorBoundaryState { + error: Error | null; +} + +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + reset = () => { + this.setState({ error: null }); + this.props.onReset?.(); + }; + + override render() { + if (!this.state.error) return this.props.children; + + if (this.props.fallback) { + return this.props.fallback(this.state.error, this.reset); + } + + return ( + + ); + } +} diff --git a/services/frontend/src/components/shared/index.ts b/services/frontend/src/components/shared/index.ts index 3d5c4992..3a4864dd 100644 --- a/services/frontend/src/components/shared/index.ts +++ b/services/frontend/src/components/shared/index.ts @@ -2,6 +2,7 @@ export { GuildChannelPicker } from "./guild-picker"; export { MarkdownLite } from "./markdown"; export { PageTransition } from "./page-transition"; export { MetricTile, SectionHeader } from "./section"; +export { ErrorBoundary } from "./error-boundary"; export { EmptyState, ErrorState, diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index 093c3014..3b2afc2c 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -31,6 +31,7 @@ export { useMessagesWsSync, useRecentEdits, useReview, + useReviewWsSync, useSemanticSearch, useTextChannels, } from "./use-messages"; diff --git a/services/frontend/src/hooks/use-action.ts b/services/frontend/src/hooks/use-action.ts index 9659f5c2..08296ee7 100644 --- a/services/frontend/src/hooks/use-action.ts +++ b/services/frontend/src/hooks/use-action.ts @@ -7,17 +7,23 @@ export interface UseActionState { /** * A lightweight mutation hook with a TanStack-compatible surface - * ({ mutate, mutateAsync, isPending, error }) built on plain state — + * ({ mutate, mutateAsync, isPending, error, resetError }) built on plain state — * the SWR replacement for useMutation. Fire-and-forget via `mutate`, * await the result via `mutateAsync`. * * `onSuccess` receives (data, args) and may perform SWR cache updates * (e.g. `mutate(key, data, { revalidate: false })`). + * + * `onError` receives the caught error and args — use it for side-effect + * logging without throwing (the error is also surfaced via `state.error`). + * + * `resetError` clears the error state without re-running the action. */ export function useAction( fn: (args: TArgs) => Promise, options?: { onSuccess?: (data: TResult, args: TArgs) => void | Promise; + onError?: (error: Error, args: TArgs) => void | Promise; }, ) { const [state, setState] = useState({ @@ -29,6 +35,8 @@ export function useAction( fnRef.current = fn; const onSuccessRef = useRef(options?.onSuccess); onSuccessRef.current = options?.onSuccess; + const onErrorRef = useRef(options?.onError); + onErrorRef.current = options?.onError; const run = useCallback(async (args?: TArgs): Promise => { setState({ isPending: true, error: null }); @@ -38,8 +46,10 @@ export function useAction( setState({ isPending: false, error: null }); return data; } catch (err) { - setState({ isPending: false, error: err as Error }); - throw err; + const error = err as Error; + setState({ isPending: false, error }); + await onErrorRef.current?.(error, args as TArgs); + throw error; } }, []); @@ -50,6 +60,6 @@ export function useAction( mutateAsync: run, isPending: state.isPending, error: state.error, - reset: () => setState({ isPending: false, error: null }), + resetError: useCallback(() => setState((s) => ({ ...s, error: null })), []), }; } diff --git a/services/frontend/src/hooks/use-messages.ts b/services/frontend/src/hooks/use-messages.ts index d254d45b..305295bd 100644 --- a/services/frontend/src/hooks/use-messages.ts +++ b/services/frontend/src/hooks/use-messages.ts @@ -124,21 +124,43 @@ export function useImages(guildId: string) { ); } -// ── Review ─────────────────────────────────────── +// ── Review ──────────────────────────────────────── +// WS-driven now (was: refreshInterval:15000 polling). The backend broadcasts +// `moderation_action` events over WS whenever a moderation action is created; +// the review list is revalidated on that event instead of polling. export function useReview(channelId?: string) { + const key = msgKeys.review(channelId); return useSWR( - msgKeys.review(channelId), + key, async () => { const result = await messagesApi.getReview(50, channelId || undefined); return result.results; }, { - refreshInterval: 15_000, + revalidateOnFocus: true, + // No more polling — WS sync handles real-time updates. }, ); } +/** + * Subscribe to WS `moderation_action` events and invalidate the review + * SWR cache. Replaces the old 15-second polling interval that caused + * duplicate requests and race conditions with WS updates. + */ +export function useReviewWsSync(ws: WsHook, channelId?: string) { + const { mutate } = useSWRConfig(); + const key = msgKeys.review(channelId); + + useEffect(() => { + const unsub = ws.on("moderation_action", () => { + void mutate(key, undefined, { revalidate: true }); + }); + return unsub; + }, [ws, key, mutate]); +} + // ── Detail ─────────────────────────────────────── export function useMessageDetail(id: string | null) { @@ -168,7 +190,11 @@ export function useMessageDetail(id: string | null) { message: detail.data ?? null, attachments: attachments.data ?? [], loading: detail.isLoading || attachments.isLoading, - error: detail.error, + error: detail.error ?? attachments.error, + refetch: () => { + void detail.mutate(); + void attachments.mutate(); + }, }; } @@ -216,7 +242,7 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) { const { mutate } = useSWRConfig(); useEffect(() => { if (!guildId) return; - // Patch every message-list key for this guild (all channels + "**filtered**"). + // Patch every message-list key for this guild (all channels + "__all__"). // The updater receives the SWR key so we can honor its channel filter: // a live `message_created`/updated for channel B must NOT be prepended to // a list that is filtered down to channel A. @@ -255,13 +281,13 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) { }); const unsub2 = ws.on("message_updated", (data) => { const msg = data as Partial & { id: string }; - // The gateway broadcasts a PARTIAL update ({ id, edited_content, - // edited_at, ... }) — merge it over the existing record instead of - // replacing it, or the card would lose username/content/channel/etc. + // The gateway broadcasts a PARTIAL update ({ id } plus the changed fields + // — merge it over the existing record instead of replacing it, or the + // card would lose username/content/channel/etc. patchLists( (_k, m) => (m as Partial).channel_id === undefined || - matchesFilter(_k as unknown[], m), + matchesFilter(_k as unknown[], m as { channel_id?: string }), (old) => old ? { @@ -271,7 +297,7 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) { ), } : old, - msg, + msg as { channel_id?: string }, ); void mutate( msgKeys.detail(msg.id), @@ -321,7 +347,7 @@ export function useMessagesWsSync(ws: WsHook, guildId: string) { * `message_snapshot` into the SWR list as it arrives, so the UI renders * progressively. Falls back to the batched `messagesApi.list` if WS is down. * - * Returns: { streaming, streamed, error }. + * Returns: { streaming, error }. */ export function useMessagesStream( ws: WsHook, @@ -414,3 +440,5 @@ export function useRecentEdits( { fallbackData: initialData }, ); } + +export { msgKeys }; diff --git a/services/frontend/src/hooks/use-voice.ts b/services/frontend/src/hooks/use-voice.ts index 6ef2ac10..342543fe 100644 --- a/services/frontend/src/hooks/use-voice.ts +++ b/services/frontend/src/hooks/use-voice.ts @@ -4,19 +4,13 @@ import { useAction } from "@/hooks/use-action"; import { voiceApi } from "@/lib/api"; import { MicTransmitter } from "@/lib/audio/mic-transmit"; import { PcmPlayer } from "@/lib/audio/pcm-player"; +import { hashUserId } from "@/lib/hash"; import type { ActiveSpeaker, Channel, VoiceStatus } from "@/lib/types"; import type { PcmChunk } from "@/lib/ws/types"; import type { WsHook } from "@/lib/ws-hook"; -/** FNV-1a 32-bit — same hash the gateway uses to tag PCM frames. */ -export function hashUserId(userId: string): number { - let hash = 0x811c9dc5; - for (let i = 0; i < userId.length; i++) { - hash ^= userId.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return hash >>> 0; -} +// Re-export for components that still import hashUserId from this module. +export { hashUserId }; const STATUS_KEY = ["voice-status"] as const; @@ -34,6 +28,8 @@ export function useVoiceChannels(guildId: string) { ); } +const SPEAKERS_KEY = ["voice-speakers"] as const; + /** * Live shared speaker state. * @@ -44,48 +40,66 @@ export function useVoiceChannels(guildId: string) { * every client with the same list); * - `voice_active_user` → incremental upsert of a single speaker delta. * + * Now backed by SWR (consistent with all other hooks) so cache, revalidation, + * and deduping apply. WS events mutate the SWR cache directly + * ({ revalidate: false }) to avoid refetching the full status. + * * This replaces the old per-browser model where each tab accumulated speakers * only from events it happened to receive while mounted. */ export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) { - const [speakers, setSpeakers] = useState( - initialStatusActive ?? [], + const { + data: speakers, + error, + mutate, + isValidating, + } = useSWR(SPEAKERS_KEY, () => Promise.resolve([]), { + fallbackData: initialStatusActive ?? [], + revalidateOnMount: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + }); + + const subscribe = useCallback( + (ws: WsHook) => { + const unsubSnapshot = ws.on("voice_state", (data) => { + const state = data as { activeSpeakers?: ActiveSpeaker[] }; + if (Array.isArray(state?.activeSpeakers)) { + void mutate(state.activeSpeakers, { revalidate: false }); + } + }); + const unsub = ws.on("voice_active_user", (data) => { + const speaker = data as ActiveSpeaker; + void mutate( + (prev: ActiveSpeaker[] | undefined) => { + const arr = prev ?? []; + const idx = arr.findIndex((s) => s.userId === speaker.userId); + if (idx >= 0) { + const next = [...arr]; + next[idx] = speaker; + return next; + } + return [...arr, speaker]; + }, + { revalidate: false }, + ); + }); + return () => { + unsubSnapshot(); + unsub(); + }; + }, + [mutate], ); - const subscribe = useCallback((ws: WsHook) => { - const unsubSnapshot = ws.on("voice_state", (data) => { - const state = data as { activeSpeakers?: ActiveSpeaker[] }; - if (Array.isArray(state?.activeSpeakers)) { - setSpeakers(state.activeSpeakers); - } - }); - const unsub = ws.on("voice_active_user", (data) => { - const speaker = data as ActiveSpeaker; - setSpeakers((prev) => { - const idx = prev.findIndex((s) => s.userId === speaker.userId); - if (idx >= 0) { - const next = [...prev]; - next[idx] = speaker; - return next; - } - return [...prev, speaker]; - }); - }); - return () => { - unsubSnapshot(); - unsub(); - setSpeakers([]); - }; - }, []); - - return { speakers, subscribe }; + return { speakers: speakers ?? [], subscribe, error, isValidating }; } function useStatusInvalidator() { const { mutate } = useSWRConfig(); - return () => { + return useCallback(() => { void mutate(STATUS_KEY); - }; + }, [mutate]); } export function useVoiceConnect() { diff --git a/services/frontend/src/lib/hash.ts b/services/frontend/src/lib/hash.ts new file mode 100644 index 00000000..fb7985d3 --- /dev/null +++ b/services/frontend/src/lib/hash.ts @@ -0,0 +1,12 @@ +/** + * FNV-1a 32-bit hash — same hash the gateway uses to tag PCM frames. + * Shared between use-voice hooks and ambient-canvas. + */ +export function hashUserId(userId: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < userId.length; i++) { + hash ^= userId.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} diff --git a/services/frontend/src/lib/swr-config.ts b/services/frontend/src/lib/swr-config.ts new file mode 100644 index 00000000..6852d97a --- /dev/null +++ b/services/frontend/src/lib/swr-config.ts @@ -0,0 +1,47 @@ +/** + * Global SWR configuration for the GMW frontend. + * + * Centralizes revalidation policy, error retry with exponential backoff, and + * deduplication so every `useSWR` call across the app gets consistent behaviour + * without each hook re-declaring the same options. + * + * Import this in the root layout (or any component mounted once) via + * `SWRConfig` from "swr". + */ + +import type { SWRConfiguration } from "swr"; + +/** + * Exponential backoff retry — starts at ~1s, doubles up to 30s, then flatlines. + * Matches the existing WsConnection reconnection philosophy. + */ +export const swrConfig: SWRConfiguration = { + // Re-validate on focus (tab switch) but not on every interval by default. + revalidateOnFocus: true, + // Dedupe rapid identical requests within 2s. + dedupingInterval: 2000, + // Never throw unhandled rejections — every hook handles `error` gracefully. + shouldRetryOnError: (error) => { + // Don't retry on 404 (NotFound) — it's a client expectation, not transient. + if (error?.statusCode === 404 || error?.code === "NOT_FOUND") return false; + // Retry network errors, 5xx, and oRPC transport failures. + return true; + }, + onErrorRetry: (_error, _key, _config, revalidate, opts) => { + const attemptCount = (opts as { attemptCount?: number }).attemptCount ?? 0; + // Stop retrying after 3 attempts (≈ 1+2+4 = 7s max backoff). + if (attemptCount >= 3) return; + + // Exponential backoff: 1000 * 2^attempt, capped at 30000ms. + const baseDelay = 1000 * 2 ** attemptCount; + const delay = Math.min(baseDelay, 30000); + + // Jitter ±25% to avoid thundering herd on shared endpoints. + const jitter = delay * 0.25 * (Math.random() * 2 - 1); + const timeout = Math.round(delay + jitter); + + setTimeout(revalidate, timeout); + }, + // Sensible defaults for the "loading" state — most data loads in <500ms. + loadingTimeout: 15000, +}; diff --git a/services/frontend/src/lib/ws/connection.ts b/services/frontend/src/lib/ws/connection.ts index aab3c974..8bd16b40 100644 --- a/services/frontend/src/lib/ws/connection.ts +++ b/services/frontend/src/lib/ws/connection.ts @@ -37,6 +37,11 @@ export class WsConnection { return this._status; } + /** Current reconnect attempt count (0 = connected/first attempt). */ + get reconnectAttemptCount(): number { + return this.reconnectAttempt; + } + onStatusChange(listener: (status: WsStatus) => void): () => void { this.statusListeners.push(listener); return () => { diff --git a/services/frontend/src/lib/ws/context.tsx b/services/frontend/src/lib/ws/context.tsx index 8581fd66..833f23a3 100644 --- a/services/frontend/src/lib/ws/context.tsx +++ b/services/frontend/src/lib/ws/context.tsx @@ -9,6 +9,7 @@ import { useRef, useState, } from "react"; +import { toast } from "@/components/primitives"; import { WsConnection } from "./connection"; import type { PcmChunk, WsEventHandler, WsEventType, WsStatus } from "./types"; @@ -29,16 +30,6 @@ interface WsContextValue { const WsContext = createContext(null); -/** FNV-1a 32-bit hash matching the backend's hashUserId function */ -function _hashUserId(userId: string): number { - let hash = 0x811c9dc5; - for (let i = 0; i < userId.length; i++) { - hash ^= userId.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return hash >>> 0; -} - export function WsProvider({ children, url, @@ -48,6 +39,9 @@ export function WsProvider({ }) { const connRef = useRef(null); const [status, setStatus] = useState("disconnected"); + // Tracks whether we've ever been connected — used to suppress the + // "reconnecting" toast on initial page load. + const wasConnected = useRef(false); // Event handler registry — Ref so listeners survive re-renders without reconnect // Using unknown as internal store; typed at the subscribe interface @@ -85,7 +79,31 @@ export function WsProvider({ const conn = new WsConnection(url); connRef.current = conn; - const unsubStatus = conn.onStatusChange(setStatus); + const unsubStatus = conn.onStatusChange((s) => { + setStatus(s); + // User feedback on WS lifecycle transitions. + if (s === "connecting") { + // Only toast if we were previously connected (i.e. a disconnect, + // not the initial connect on page load). + if (wasConnected.current) { + toast({ + title: "Reconnecting…", + description: "WebSocket connection lost. Attempting to reconnect.", + tone: "neutral", + }); + } + wasConnected.current = false; + } else if (s === "connected") { + wasConnected.current = true; + } else if (s === "error" && !wasConnected.current) { + toast({ + title: "Connection error", + description: + "WebSocket failed to connect. Retrying in the background.", + tone: "vermilion", + }); + } + }); const unsubEvent = conn.onEvent((event) => { if (event.type === "text") { handleJsonEvent(event.data);