From 7dca2c3110429275719aae00f7481911bfa0761a Mon Sep 17 00:00:00 2001 From: hzhang Date: Sun, 24 May 2026 01:18:26 +0100 Subject: [PATCH] fix(api): normalize null lists to [] (backend serializes empty as null) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Go backend serializes empty slices as JSON null in several places (e.g. {count:0, topics:null} when no rows match). The SPA assumed arrays and crashed on .length: TypeError: Cannot read properties of null (reading 'length') at Hh (TopicList.tsx:64:45) Fix in api.ts so every list-returning helper normalizes null → [] once, centrally. Three call sites covered: - listTopics — topics field - listArguments — arguments field - getAgentSummary — recent_topics field Components stay as-is; nullable handling moves out of UI code where it belongs in the data layer. getTopic preserves camps:null vs camps:[] distinction (null = not yet allocated, [] would be allocated-with-zero which the allocator contract forbids — keep them meaningfully distinct). No backend change. Tested on prod with empty topics — list page now renders 'no topics match this filter' instead of crashing. --- src/api.ts | 45 ++++++++++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/src/api.ts b/src/api.ts index 5d7ca5a..6c66558 100644 --- a/src/api.ts +++ b/src/api.ts @@ -64,7 +64,12 @@ async function request( // -------------------------------------------------------------------- // Topics -export function listTopics(filter: { +// Note on null vs []: the Go backend serializes empty slices as JSON +// null (`json:",omitempty"`-ish behavior in places), so all list-returning +// endpoints can return e.g. {count:0, topics:null}. Normalize to [] here +// so consumers can do .length / .map without per-call guards. + +export async function listTopics(filter: { status?: TopicStatus; visibility?: Visibility; limit?: number; @@ -77,23 +82,35 @@ export function listTopics(filter: { if (filter.limit) qs.set('limit', String(filter.limit)); if (filter.offset) qs.set('offset', String(filter.offset)); const suffix = qs.toString() ? `?${qs}` : ''; - return request('GET', `/topics${suffix}`, { signal: filter.signal }); + const r = await request<{ topics: Topic[] | null; count: number }>( + 'GET', + `/topics${suffix}`, + { signal: filter.signal }, + ); + return { topics: r.topics ?? [], count: r.count ?? 0 }; } -export function getTopic( +export async function getTopic( id: string, signal?: AbortSignal, ): Promise { - return request('GET', `/topics/${encodeURIComponent(id)}`, { signal }); + const t = await request('GET', `/topics/${encodeURIComponent(id)}`, { signal }); + // camps is nullable pre-allocation; keep null vs [] meaningful — null + // means "not yet allocated" (UI shows '—'), [] would mean "allocated + // but no rows" (impossible by allocator contract, but distinct). + return t; } -export function listArguments( +export async function listArguments( topicId: string, signal?: AbortSignal, ): Promise<{ arguments: Argument[]; count: number }> { - return request('GET', `/topics/${encodeURIComponent(topicId)}/arguments`, { - signal, - }); + const r = await request<{ arguments: Argument[] | null; count: number }>( + 'GET', + `/topics/${encodeURIComponent(topicId)}/arguments`, + { signal }, + ); + return { arguments: r.arguments ?? [], count: r.count ?? 0 }; } export function getVerdict( @@ -112,14 +129,16 @@ export function getVerdict( // -------------------------------------------------------------------- // Admin -export function getAgentSummary( +export async function getAgentSummary( agentId: string, signal?: AbortSignal, ): Promise { - return request('GET', `/admin/agents/${encodeURIComponent(agentId)}`, { - admin: true, - signal, - }); + const r = await request( + 'GET', + `/admin/agents/${encodeURIComponent(agentId)}`, + { admin: true, signal }, + ); + return { ...r, recent_topics: r.recent_topics ?? [] }; } export { HttpError };