From 89f5e7fd7b3b30a922d6ba2cd007cf21a55ddb36 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 10 May 2026 02:20:53 +0200 Subject: [PATCH] fix(PBI-74): solo + notifications hooks volgen ook hidden-tab/resync patroon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Het uitgangspunt van PBI-74 (robuust tegen gemiste SSE-events, hidden tabs en onbekende notify-vormen) gold universeel — niet alleen voor product-workspace. use-solo-realtime en use-notifications-realtime hadden nog dezelfde bug die use-backlog-realtime in Story 5 al opgelost kreeg: sluit stream op hidden, geen resync. Reproductie (zoals gemeld): solo-screen open in tab A, product-backlog open in tab B; bewerk task-title in tab B → tab A's solo-SSE was gesloten (hidden) en kreeg het NOTIFY-event nooit. Tab terug naar solo → EventSource reconnect maar geen resync → oude title persisteert. Postgres NOTIFY heeft geen replay, dus zonder resync zijn die events permanent verloren. Fix in beide hooks (zelfde patroon als Story 5 voor backlog): - Stream blijft open op visibilitychange hidden — geen close() meer. - Bij hidden→visible én bij window 'online': router.refresh() zodat de server-component opnieuw fetcht en de initial-state-prop ververst (wat voor solo de tasks-record reset via initTasks; voor notifications de questions-bel-state). - Bij latere 'ready'-events na reconnect (use-solo-realtime): zelfde router.refresh() trigger zodat we niet vertrouwen op alleen het visibility-pad. Verify: lint + typecheck clean, 626/626 tests groen. Refs: PBI-74 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/INDEX.md | 2 +- lib/realtime/use-notifications-realtime.ts | 21 ++++++++---- lib/realtime/use-solo-realtime.ts | 38 +++++++++++++++++----- 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index 4ee9fa0..599e83c 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -2,7 +2,7 @@ # Documentation Index -Auto-generated on 2026-05-09 from front-matter and headings. +Auto-generated on 2026-05-10 from front-matter and headings. ## Architecture Decision Records diff --git a/lib/realtime/use-notifications-realtime.ts b/lib/realtime/use-notifications-realtime.ts index bbb23c5..36430ce 100644 --- a/lib/realtime/use-notifications-realtime.ts +++ b/lib/realtime/use-notifications-realtime.ts @@ -191,21 +191,30 @@ export function useNotificationsRealtime() { }) } + // PBI-74: stream blijft open op hidden. Reconnect alleen als hij door + // netwerkfout/server-close weg is. Bij visible-overgang en bij online + // triggeren we router.refresh() zodat de notifications-bel verse state + // pakt — gemiste vraag-events via NOTIFY-throttling worden hierdoor + // alsnog zichtbaar. const onVisibilityChange = () => { - if (document.visibilityState === 'visible') { - if (!sourceRef.current || sourceRef.current.readyState === EventSource.CLOSED) { - connect() - } - } else { - close() + if (document.visibilityState !== 'visible') return + if (!sourceRef.current || sourceRef.current.readyState === EventSource.CLOSED) { + connect() } + router.refresh() + } + + const onOnline = () => { + router.refresh() } connect() document.addEventListener('visibilitychange', onVisibilityChange) + window.addEventListener('online', onOnline) return () => { document.removeEventListener('visibilitychange', onVisibilityChange) + window.removeEventListener('online', onOnline) close() } }, [router]) diff --git a/lib/realtime/use-solo-realtime.ts b/lib/realtime/use-solo-realtime.ts index cf93361..50a837b 100644 --- a/lib/realtime/use-solo-realtime.ts +++ b/lib/realtime/use-solo-realtime.ts @@ -6,7 +6,12 @@ // - Opent EventSource('/api/realtime/solo?product_id=...') wanneer // productId niet null is; sluit de stream als productId null wordt. // - Reconnect met exponential backoff (1s → 30s, reset bij ready). -// - Pauseert bij document.visibilityState === 'hidden', resumes bij visible. +// - PBI-74: stream blijft open op tab hidden (geen close meer). Bij +// hidden→visible en bij window 'online' triggeren we router.refresh() +// zodat gemiste events alsnog binnenkomen via een verse server-render +// (re-fetcht initialTasks → initTasks reset solo-store). Postgres NOTIFY +// heeft geen replay, dus zonder deze resync zouden hidden-tab events +// permanent verloren zijn — zelfde fix als Story 5 voor backlog-realtime. // - Cleanup op unmount. // - Connection-status (status, showConnectingIndicator) wordt naar de // solo-store geschreven; UI-componenten lezen daar uit. @@ -19,6 +24,7 @@ import { useEffect, useRef } from 'react' import { flushSync } from 'react-dom' +import { useRouter } from 'next/navigation' import { useSoloStore } from '@/stores/solo-store' import type { ClaudeJobEvent, JobState, RealtimeEvent, RealtimeStatus } from '@/stores/solo-store' @@ -27,10 +33,12 @@ const BACKOFF_MAX_MS = 30_000 const CONNECTING_INDICATOR_DELAY_MS = 4_000 export function useSoloRealtime(productId: string | null) { + const router = useRouter() const sourceRef = useRef(null) const backoffRef = useRef(BACKOFF_START_MS) const reconnectTimerRef = useRef | null>(null) const indicatorTimerRef = useRef | null>(null) + const readyCountRef = useRef(0) useEffect(() => { const setStatus = useSoloStore.getState().setRealtimeStatus @@ -88,6 +96,12 @@ export function useSoloRealtime(productId: string | null) { source.addEventListener('ready', () => { backoffRef.current = BACKOFF_START_MS scheduleIndicator('open') + readyCountRef.current += 1 + // PBI-74: latere ready = post-reconnect → resync via router.refresh() + // zodat gemiste tasks-state via re-render initial-prop binnenkomt. + if (readyCountRef.current > 1) { + router.refresh() + } }) source.addEventListener('claude_jobs_initial', (e) => { @@ -173,25 +187,33 @@ export function useSoloRealtime(productId: string | null) { } } + // PBI-74: stream blijft open op hidden. Reconnect alleen als de stream + // door netwerkfout/server-close weg is en de tab visible is. Bij iedere + // visible-overgang triggeren we router.refresh() — gemiste events tijdens + // throttling/freeze worden via een verse server-render alsnog opgepakt. const onVisibility = () => { - if (document.visibilityState === 'hidden') { - close() - scheduleIndicator('disconnected') - } else if (sourceRef.current === null) { + if (document.visibilityState !== 'visible') return + if (sourceRef.current === null) { backoffRef.current = BACKOFF_START_MS connect() } + router.refresh() } - if (document.visibilityState === 'visible') { - connect() + const onOnline = () => { + router.refresh() } + + connect() document.addEventListener('visibilitychange', onVisibility) + window.addEventListener('online', onOnline) return () => { document.removeEventListener('visibilitychange', onVisibility) + window.removeEventListener('online', onOnline) if (indicatorTimerRef.current) clearTimeout(indicatorTimerRef.current) close() + readyCountRef.current = 0 } - }, [productId]) + }, [productId, router]) }