feat(fe): optimize error handling, state consistency, and WS feedback

- Add global SWR config with exponential backoff retry (swr-config.ts)
- Add ErrorBoundary component for React crash recovery (error-boundary.tsx)
- Standardize ErrorState onRetry on all 6 dashboard views (dashboard, media,
  messages, moderation, recordings, voice)
- Fix useAction: expose resetError + onError callback
- Refactor useSpeakers to SWR-backed state (was local useState) for
  consistent cache/revalidate semantics with other hooks
- Remove polling in useReview (15s refreshInterval); replace with
  useReviewWsSync subscribing to WS moderation_action events
- Extract hashUserId to lib/hash.ts (de-dup with ambient-canvas)
- WS context: add reconnect/error toast feedback via onStatusChange
- WS connection: expose reconnectAttemptCount getter

All typecheck + lint clean, Next 16 build passes.
This commit is contained in:
asepharyana
2026-08-25 13:46:15 +07:00
parent 796c6390ac
commit 31e303c187
17 changed files with 402 additions and 79 deletions
+12
View File
@@ -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;
}
+47
View File
@@ -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,
};
@@ -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 () => {
+29 -11
View File
@@ -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<WsContextValue | null>(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<WsConnection | null>(null);
const [status, setStatus] = useState<WsStatus>("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);