fix(fe): crash 'reading channel_id' + recharts width/height warnings
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 2m14s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m53s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m15s

- useMessageDetail: guard attachments fetcher — revalidate bisa race
  detail load, detail.data undefined saat fetcher jalan ->
  TypeError 'Cannot read properties of undefined (reading channel_id)'
  yang bikin halaman /messages mati (Next error boundary). Sekarang
  fetcher balikin [] kalau channel_id belum ada; hapus non-null
  assertion. Diverifikasi: /messages sebelumnya crash, sekarang render
  dengan data asli (list, verdict, confidence, sticker, emoji).
- ResponsiveContainer (recharts 3.8): initialDimension -1 di render
  pertama -> warning 'width(-1) and height(-1)'. Pakai height numerik
  tetap (192/160/48) + minWidth/minHeight 0 -> calculatedHeight >0,
  warning hilang; width tetap responsif via ResizeObserver. Chart
  baru dirender setelah mount (useMounted) biar container punya ukuran.
  Diverifikasi console: 0 warning, 0 error di dashboard & voice.
This commit is contained in:
asepharyana
2026-08-01 10:21:23 +07:00
parent 6f41c22cb5
commit ec89d64dbf
5 changed files with 126 additions and 73 deletions
@@ -3,6 +3,7 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Area, AreaChart, ResponsiveContainer } from "recharts"; import { Area, AreaChart, ResponsiveContainer } from "recharts";
import { GlassCard } from "@/components/glass/card"; import { GlassCard } from "@/components/glass/card";
import { useMounted } from "@/lib/hooks/use-mounted";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface StatCardProps { interface StatCardProps {
@@ -22,6 +23,7 @@ export function StatCard({
sparklineData, sparklineData,
formatter = (v) => (typeof v === "number" ? v.toLocaleString() : v), formatter = (v) => (typeof v === "number" ? v.toLocaleString() : v),
}: StatCardProps) { }: StatCardProps) {
const mounted = useMounted();
const accentColor = { const accentColor = {
default: "var(--color-primary)", default: "var(--color-primary)",
danger: "var(--color-destructive)", danger: "var(--color-destructive)",
@@ -54,9 +56,14 @@ export function StatCard({
</div> </div>
{/* Sparkline background */} {/* Sparkline background */}
{sparklineData && sparklineData.length > 0 && ( {sparklineData && sparklineData.length > 0 && mounted && (
<div className="absolute bottom-0 left-0 right-0 h-12 opacity-20"> <div className="absolute bottom-0 left-0 right-0 h-12 opacity-20">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer
width="100%"
height={48}
minWidth={0}
minHeight={0}
>
<AreaChart data={sparklineData}> <AreaChart data={sparklineData}>
<defs> <defs>
<linearGradient <linearGradient
@@ -9,12 +9,15 @@ import {
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { GlassCard } from "@/components/glass/card"; import { GlassCard } from "@/components/glass/card";
import { useMounted } from "@/lib/hooks/use-mounted";
interface TopChannelsChartProps { interface TopChannelsChartProps {
data?: { name: string; count: number }[]; data?: { name: string; count: number }[];
} }
export function TopChannelsChart({ data = [] }: TopChannelsChartProps) { export function TopChannelsChart({ data = [] }: TopChannelsChartProps) {
const mounted = useMounted();
return ( return (
<GlassCard variant="base"> <GlassCard variant="base">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
@@ -23,7 +26,13 @@ export function TopChannelsChart({ data = [] }: TopChannelsChartProps) {
</span> </span>
</div> </div>
<div className="h-48"> <div className="h-48">
<ResponsiveContainer width="100%" height="100%"> {mounted ? (
<ResponsiveContainer
width="100%"
height={192}
minWidth={0}
minHeight={0}
>
<BarChart data={data} layout="vertical"> <BarChart data={data} layout="vertical">
<XAxis <XAxis
type="number" type="number"
@@ -55,6 +64,9 @@ export function TopChannelsChart({ data = [] }: TopChannelsChartProps) {
/> />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
) : (
<div className="h-full w-full animate-pulse rounded-md bg-card/40" />
)}
</div> </div>
</GlassCard> </GlassCard>
); );
@@ -1,6 +1,5 @@
"use client"; "use client";
import { GlassCard } from "@/components/glass/card";
import { import {
Bar, Bar,
BarChart, BarChart,
@@ -9,12 +8,16 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { GlassCard } from "@/components/glass/card";
import { useMounted } from "@/lib/hooks/use-mounted";
interface ActivityTimelineProps { interface ActivityTimelineProps {
data?: { user: string; duration: number }[]; data?: { user: string; duration: number }[];
} }
export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) { export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
const mounted = useMounted();
return ( return (
<GlassCard variant="base"> <GlassCard variant="base">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
@@ -23,7 +26,13 @@ export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
</span> </span>
</div> </div>
<div className="h-40"> <div className="h-40">
<ResponsiveContainer width="100%" height="100%"> {mounted ? (
<ResponsiveContainer
width="100%"
height={160}
minWidth={0}
minHeight={0}
>
<BarChart data={data} layout="vertical"> <BarChart data={data} layout="vertical">
<XAxis <XAxis
type="number" type="number"
@@ -47,7 +56,10 @@ export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
fontSize: 12, fontSize: 12,
color: "oklch(0.93 0.01 245)", color: "oklch(0.93 0.01 245)",
}} }}
formatter={(value) => [`${(Number(value) / 60).toFixed(1)}m`, "Duration"]} formatter={(value) => [
`${(Number(value) / 60).toFixed(1)}m`,
"Duration",
]}
/> />
<Bar <Bar
dataKey="duration" dataKey="duration"
@@ -56,6 +68,9 @@ export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
/> />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
) : (
<div className="h-full w-full animate-pulse rounded-md bg-card/40" />
)}
</div> </div>
</GlassCard> </GlassCard>
); );
+8 -5
View File
@@ -126,14 +126,17 @@ export function useReview(channelId?: string) {
export function useMessageDetail(id: string | null) { export function useMessageDetail(id: string | null) {
const detail = useSWR<MessageRecord>(id ? msgKeys.detail(id) : null, () => const detail = useSWR<MessageRecord>(id ? msgKeys.detail(id) : null, () =>
messagesApi.getDetail(id!), messagesApi.getDetail(id ?? ""),
); );
const channelId = id ? detail.data?.channel_id : undefined;
const attachments = useSWR<AttachmentRecord[]>( const attachments = useSWR<AttachmentRecord[]>(
id && detail.data?.channel_id channelId ? [...msgKeys.detail(id ?? ""), "attachments"] : null,
? [...msgKeys.detail(id), "attachments"]
: null,
async () => { async () => {
const res = await messagesApi.getAttachments(detail.data!.channel_id, 10); // Guard: only fetch when we actually have a channel id — a revalidate
// can race the detail load and see detail.data === undefined.
const cid = detail.data?.channel_id;
if (!cid) return [];
const res = await messagesApi.getAttachments(cid, 10);
return res.data; return res.data;
}, },
); );
@@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
/**
* Returns true once the component has mounted on the client. Charts wrapped
* in ResponsiveContainer measure their parent during the first layout — if
* the container has no size yet (hydration/flex/grid), Recharts logs
* "width(-1) and height(-1)" warnings. Deferring the chart render until after
* mount guarantees the container has real dimensions.
*/
export function useMounted(): boolean {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return mounted;
}