Scrum4Me/hooks/use-jobs-realtime.ts
Janpeter Visser 883534a521
fix(PBI-59): map jobs_initial SSE payload by job_id, not id (#155)
De server-route stuurt JobPayload[] (met `job_id`), maar de client deed
`initJobs(jobs, ...)` waardoor alle entries in activeJobs `id: undefined`
kregen — wat React-key warnings opleverde:

  Each child in a list should have a unique "key" prop.

Fix: SSE jobs_initial niet meer als overwrite gebruiken; SSR-fetch heeft
de volledige JobWithRelations al in de store gezet. We reconcileren nu
per job met upsertJob (status/branch/error/summary updaten van bekende
jobs, onbekende jobs als partials toevoegen — zelfde gedrag als gewone
'message' SSE events).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:22:07 +02:00

91 lines
2.5 KiB
TypeScript

import { useEffect } from 'react'
import { useJobsStore } from '@/stores/jobs-store'
import type { ClaudeJobStatus } from '@prisma/client'
interface JobStatusPayload {
job_id: string
kind?: string
status: string
task_id?: string | null
idea_id?: string | null
sprint_run_id?: string | null
branch?: string
pushed_at?: string
pr_url?: string
verify_result?: string
summary?: string
error?: string
}
export default function useJobsRealtime() {
const upsertJob = useJobsStore(s => s.upsertJob)
useEffect(() => {
let es: EventSource | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let active = true
function connect() {
if (!active) return
es = new EventSource('/api/realtime/jobs')
es.addEventListener('jobs_initial', (event) => {
// De server stuurt JobPayload[] (met `job_id`), niet JobWithRelations[].
// Daarom geen initJobs-overwrite — de SSR-fetch heeft de volledige
// shape al in de store geplaatst. We reconcileren alleen status/branch
// van bekende jobs en pushen onbekende jobs (nieuw aangemaakt tussen
// SSR en SSE-connect) als partials.
try {
const payload = JSON.parse(event.data)
if (!Array.isArray(payload)) return
for (const p of payload as JobStatusPayload[]) {
if (!p.job_id) continue
upsertJob({
id: p.job_id,
status: p.status as ClaudeJobStatus,
branch: p.branch ?? null,
error: p.error ?? null,
summary: p.summary ?? null,
})
}
} catch {
// malformed JSON
}
})
es.addEventListener('message', (event) => {
try {
const payload = JSON.parse(event.data) as JobStatusPayload
if (!payload.job_id) return
upsertJob({
id: payload.job_id,
status: payload.status as ClaudeJobStatus,
branch: payload.branch ?? null,
prUrl: payload.pr_url ?? null,
error: payload.error ?? null,
summary: payload.summary ?? null,
})
} catch {
// malformed JSON
}
})
es.onerror = () => {
es?.close()
es = null
if (active) {
reconnectTimer = setTimeout(connect, 3000)
}
}
}
connect()
return () => {
active = false
if (reconnectTimer) clearTimeout(reconnectTimer)
es?.close()
}
}, [upsertJob])
}