From 9847c5dff1152683d4314ed5f3cba0ea9935b726 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 18 Apr 2026 19:38:16 -0400 Subject: [PATCH 1/3] fix(dashboard): catch up GitHub signal poll with cached query age Compare server revalidation timestamps to React Query dataUpdatedAt on first sync and on WebSocket reconnect so offline webhook invalidations refetch lists. --- .../src/lib/use-github-signal-stream.ts | 119 +++++++++++++++--- 1 file changed, 103 insertions(+), 16 deletions(-) diff --git a/apps/dashboard/src/lib/use-github-signal-stream.ts b/apps/dashboard/src/lib/use-github-signal-stream.ts index 3ea189f..cfba757 100644 --- a/apps/dashboard/src/lib/use-github-signal-stream.ts +++ b/apps/dashboard/src/lib/use-github-signal-stream.ts @@ -1,5 +1,5 @@ import { type QueryKey, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useMemo, useRef } from "react"; +import { type MutableRefObject, useEffect, useMemo, useRef } from "react"; import { debug } from "./debug"; import { getRevalidationSignalTimestamps } from "./github.functions"; @@ -81,9 +81,50 @@ export function invalidateTargets( return invalidatedCount; } +/** Sync server signal timestamps with local query ages; mutates lastSeenTimestamps. */ +function collectKeysToInvalidateAfterServerSync( + queryClient: ReturnType, + targets: readonly GitHubSignalStreamTarget[], + signals: Array<{ signalKey: string; updatedAt: number }>, + lastSeenTimestamps: Map, +): string[] { + const updatedKeys: string[] = []; + + for (const signal of signals) { + const lastSeen = lastSeenTimestamps.get(signal.signalKey); + if (lastSeen === undefined) { + let needsCatchUp = false; + for (const target of targets) { + if (!target.signalKeys.includes(signal.signalKey)) { + continue; + } + const qs = queryClient.getQueryState(target.queryKey); + if ( + qs && + qs.dataUpdatedAt > 0 && + signal.updatedAt > qs.dataUpdatedAt + ) { + needsCatchUp = true; + break; + } + } + if (needsCatchUp) { + updatedKeys.push(signal.signalKey); + } + lastSeenTimestamps.set(signal.signalKey, signal.updatedAt); + } else if (signal.updatedAt > lastSeen) { + lastSeenTimestamps.set(signal.signalKey, signal.updatedAt); + updatedKeys.push(signal.signalKey); + } + } + + return updatedKeys; +} + function useGitHubSignalStreamWebSocket( targets: readonly GitHubSignalStreamTarget[], signalKeysKey: string, + lastSeenTimestampsRef: MutableRefObject>, ) { const queryClient = useQueryClient(); const targetsRef = useRef(targets); @@ -99,6 +140,45 @@ function useGitHubSignalStreamWebSocket( let reconnectTimer: ReturnType | null = null; let disposed = false; + async function syncSignalsFromServer(source: string) { + try { + const signals = await getRevalidationSignalTimestamps({ + data: { signalKeys: keys }, + }); + if (disposed) return; + + const updatedKeys = collectKeysToInvalidateAfterServerSync( + queryClient, + targetsRef.current, + signals, + lastSeenTimestampsRef.current, + ); + + if (updatedKeys.length === 0) { + return; + } + + debug(source, "detected missed or stale cache vs signals", { + updatedKeys, + }); + + const invalidatedCount = invalidateTargets( + queryClient, + targetsRef.current, + new Set(updatedKeys), + source, + ); + + debug(source, "sync processed", { + updatedKeys, + invalidatedCount, + totalTargets: targetsRef.current.length, + }); + } catch (error) { + debug(source, "sync failed", { error }); + } + } + function sendSubscription(socket: WebSocket) { if (socket.readyState === WebSocket.OPEN) { debug("github-signal-stream", "subscribing to signal keys", { @@ -160,6 +240,7 @@ function useGitHubSignalStreamWebSocket( ws.addEventListener("open", () => { debug("github-signal-stream", "connected"); if (ws) sendSubscription(ws); + void syncSignalsFromServer("github-signal-ws-catchup"); }); ws.addEventListener("message", handleMessage); @@ -199,12 +280,13 @@ function useGitHubSignalStreamWebSocket( ws.close(); } }; - }, [signalKeysKey, queryClient]); + }, [signalKeysKey, queryClient, lastSeenTimestampsRef]); } function useGitHubSignalPoll( targets: readonly GitHubSignalStreamTarget[], signalKeysKey: string, + lastSeenTimestampsRef: MutableRefObject>, ) { const queryClient = useQueryClient(); const targetsRef = useRef(targets); @@ -218,7 +300,6 @@ function useGitHubSignalPoll( const keys = signalKeysKey.split(","); let pollTimer: ReturnType | null = null; let disposed = false; - const lastSeenTimestamps = new Map(); async function pollSignals() { if (disposed) return; @@ -230,16 +311,12 @@ function useGitHubSignalPoll( if (disposed) return; - const updatedKeys: string[] = []; - for (const signal of signals) { - const lastSeen = lastSeenTimestamps.get(signal.signalKey); - if (lastSeen === undefined) { - lastSeenTimestamps.set(signal.signalKey, signal.updatedAt); - } else if (signal.updatedAt > lastSeen) { - lastSeenTimestamps.set(signal.signalKey, signal.updatedAt); - updatedKeys.push(signal.signalKey); - } - } + const updatedKeys = collectKeysToInvalidateAfterServerSync( + queryClient, + targetsRef.current, + signals, + lastSeenTimestampsRef.current, + ); if (updatedKeys.length > 0) { debug("github-signal-poll", "detected missed signals", { @@ -283,7 +360,7 @@ function useGitHubSignalPoll( clearTimeout(pollTimer); } }; - }, [signalKeysKey, queryClient]); + }, [signalKeysKey, queryClient, lastSeenTimestampsRef]); } export function useGitHubSignalStream( @@ -299,6 +376,16 @@ export function useGitHubSignalStream( // not when the array reference changes. const signalKeysKey = allSignalKeys.join(","); - useGitHubSignalStreamWebSocket(targets, signalKeysKey); - useGitHubSignalPoll(targets, signalKeysKey); + const lastSeenTimestampsRef = useRef(new Map()); + + useEffect(() => { + lastSeenTimestampsRef.current = new Map(); + }, [signalKeysKey]); + + useGitHubSignalStreamWebSocket( + targets, + signalKeysKey, + lastSeenTimestampsRef, + ); + useGitHubSignalPoll(targets, signalKeysKey, lastSeenTimestampsRef); } From 62bc5cf3eba5595cf5b86309c0574207ac36a941 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 18 Apr 2026 19:46:11 -0400 Subject: [PATCH 2/3] fix(dashboard): track signal last-seen per query target (CodeRabbit) Key lastSeen by queryKey+signalKey so new targets sharing pullsMine still get catch-up vs dataUpdatedAt; reset baseline when merged target set changes. --- .../src/lib/use-github-signal-stream.ts | 62 ++++++++++++------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/apps/dashboard/src/lib/use-github-signal-stream.ts b/apps/dashboard/src/lib/use-github-signal-stream.ts index 74f93f9..3adf276 100644 --- a/apps/dashboard/src/lib/use-github-signal-stream.ts +++ b/apps/dashboard/src/lib/use-github-signal-stream.ts @@ -133,44 +133,52 @@ export function invalidateTargets( return invalidatedCount; } -/** Sync server signal timestamps with local query ages; mutates lastSeenTimestamps. */ +function signalStreamCompositeKey( + queryKey: QueryKey, + signalKey: string, +): string { + return `${JSON.stringify(queryKey)}\0${signalKey}`; +} + +/** Sync server signal timestamps with local query ages; mutates lastSeenTimestamps (per queryKey+signalKey). */ function collectKeysToInvalidateAfterServerSync( queryClient: ReturnType, targets: readonly GitHubSignalStreamTarget[], signals: Array<{ signalKey: string; updatedAt: number }>, lastSeenTimestamps: Map, ): string[] { - const updatedKeys: string[] = []; + const updatedKeys = new Set(); for (const signal of signals) { - const lastSeen = lastSeenTimestamps.get(signal.signalKey); - if (lastSeen === undefined) { - let needsCatchUp = false; - for (const target of targets) { - if (!target.signalKeys.includes(signal.signalKey)) { - continue; - } - const qs = queryClient.getQueryState(target.queryKey); + for (const target of targets) { + if (!target.signalKeys.includes(signal.signalKey)) { + continue; + } + + const compositeKey = signalStreamCompositeKey( + target.queryKey, + signal.signalKey, + ); + const lastSeen = lastSeenTimestamps.get(compositeKey); + const qs = queryClient.getQueryState(target.queryKey); + + if (lastSeen === undefined) { if ( qs && qs.dataUpdatedAt > 0 && signal.updatedAt > qs.dataUpdatedAt ) { - needsCatchUp = true; - break; + updatedKeys.add(signal.signalKey); } + lastSeenTimestamps.set(compositeKey, signal.updatedAt); + } else if (signal.updatedAt > lastSeen) { + lastSeenTimestamps.set(compositeKey, signal.updatedAt); + updatedKeys.add(signal.signalKey); } - if (needsCatchUp) { - updatedKeys.push(signal.signalKey); - } - lastSeenTimestamps.set(signal.signalKey, signal.updatedAt); - } else if (signal.updatedAt > lastSeen) { - lastSeenTimestamps.set(signal.signalKey, signal.updatedAt); - updatedKeys.push(signal.signalKey); } } - return updatedKeys; + return Array.from(updatedKeys); } function useGitHubSignalStreamWebSocket( @@ -433,11 +441,23 @@ export function useGitHubSignalStream( // not when the array reference changes. const signalKeysKey = allSignalKeys.join(","); + const mergedTargetsIdentity = useMemo( + () => + mergedTargets + .map( + (t) => + `${JSON.stringify(t.queryKey)}\0${[...t.signalKeys].sort().join(",")}`, + ) + .sort() + .join("|"), + [mergedTargets], + ); + const lastSeenTimestampsRef = useRef(new Map()); useEffect(() => { lastSeenTimestampsRef.current = new Map(); - }, [signalKeysKey]); + }, [signalKeysKey, mergedTargetsIdentity]); useGitHubSignalStreamWebSocket( mergedTargets, From 1819771d0109a8eeffa9bb75b7a7c5d54f3dba02 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 18 Apr 2026 19:52:48 -0400 Subject: [PATCH 3/3] fix(dashboard): satisfy Biome exhaustive-deps on signal map reset --- apps/dashboard/src/lib/use-github-signal-stream.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/src/lib/use-github-signal-stream.ts b/apps/dashboard/src/lib/use-github-signal-stream.ts index 3adf276..6b647b7 100644 --- a/apps/dashboard/src/lib/use-github-signal-stream.ts +++ b/apps/dashboard/src/lib/use-github-signal-stream.ts @@ -163,11 +163,7 @@ function collectKeysToInvalidateAfterServerSync( const qs = queryClient.getQueryState(target.queryKey); if (lastSeen === undefined) { - if ( - qs && - qs.dataUpdatedAt > 0 && - signal.updatedAt > qs.dataUpdatedAt - ) { + if (qs && qs.dataUpdatedAt > 0 && signal.updatedAt > qs.dataUpdatedAt) { updatedKeys.add(signal.signalKey); } lastSeenTimestamps.set(compositeKey, signal.updatedAt); @@ -456,6 +452,9 @@ export function useGitHubSignalStream( const lastSeenTimestampsRef = useRef(new Map()); useEffect(() => { + // Reference deps so the reset runs when subscription identity changes (Biome exhaustive-deps). + void signalKeysKey; + void mergedTargetsIdentity; lastSeenTimestampsRef.current = new Map(); }, [signalKeysKey, mergedTargetsIdentity]);