From d7f6d0a44cd4fdd2ab9a36457649cd02b18e26c0 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Fri, 13 Feb 2026 18:44:10 -0600 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9A=A1=20Singleton=20SSE=20connection=20?= =?UTF-8?q?+=20incremental=20updates=20for=20TDD=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the performance issue where each component calling useReportData() opened its own EventSource, resulting in 4 concurrent SSE connections and 4 file watchers on the server. Also eliminates full data re-sends on every file change. - Move SSE management to a React context provider (SSEProvider) that wraps the app once, replacing per-component EventSource instances - Replace useReportDataSSE hook with useSSEState context consumer - Server now diffs report data against last-sent state per connection and sends incremental comparisonUpdate/comparisonRemoved/summaryUpdate events instead of the full comparisons array - Initial connection still receives full reportData for complete state - Polling fallback preserved for reconnection scenarios Closes #213 --- .../src/hooks/queries/use-tdd-queries.js | 8 +- src/reporter/src/hooks/use-sse.js | 110 +----------- src/reporter/src/main.jsx | 9 +- src/reporter/src/providers/sse-provider.jsx | 160 ++++++++++++++++++ src/server/routers/events.js | 103 ++++++++++- 5 files changed, 272 insertions(+), 118 deletions(-) create mode 100644 src/reporter/src/providers/sse-provider.jsx diff --git a/src/reporter/src/hooks/queries/use-tdd-queries.js b/src/reporter/src/hooks/queries/use-tdd-queries.js index 2f68638c..33d20343 100644 --- a/src/reporter/src/hooks/queries/use-tdd-queries.js +++ b/src/reporter/src/hooks/queries/use-tdd-queries.js @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { tdd } from '../../api/client.js'; import { queryKeys } from '../../lib/query-keys.js'; -import { SSE_STATE, useReportDataSSE } from '../use-sse.js'; +import { SSE_STATE, useSSEState } from '../use-sse.js'; export function useComparison(id, options = {}) { return useQuery({ @@ -14,10 +14,8 @@ export function useComparison(id, options = {}) { } export function useReportData(options = {}) { - // Use SSE for real-time updates - let { state: sseState } = useReportDataSSE({ - enabled: options.polling !== false, - }); + // Read SSE state from the singleton provider + let { state: sseState } = useSSEState(); // SSE is connected - it updates the cache directly, no polling needed // Fall back to polling only when SSE is not connected diff --git a/src/reporter/src/hooks/use-sse.js b/src/reporter/src/hooks/use-sse.js index 5f4d90ee..a6c9cc7c 100644 --- a/src/reporter/src/hooks/use-sse.js +++ b/src/reporter/src/hooks/use-sse.js @@ -1,109 +1,13 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { useEffect, useRef, useState } from 'react'; -import { queryKeys } from '../lib/query-keys.js'; +import { useContext } from 'react'; +import { SSE_STATE, SSEContext } from '../providers/sse-provider.jsx'; -/** - * SSE connection states - */ -export let SSE_STATE = { - CONNECTING: 'connecting', - CONNECTED: 'connected', - DISCONNECTED: 'disconnected', - ERROR: 'error', -}; +// Re-export for consumers that import SSE_STATE from here +export { SSE_STATE }; /** - * Hook to manage SSE connection for real-time report data updates - * @param {Object} options - * @param {boolean} [options.enabled=true] - Whether SSE should be enabled + * Read SSE connection state from the singleton provider. * @returns {{ state: string, error: Error|null }} */ -export function useReportDataSSE(options = {}) { - let shouldEnable = options.enabled ?? true; - - const queryClient = useQueryClient(); - const eventSourceRef = useRef(null); - const reconnectAttemptRef = useRef(0); - const reconnectTimerRef = useRef(null); - - const [state, setState] = useState(SSE_STATE.DISCONNECTED); - const [error, setError] = useState(null); - - useEffect(() => { - if (!shouldEnable) { - // Clean up if disabled - if (eventSourceRef.current) { - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - setState(SSE_STATE.DISCONNECTED); - return; - } - - const connect = () => { - // Don't reconnect if already connected or connecting - // EventSource.CLOSED (2) is well-supported in all modern browsers - if (eventSourceRef.current && eventSourceRef.current.readyState !== 2) { - return; - } - - setState(SSE_STATE.CONNECTING); - setError(null); - - const eventSource = new EventSource('/api/events'); - eventSourceRef.current = eventSource; - - eventSource.onopen = () => { - setState(SSE_STATE.CONNECTED); - setError(null); - reconnectAttemptRef.current = 0; - }; - - eventSource.addEventListener('reportData', event => { - try { - const data = JSON.parse(event.data); - // Update React Query cache directly - queryClient.setQueryData(queryKeys.reportData(), data); - } catch { - // Ignore parse errors - } - }); - - eventSource.addEventListener('heartbeat', () => { - // Heartbeat received - connection is alive - }); - - eventSource.onerror = () => { - eventSource.close(); - eventSourceRef.current = null; - setState(SSE_STATE.ERROR); - setError(new Error('SSE connection failed')); - - // Exponential backoff for reconnection (max 30 seconds) - const attempt = reconnectAttemptRef.current; - const delay = Math.min(1000 * 2 ** attempt, 30000); - reconnectAttemptRef.current = attempt + 1; - - reconnectTimerRef.current = window.setTimeout(() => { - connect(); - }, delay); - }; - }; - - connect(); - - return () => { - if (reconnectTimerRef.current) { - clearTimeout(reconnectTimerRef.current); - reconnectTimerRef.current = null; - } - if (eventSourceRef.current) { - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - setState(SSE_STATE.DISCONNECTED); - }; - }, [shouldEnable, queryClient]); - - return { state, error }; +export function useSSEState() { + return useContext(SSEContext); } diff --git a/src/reporter/src/main.jsx b/src/reporter/src/main.jsx index d61f5508..0c6183f4 100644 --- a/src/reporter/src/main.jsx +++ b/src/reporter/src/main.jsx @@ -4,6 +4,7 @@ import ReactDOM from 'react-dom/client'; import AppRouter from './components/app-router.jsx'; import { ToastProvider } from './components/ui/toast.jsx'; import { queryClient } from './lib/query-client.js'; +import { SSEProvider } from './providers/sse-provider.jsx'; import './reporter.css'; let initializeReporter = () => { @@ -18,9 +19,11 @@ let initializeReporter = () => { ReactDOM.createRoot(root).render( - - - + + + + + ); diff --git a/src/reporter/src/providers/sse-provider.jsx b/src/reporter/src/providers/sse-provider.jsx new file mode 100644 index 00000000..fd153014 --- /dev/null +++ b/src/reporter/src/providers/sse-provider.jsx @@ -0,0 +1,160 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { createContext, useEffect, useRef, useState } from 'react'; +import { queryKeys } from '../lib/query-keys.js'; + +export let SSE_STATE = { + CONNECTING: 'connecting', + CONNECTED: 'connected', + DISCONNECTED: 'disconnected', + ERROR: 'error', +}; + +export let SSEContext = createContext({ + state: SSE_STATE.DISCONNECTED, + error: null, +}); + +/** + * Singleton SSE provider — manages one EventSource connection for the entire app. + * Updates React Query cache on reportData, comparisonUpdate, comparisonRemoved, + * and summaryUpdate events. + */ +export function SSEProvider({ enabled = true, children }) { + let queryClient = useQueryClient(); + let eventSourceRef = useRef(null); + let reconnectAttemptRef = useRef(0); + let reconnectTimerRef = useRef(null); + + let [state, setState] = useState(SSE_STATE.DISCONNECTED); + let [error, setError] = useState(null); + + useEffect(() => { + if (!enabled) { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + setState(SSE_STATE.DISCONNECTED); + return; + } + + let connect = () => { + // EventSource.CLOSED === 2 + if (eventSourceRef.current && eventSourceRef.current.readyState !== 2) { + return; + } + + setState(SSE_STATE.CONNECTING); + setError(null); + + let eventSource = new EventSource('/api/events'); + eventSourceRef.current = eventSource; + + eventSource.onopen = () => { + setState(SSE_STATE.CONNECTED); + setError(null); + reconnectAttemptRef.current = 0; + }; + + // Full report data — sent on initial connection + eventSource.addEventListener('reportData', event => { + try { + let data = JSON.parse(event.data); + queryClient.setQueryData(queryKeys.reportData(), data); + } catch { + // Ignore parse errors + } + }); + + // Incremental: single comparison added or changed + eventSource.addEventListener('comparisonUpdate', event => { + try { + let comparison = JSON.parse(event.data); + queryClient.setQueryData(queryKeys.reportData(), old => { + if (!old) return old; + let comparisons = old.comparisons || []; + let idx = comparisons.findIndex(c => c.id === comparison.id); + if (idx >= 0) { + comparisons = comparisons.map((c, i) => + i === idx ? { ...c, ...comparison } : c + ); + } else { + comparisons = [...comparisons, comparison]; + } + return { ...old, comparisons }; + }); + } catch { + // Ignore parse errors + } + }); + + // Incremental: comparison removed + eventSource.addEventListener('comparisonRemoved', event => { + try { + let { id } = JSON.parse(event.data); + queryClient.setQueryData(queryKeys.reportData(), old => { + if (!old?.comparisons) return old; + return { + ...old, + comparisons: old.comparisons.filter(c => c.id !== id), + }; + }); + } catch { + // Ignore parse errors + } + }); + + // Incremental: summary fields changed + eventSource.addEventListener('summaryUpdate', event => { + try { + let summary = JSON.parse(event.data); + queryClient.setQueryData(queryKeys.reportData(), old => { + if (!old) return old; + return { ...old, ...summary, comparisons: old.comparisons }; + }); + } catch { + // Ignore parse errors + } + }); + + eventSource.addEventListener('heartbeat', () => { + // Connection alive + }); + + eventSource.onerror = () => { + eventSource.close(); + eventSourceRef.current = null; + setState(SSE_STATE.ERROR); + setError(new Error('SSE connection failed')); + + let attempt = reconnectAttemptRef.current; + let delay = Math.min(1000 * 2 ** attempt, 30000); + reconnectAttemptRef.current = attempt + 1; + + reconnectTimerRef.current = window.setTimeout(() => { + connect(); + }, delay); + }; + }; + + connect(); + + return () => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + setState(SSE_STATE.DISCONNECTED); + }; + }, [enabled, queryClient]); + + return ( + + {children} + + ); +} diff --git a/src/server/routers/events.js b/src/server/routers/events.js index cca79c61..c72c2196 100644 --- a/src/server/routers/events.js +++ b/src/server/routers/events.js @@ -62,6 +62,86 @@ export function createEventsRouter(context) { res.write(`data: ${JSON.stringify(data)}\n\n`); }; + /** + * Build a lookup map from comparisons array keyed by id + */ + const buildComparisonMap = comparisons => { + let map = new Map(); + for (let c of comparisons) { + map.set(c.id, c); + } + return map; + }; + + /** + * Compare two comparison objects to detect meaningful changes. + * Returns true if any tracked field differs. + */ + const comparisonChanged = (oldComp, newComp) => { + return ( + oldComp.status !== newComp.status || + oldComp.diffPercentage !== newComp.diffPercentage || + oldComp.userAction !== newComp.userAction || + oldComp.timestamp !== newComp.timestamp || + oldComp.name !== newComp.name + ); + }; + + /** + * Extract summary fields (everything except comparisons) for diffing + */ + const extractSummary = data => { + let { comparisons: _c, ...summary } = data; + return summary; + }; + + /** + * Check if summary-level fields changed between old and new data + */ + const summaryChanged = (oldData, newData) => { + let oldSummary = extractSummary(oldData); + let newSummary = extractSummary(newData); + return JSON.stringify(oldSummary) !== JSON.stringify(newSummary); + }; + + /** + * Send incremental updates by diffing old vs new report data. + * Returns true if any events were sent. + */ + const sendIncrementalUpdates = (res, oldData, newData) => { + let sent = false; + let oldComparisons = oldData.comparisons || []; + let newComparisons = newData.comparisons || []; + + let oldMap = buildComparisonMap(oldComparisons); + let newMap = buildComparisonMap(newComparisons); + + // New or changed comparisons + for (let [id, newComp] of newMap) { + let oldComp = oldMap.get(id); + if (!oldComp || comparisonChanged(oldComp, newComp)) { + sendEvent(res, 'comparisonUpdate', newComp); + sent = true; + } + } + + // Removed comparisons + for (let [id] of oldMap) { + if (!newMap.has(id)) { + sendEvent(res, 'comparisonRemoved', { id }); + sent = true; + } + } + + // Summary-level changes (total, passed, failed, etc.) + if (summaryChanged(oldData, newData)) { + sendEvent(res, 'summaryUpdate', extractSummary(newData)); + sent = true; + } + + return sent; + }; + return async function handleEventsRoute(req, res, pathname) { if (req.method !== 'GET' || pathname !== '/api/events') { return false; @@ -75,10 +155,10 @@ export function createEventsRouter(context) { 'X-Accel-Buffering': 'no', // Disable nginx buffering }); - // Send initial data immediately - const initialData = readReportData(); - if (initialData) { - sendEvent(res, 'reportData', initialData); + // Send initial full data immediately + let lastSentData = readReportData(); + if (lastSentData) { + sendEvent(res, 'reportData', lastSentData); } // Debounce file change events (fs.watch can fire multiple times) @@ -86,10 +166,19 @@ export function createEventsRouter(context) { let watcher = null; const sendUpdate = () => { - const data = readReportData(); - if (data) { - sendEvent(res, 'reportData', data); + const newData = readReportData(); + if (!newData) return; + + if (!lastSentData) { + // No previous data — send full payload + sendEvent(res, 'reportData', newData); + } else { + // Diff and send incremental updates + let sent = sendIncrementalUpdates(res, lastSentData, newData); + // If nothing changed, skip (no event needed) + if (!sent) return; } + lastSentData = newData; }; // Watch for file changes From 7a511b297c59000a1e3a0ac69c3cf36e47f25912 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Fri, 13 Feb 2026 19:51:25 -0600 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=A7=20Address=20PR=20review=20feed?= =?UTF-8?q?back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use JSON.stringify for comparison diffing instead of field-by-field checks (catches all field changes, avoids float precision issues) - SSE context default → null with guard in useSSEState to catch accidental usage outside SSEProvider - Add comment clarifying comparisonUpdate sends full comparison objects - Add 5 tests for incremental events: comparisonUpdate (new + changed), comparisonRemoved, summaryUpdate, and no-op when data is unchanged --- src/reporter/src/hooks/use-sse.js | 6 +- src/reporter/src/providers/sse-provider.jsx | 5 +- src/server/routers/events.js | 14 +- tests/server/routers/events.test.js | 203 ++++++++++++++++++++ 4 files changed, 211 insertions(+), 17 deletions(-) diff --git a/src/reporter/src/hooks/use-sse.js b/src/reporter/src/hooks/use-sse.js index a6c9cc7c..7ff9c731 100644 --- a/src/reporter/src/hooks/use-sse.js +++ b/src/reporter/src/hooks/use-sse.js @@ -9,5 +9,9 @@ export { SSE_STATE }; * @returns {{ state: string, error: Error|null }} */ export function useSSEState() { - return useContext(SSEContext); + let context = useContext(SSEContext); + if (!context) { + throw new Error('useSSEState must be used within SSEProvider'); + } + return context; } diff --git a/src/reporter/src/providers/sse-provider.jsx b/src/reporter/src/providers/sse-provider.jsx index fd153014..999bb21c 100644 --- a/src/reporter/src/providers/sse-provider.jsx +++ b/src/reporter/src/providers/sse-provider.jsx @@ -9,10 +9,7 @@ export let SSE_STATE = { ERROR: 'error', }; -export let SSEContext = createContext({ - state: SSE_STATE.DISCONNECTED, - error: null, -}); +export let SSEContext = createContext(null); /** * Singleton SSE provider — manages one EventSource connection for the entire app. diff --git a/src/server/routers/events.js b/src/server/routers/events.js index c72c2196..386bc717 100644 --- a/src/server/routers/events.js +++ b/src/server/routers/events.js @@ -73,18 +73,8 @@ export function createEventsRouter(context) { return map; }; - /** - * Compare two comparison objects to detect meaningful changes. - * Returns true if any tracked field differs. - */ const comparisonChanged = (oldComp, newComp) => { - return ( - oldComp.status !== newComp.status || - oldComp.diffPercentage !== newComp.diffPercentage || - oldComp.userAction !== newComp.userAction || - oldComp.timestamp !== newComp.timestamp || - oldComp.name !== newComp.name - ); + return JSON.stringify(oldComp) !== JSON.stringify(newComp); }; /** @@ -116,7 +106,7 @@ export function createEventsRouter(context) { let oldMap = buildComparisonMap(oldComparisons); let newMap = buildComparisonMap(newComparisons); - // New or changed comparisons + // New or changed comparisons — sends the full comparison object, not a partial delta for (let [id, newComp] of newMap) { let oldComp = oldMap.get(id); if (!oldComp || comparisonChanged(oldComp, newComp)) { diff --git a/tests/server/routers/events.test.js b/tests/server/routers/events.test.js index c372e92b..669d5d37 100644 --- a/tests/server/routers/events.test.js +++ b/tests/server/routers/events.test.js @@ -331,6 +331,209 @@ describe('server/routers/events', () => { req.emit('close'); }); + it('sends comparisonUpdate for new comparisons', async () => { + let initialData = { + comparisons: [{ id: 'a', name: 'existing', status: 'passed' }], + total: 1, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(initialData) + ); + + let handler = createEventsRouter({ workingDir: testDir }); + let req = createMockRequest('GET'); + let res = createMockResponse(); + + await handler(req, res, '/api/events'); + + // Add a new comparison + let updatedData = { + comparisons: [ + { id: 'a', name: 'existing', status: 'passed' }, + { id: 'b', name: 'new-one', status: 'failed' }, + ], + total: 2, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(updatedData) + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + let output = res.getOutput(); + assert.ok(output.includes('event: comparisonUpdate')); + assert.ok(output.includes('"id":"b"')); + assert.ok(output.includes('"name":"new-one"')); + + req.emit('close'); + }); + + it('sends comparisonUpdate for changed comparisons', async () => { + let initialData = { + comparisons: [ + { id: 'a', name: 'test', status: 'passed', diffPercentage: 0 }, + ], + total: 1, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(initialData) + ); + + let handler = createEventsRouter({ workingDir: testDir }); + let req = createMockRequest('GET'); + let res = createMockResponse(); + + await handler(req, res, '/api/events'); + + // Change the comparison's status + let updatedData = { + comparisons: [ + { id: 'a', name: 'test', status: 'failed', diffPercentage: 5.2 }, + ], + total: 1, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(updatedData) + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + let output = res.getOutput(); + assert.ok(output.includes('event: comparisonUpdate')); + assert.ok(output.includes('"status":"failed"')); + // Should not send full reportData for incremental changes + let reportDataCount = (output.match(/event: reportData/g) || []).length; + assert.strictEqual( + reportDataCount, + 1, + 'Only initial reportData should be sent' + ); + + req.emit('close'); + }); + + it('sends comparisonRemoved when comparison is deleted', async () => { + let initialData = { + comparisons: [ + { id: 'a', name: 'keep' }, + { id: 'b', name: 'remove' }, + ], + total: 2, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(initialData) + ); + + let handler = createEventsRouter({ workingDir: testDir }); + let req = createMockRequest('GET'); + let res = createMockResponse(); + + await handler(req, res, '/api/events'); + + // Remove comparison b + let updatedData = { + comparisons: [{ id: 'a', name: 'keep' }], + total: 1, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(updatedData) + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + let output = res.getOutput(); + assert.ok(output.includes('event: comparisonRemoved')); + assert.ok(output.includes('"id":"b"')); + + req.emit('close'); + }); + + it('sends summaryUpdate when summary fields change', async () => { + let initialData = { + comparisons: [{ id: 'a', name: 'test' }], + total: 1, + passed: 1, + failed: 0, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(initialData) + ); + + let handler = createEventsRouter({ workingDir: testDir }); + let req = createMockRequest('GET'); + let res = createMockResponse(); + + await handler(req, res, '/api/events'); + + // Change summary fields only, same comparisons + let updatedData = { + comparisons: [{ id: 'a', name: 'test' }], + total: 1, + passed: 0, + failed: 1, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(updatedData) + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + let output = res.getOutput(); + assert.ok(output.includes('event: summaryUpdate')); + assert.ok(output.includes('"failed":1')); + // Summary should not include comparisons + let summaryLine = output + .split('\n') + .find(l => l.startsWith('data:') && l.includes('"failed":1')); + assert.ok(!summaryLine.includes('"comparisons"')); + + req.emit('close'); + }); + + it('sends no events when nothing changed', async () => { + let initialData = { + comparisons: [{ id: 'a', name: 'test', status: 'passed' }], + total: 1, + }; + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(initialData) + ); + + let handler = createEventsRouter({ workingDir: testDir }); + let req = createMockRequest('GET'); + let res = createMockResponse(); + + await handler(req, res, '/api/events'); + + let chunksAfterInitial = res.chunks.length; + + // Write identical data + writeFileSync( + join(testDir, '.vizzly', 'report-data.json'), + JSON.stringify(initialData) + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + // No new chunks should have been written + assert.strictEqual( + res.chunks.length, + chunksAfterInitial, + 'No events sent for identical data' + ); + + req.emit('close'); + }); + it('ignores changes to other files in .vizzly directory', async () => { let handler = createEventsRouter({ workingDir: testDir }); let req = createMockRequest('GET');