From 96fc50154dfbd11e51edc7e15f37c618b8da447d Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 10 May 2026 01:20:06 +0200 Subject: [PATCH] feat(PBI-74): hidden-tab + reconnect resync (Story 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ontwerp samen in één commit zodat geen vangnet wegvalt zonder vervanging. - T-861: useBacklogRealtime sluit niet meer op visibilitychange hidden; EventSource blijft open zolang browser/netwerk dit toelaten. Reconnect bij netwerkfout blijft via backoff. visibilitychange fungeert nog wel als re-connect-trigger als de stream tussentijds is gesloten (b.v. 240s hard-close server-side). - T-862: 'ready'-event-handler telt connect-cycles. De eerste 'ready' is de initial connect (geen resync). Bij latere 'ready' (post-reconnect) wordt resyncActiveScopes('reconnect') aangeroepen om gemiste events op te halen. - T-863: nieuwe lib/realtime/use-workspace-resync.ts — luistert op document.visibilitychange (hidden→visible) en window.online; dispatcht resyncActiveScopes('visible') resp. 'reconnect'. Mounted in BacklogHydrationWrapper na useBacklogRealtime. - T-864: 4 nieuwe vitest-cases voor useWorkspaceResync (jsdom): visible→ visible event, online event, hidden negeren, cleanup-bij-unmount. Daarnaast lint-cleanup: ongebruikte 'order'-variabelen in pbi-list en story-panel weggehaald. Verify: lint+typecheck clean, 646/646 tests groen. Refs: PBI-74, ST-1322, T-861..T-864 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../realtime/use-workspace-resync.test.tsx | 69 +++++++++++++++++++ .../backlog/backlog-hydration-wrapper.tsx | 2 + components/backlog/pbi-list.tsx | 1 - components/backlog/story-panel.tsx | 1 - lib/realtime/use-backlog-realtime.ts | 25 +++++-- lib/realtime/use-workspace-resync.ts | 40 +++++++++++ 6 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 __tests__/realtime/use-workspace-resync.test.tsx create mode 100644 lib/realtime/use-workspace-resync.ts diff --git a/__tests__/realtime/use-workspace-resync.test.tsx b/__tests__/realtime/use-workspace-resync.test.tsx new file mode 100644 index 0000000..cbc50a5 --- /dev/null +++ b/__tests__/realtime/use-workspace-resync.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook } from '@testing-library/react' + +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { useWorkspaceResync } from '@/lib/realtime/use-workspace-resync' + +let resyncSpy: ReturnType + +beforeEach(() => { + resyncSpy = vi.fn().mockResolvedValue(undefined) + useProductWorkspaceStore.setState((s) => { + s.resyncActiveScopes = resyncSpy as unknown as typeof s.resyncActiveScopes + }) + // visibilitychange handler leest document.visibilityState — default is 'visible' + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('useWorkspaceResync', () => { + it('triggert resyncActiveScopes("visible") op visibilitychange hidden→visible', () => { + renderHook(() => useWorkspaceResync()) + + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event('visibilitychange')) + + expect(resyncSpy).toHaveBeenCalledWith('visible') + }) + + it('triggert resyncActiveScopes("reconnect") op online-event', () => { + renderHook(() => useWorkspaceResync()) + window.dispatchEvent(new Event('online')) + expect(resyncSpy).toHaveBeenCalledWith('reconnect') + }) + + it('triggert geen resync bij visibilitychange naar hidden', () => { + renderHook(() => useWorkspaceResync()) + + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event('visibilitychange')) + + expect(resyncSpy).not.toHaveBeenCalled() + }) + + it('cleanup verwijdert listeners bij unmount', () => { + const { unmount } = renderHook(() => useWorkspaceResync()) + unmount() + + window.dispatchEvent(new Event('online')) + document.dispatchEvent(new Event('visibilitychange')) + + expect(resyncSpy).not.toHaveBeenCalled() + }) +}) diff --git a/components/backlog/backlog-hydration-wrapper.tsx b/components/backlog/backlog-hydration-wrapper.tsx index 10b996a..136f703 100644 --- a/components/backlog/backlog-hydration-wrapper.tsx +++ b/components/backlog/backlog-hydration-wrapper.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react' import { useBacklogStore, type BacklogPbi, type BacklogStory, type BacklogTask } from '@/stores/backlog-store' import { useBacklogRealtime } from '@/lib/realtime/use-backlog-realtime' +import { useWorkspaceResync } from '@/lib/realtime/use-workspace-resync' import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import type { BacklogPbi as WorkspacePbi, @@ -77,6 +78,7 @@ export function BacklogHydrationWrapper({ }, [initialData, productId, productName, setInitialData]) useBacklogRealtime(productId) + useWorkspaceResync() return <>{children} } diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index a21260e..d51a838 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -302,7 +302,6 @@ export function PbiList({ productId, isDemo }: PbiListProps) { // pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order). // Geen aparte order/priority maps meer — workspace-store entities zijn de waarheid. const pbiMap = Object.fromEntries(pbis.map(p => [p.id, p])) - const order = pbis.map(p => p.id) const orderedPbis = pbis const base = orderedPbis.filter(p => { diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index 539e277..5967030 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -143,7 +143,6 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) { // rawStories komt al gesorteerd binnen via selectStoriesForActivePbi. const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s])) - const order = rawStories.map(s => s.id) const orderedStories = rawStories const base = orderedStories diff --git a/lib/realtime/use-backlog-realtime.ts b/lib/realtime/use-backlog-realtime.ts index f6659cb..0ebb653 100644 --- a/lib/realtime/use-backlog-realtime.ts +++ b/lib/realtime/use-backlog-realtime.ts @@ -7,6 +7,12 @@ // PBI-74 / T-845: dual-dispatch — events worden ook naar de nieuwe // product-workspace-store gestuurd. De oude store blijft leidend totdat // Story 3 de UI-consumers heeft omgezet en Story 8 de oude store opruimt. +// PBI-74 / 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. +// PBI-74 / 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 { useBacklogStore } from '@/stores/backlog-store' @@ -27,6 +33,7 @@ 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 @@ -51,6 +58,13 @@ export function useBacklogRealtime(productId: string | null) { 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) => { @@ -83,23 +97,22 @@ export function useBacklogRealtime(productId: string | null) { } } + // 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 === 'hidden') { - close() - } else if (sourceRef.current === null) { + if (document.visibilityState === 'visible' && sourceRef.current === null) { backoffRef.current = BACKOFF_START_MS connect() } } - if (document.visibilityState === 'visible') { - connect() - } + connect() document.addEventListener('visibilitychange', onVisibility) return () => { document.removeEventListener('visibilitychange', onVisibility) close() + readyCountRef.current = 0 } }, [productId]) } diff --git a/lib/realtime/use-workspace-resync.ts b/lib/realtime/use-workspace-resync.ts new file mode 100644 index 0000000..844fa48 --- /dev/null +++ b/lib/realtime/use-workspace-resync.ts @@ -0,0 +1,40 @@ +'use client' + +// PBI-74 / T-863: useWorkspaceResync hook. +// +// Trigger resyncActiveScopes bij: +// - hidden→visible (browser-throttled events kunnen gemist zijn) +// - online (netwerk hersteld na disconnect) +// +// Hoort gemount te worden naast useBacklogRealtime in BacklogHydrationWrapper. + +import { useEffect } from 'react' +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' + +export function useWorkspaceResync(): void { + useEffect(() => { + if (typeof document === 'undefined') return + + const onVisibility = () => { + if (document.visibilityState === 'visible') { + void useProductWorkspaceStore + .getState() + .resyncActiveScopes('visible') + } + } + + const onOnline = () => { + void useProductWorkspaceStore + .getState() + .resyncActiveScopes('reconnect') + } + + document.addEventListener('visibilitychange', onVisibility) + window.addEventListener('online', onOnline) + + return () => { + document.removeEventListener('visibilitychange', onVisibility) + window.removeEventListener('online', onOnline) + } + }, []) +}