fix(api): normalize null lists to [] (backend serializes empty as null)

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.
This commit is contained in:
h z
2026-05-24 01:18:26 +01:00
parent a6258da84e
commit 7dca2c3110

View File

@@ -64,7 +64,12 @@ async function request<T>(
// -------------------------------------------------------------------- // --------------------------------------------------------------------
// Topics // 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; status?: TopicStatus;
visibility?: Visibility; visibility?: Visibility;
limit?: number; limit?: number;
@@ -77,23 +82,35 @@ export function listTopics(filter: {
if (filter.limit) qs.set('limit', String(filter.limit)); if (filter.limit) qs.set('limit', String(filter.limit));
if (filter.offset) qs.set('offset', String(filter.offset)); if (filter.offset) qs.set('offset', String(filter.offset));
const suffix = qs.toString() ? `?${qs}` : ''; 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, id: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<TopicDetail> { ): Promise<TopicDetail> {
return request('GET', `/topics/${encodeURIComponent(id)}`, { signal }); const t = await request<TopicDetail>('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, topicId: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<{ arguments: Argument[]; count: number }> { ): Promise<{ arguments: Argument[]; count: number }> {
return request('GET', `/topics/${encodeURIComponent(topicId)}/arguments`, { const r = await request<{ arguments: Argument[] | null; count: number }>(
signal, 'GET',
}); `/topics/${encodeURIComponent(topicId)}/arguments`,
{ signal },
);
return { arguments: r.arguments ?? [], count: r.count ?? 0 };
} }
export function getVerdict( export function getVerdict(
@@ -112,14 +129,16 @@ export function getVerdict(
// -------------------------------------------------------------------- // --------------------------------------------------------------------
// Admin // Admin
export function getAgentSummary( export async function getAgentSummary(
agentId: string, agentId: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<AgentSummary> { ): Promise<AgentSummary> {
return request('GET', `/admin/agents/${encodeURIComponent(agentId)}`, { const r = await request<AgentSummary & { recent_topics: AgentSummary['recent_topics'] | null }>(
admin: true, 'GET',
signal, `/admin/agents/${encodeURIComponent(agentId)}`,
}); { admin: true, signal },
);
return { ...r, recent_topics: r.recent_topics ?? [] };
} }
export { HttpError }; export { HttpError };