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
@@ -50,7 +50,12 @@ export function DashboardView({
initialStats?: DashboardStats;
initialActivity?: Awaited<ReturnType<typeof useActivity>>["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 <ErrorState error={error} />;
if (error && !stats)
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
if (!stats && isLoading)
return (
<div className="space-y-5">
@@ -78,7 +84,13 @@ export function DashboardView({
</div>
</div>
);
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
if (!stats)
return (
<ErrorState
error={error ?? new Error("No data")}
onRetry={() => void mutateStats()}
/>
);
const s = stats;
const total = s.total_flagged + s.total_clean || 1;
@@ -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 <ErrorState error={error} />;
if (error && !media)
return <ErrorState error={error} onRetry={() => void mutate()} />;
if (!media && isLoading)
return (
<div className="space-y-5">
@@ -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 ? (
<ErrorState error={error} />
<ErrorState error={error} onRetry={() => refetch()} />
) : isLoading && !messages ? (
<SkeletonRows rows={8} />
) : list.length === 0 ? (
@@ -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<string>("");
const [typeFilter, setTypeFilter] = useState<string>("");
const { data: actions } = useModerationActions(
@@ -122,7 +127,8 @@ export function ModerationView({
);
}, [failedRate, ambient]);
if (error && !stats) return <ErrorState error={error} />;
if (error && !stats)
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
if (!stats && isLoading)
return (
<div className="space-y-5">
@@ -131,7 +137,13 @@ export function ModerationView({
<SkeletonRows rows={6} />
</div>
);
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
if (!stats)
return (
<ErrorState
error={error ?? new Error("No data")}
onRetry={() => void mutateStats()}
/>
);
const statusOpts: SelectOption[] = [
{ value: "", label: "All statuses" },
@@ -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 <ErrorState error={error} />;
if (error && !items)
return <ErrorState error={error} onRetry={() => void mutate()} />;
if (!items && isLoading)
return (
<GlassPanel>
@@ -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 <ErrorState error={error} />;
if (error && !status)
return <ErrorState error={error} onRetry={() => void mutate()} />;
if (!status && isLoading)
return (
<div className="space-y-5">
@@ -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 <ErrorBoundary><SomeView /></ErrorBoundary>.
*/
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 (
<ErrorState
title="Something went wrong"
error={this.state.error}
onRetry={this.reset}
/>
);
}
}
@@ -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,
+1
View File
@@ -31,6 +31,7 @@ export {
useMessagesWsSync,
useRecentEdits,
useReview,
useReviewWsSync,
useSemanticSearch,
useTextChannels,
} from "./use-messages";
+14 -4
View File
@@ -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<TArgs = void, TResult = unknown>(
fn: (args: TArgs) => Promise<TResult>,
options?: {
onSuccess?: (data: TResult, args: TArgs) => void | Promise<void>;
onError?: (error: Error, args: TArgs) => void | Promise<void>;
},
) {
const [state, setState] = useState<UseActionState>({
@@ -29,6 +35,8 @@ export function useAction<TArgs = void, TResult = unknown>(
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<TResult> => {
setState({ isPending: true, error: null });
@@ -38,8 +46,10 @@ export function useAction<TArgs = void, TResult = unknown>(
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<TArgs = void, TResult = unknown>(
mutateAsync: run,
isPending: state.isPending,
error: state.error,
reset: () => setState({ isPending: false, error: null }),
resetError: useCallback(() => setState((s) => ({ ...s, error: null })), []),
};
}
+39 -11
View File
@@ -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<MessageRecord[]>(
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<MessageRecord> & { 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<MessageRecord>).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 };
+54 -40
View File
@@ -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<ActiveSpeaker[]>(
initialStatusActive ?? [],
const {
data: speakers,
error,
mutate,
isValidating,
} = useSWR<ActiveSpeaker[]>(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() {
+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);