'use client' // ST-1115 / PBI-74: Client hook for the backlog 3-pane SSE stream. // Mounts in BacklogHydrationWrapper so it survives Server Action refreshes. // Dispatches pbi/story/task change events into useProductWorkspaceStore. // // T-861: stream blijft open op tab hidden. Per spec werkt EventSource gewoon // door als de browser het toelaat — gemiste events worden opgehaald via // resyncActiveScopes('visible') uit useWorkspaceResync. // T-862: bij latere 'ready' events (post-reconnect) triggeren we // resyncActiveScopes('reconnect') zodat events die tijdens disconnect zijn // gemist, alsnog binnenkomen. import { useEffect, useRef } from 'react' import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import type { ProductRealtimeEvent } from '@/stores/product-workspace/types' const BACKOFF_START_MS = 1_000 const BACKOFF_MAX_MS = 30_000 type EntityPayload = { op: 'I' | 'U' | 'D' entity: 'pbi' | 'story' | 'task' [key: string]: unknown } export function useBacklogRealtime(productId: string | null) { const sourceRef = useRef(null) const backoffRef = useRef(BACKOFF_START_MS) const reconnectTimerRef = useRef | null>(null) const readyCountRef = useRef(0) useEffect(() => { if (!productId) return const close = () => { if (sourceRef.current) { sourceRef.current.close() sourceRef.current = null } if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current) reconnectTimerRef.current = null } } const connect = () => { close() const source = new EventSource( `/api/realtime/backlog?product_id=${encodeURIComponent(productId)}`, ) sourceRef.current = source source.addEventListener('ready', () => { backoffRef.current = BACKOFF_START_MS readyCountRef.current += 1 // T-862: eerste ready = initial connect; latere ready = reconnect. if (readyCountRef.current > 1) { void useProductWorkspaceStore .getState() .resyncActiveScopes('reconnect') } }) source.onmessage = (e) => { if (!e.data) return try { const payload = JSON.parse(e.data) as EntityPayload useProductWorkspaceStore .getState() .applyRealtimeEvent(payload as unknown as ProductRealtimeEvent) } catch (err) { if (process.env.NODE_ENV !== 'production') { console.error('[realtime/backlog] failed to parse event', err, e.data) } } } source.onerror = () => { if (sourceRef.current !== source) return close() if (document.visibilityState === 'hidden') return const delay = backoffRef.current backoffRef.current = Math.min(backoffRef.current * 2, BACKOFF_MAX_MS) reconnectTimerRef.current = setTimeout(connect, delay) } } // T-861: stream blijft open op hidden. Reconnect alleen als source weg // is (b.v. na netwerkfout) en de tab visible is. const onVisibility = () => { if (document.visibilityState === 'visible' && sourceRef.current === null) { backoffRef.current = BACKOFF_START_MS connect() } } connect() document.addEventListener('visibilitychange', onVisibility) return () => { document.removeEventListener('visibilitychange', onVisibility) close() readyCountRef.current = 0 } }, [productId]) }