From 4868a4448d0e90b671aa08d98d6d70896f1646d6 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 9 Oct 2026 15:17:49 +0200 Subject: [PATCH] SignalR: every view re-reads after every connection gap, through one mechanism Pushes only announce changes, so after a gap a view has to check the backend. Only some did: the bells re-fetched from connection.onreconnected (handlers piled up, nothing after a restart), the Progress Portal never re-read (a page left open across the end of an enrollment kept its last state), and the session page re-read only its events. The provider now counts the ends of gaps (a reconnect after its rejoin, a start of a connection that was up before) in resyncEpoch, and useSignalRResync(onResync) fires on each once the group joins of that moment have settled, so a push sent between a re-read and its rejoin cannot get lost; a joinGroup that finds a join of the same group in flight now awaits it. Bells, Progress Portal (summary and events), session page (session, analysis, vulnerability report; events keep the join catch-up), diagnosis page, dashboard list, dashboard stats and fleet health use it; the wasConnectedRef checks are gone. Co-Authored-By: Claude Opus 5.5 --- .../dashboard/hooks/useDashboardSessions.ts | 32 ++++---- .../app/dashboard/hooks/useDashboardStats.ts | 27 ++----- .../app/diagnosis/page.tsx | 8 +- .../app/fleet-health/hooks/useFleetHealth.ts | 15 +--- .../app/progress/hooks/useProgressSignalR.ts | 9 ++- .../app/sessions/hooks/useSessionSignalR.ts | 14 ++++ .../app/sessions/page.tsx | 1 + .../contexts/GlobalNotificationContext.tsx | 16 ++-- .../contexts/SignalRContext.tsx | 77 +++++++++++++++++-- .../contexts/TenantNotificationContext.tsx | 20 ++--- 10 files changed, 138 insertions(+), 81 deletions(-) diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts index fd9232ad..69054db9 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts @@ -9,6 +9,7 @@ import { boundTenantToDelegatedScope } from "@/utils/delegatedScope"; import { mergeSessionsById } from "@/lib/sessionSearchMerge"; import { isHomeTenantTarget } from "@/utils/homeTenantScope"; import { hasTenantReadScope } from "@/lib/tenantScope"; +import { useSignalRResync } from "@/contexts/SignalRContext"; import { type NotificationType, notifyApiError } from "@/contexts/NotificationContext"; import type { Session } from "../types"; import type { SignalRMessageName } from "@/lib/signalrMessages"; @@ -62,7 +63,7 @@ interface UseDashboardSessionsParams { globalAdminMode: boolean; /** * Whether to join the cross-tenant `global-admins` SignalR broadcast group. Real GA only — a delegated - * caller has no platform scope and would be rejected (403); they rely on the per-tenant reconnect refetch. + * caller has no platform scope and would be rejected (403); they rely on the per-tenant gap refetch. */ joinGlobalAdmins: boolean; tenantIdFilter: string; @@ -101,7 +102,7 @@ export interface UseDashboardSessionsReturn { * Owns the dashboard's session list lifecycle: * - initial fetch (gated on user role + tenantId/globalAdminMode readiness) * - SignalR group joining (tenant + global-admins) and live update handlers - * - reconnect refetch + * - refetch after every connection gap * - paginated load-more via cursor * - blocked-devices sync after each fresh fetch * - reset-on-globalAdminMode-toggle @@ -182,7 +183,6 @@ export function useDashboardSessions({ const hasInitialFetch = useRef(false); const hasGlobalModeInitialized = useRef(false); const hasJoinedGroup = useRef(false); - const wasConnectedRef = useRef(false); const fetchBlockedDevices = useCallback(async (currentSessions: Session[]) => { if (!adminModeRef.current || !globalAdminModeRef.current) { @@ -478,21 +478,12 @@ export function useDashboardSessions({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [user, tenantId, globalAdminMode]); - // Join tenant SignalR group; refetch on reconnect + // Join tenant SignalR group useEffect(() => { - if (isConnected) { - const isReconnect = wasConnectedRef.current; - wasConnectedRef.current = true; - - if (!hasJoinedGroup.current) { - const groupName = `tenant-${tenantId}`; - hasJoinedGroup.current = true; - joinGroup(groupName); - } - - if (isReconnect && hasInitialFetch.current) { - fetchSessions(); - } + if (isConnected && !hasJoinedGroup.current) { + const groupName = `tenant-${tenantId}`; + hasJoinedGroup.current = true; + joinGroup(groupName); } return () => { @@ -505,9 +496,14 @@ export function useDashboardSessions({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isConnected, tenantId]); + // Refetch after every connection gap: sessions pushed meanwhile were missed. + useSignalRResync(() => { + if (hasInitialFetch.current) fetchSessions(); + }); + // Join the cross-tenant global-admins broadcast group while in GA mode. Real GA only (joinGlobalAdmins) — a // delegated ("MSP") caller has no platform scope and would be rejected (403); the dashboard still reads - // the bounded aggregate and recovers live state via the reconnect refetch above. The global notification + // the bounded aggregate and recovers live state via the gap refetch above. The global notification // bell holds the same group for the whole session: group membership is reference-counted per name in the // SignalR layer, so the cleanup's leave drops only the dashboard's own reference (an unconditional leave // whenever GA mode was off used to drop the bell's membership as well). diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts index 36f5031c..5f8af8be 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardStats.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; import { useLatest } from "@/hooks/useLatest"; +import { useSignalRResync } from "@/contexts/SignalRContext"; import { asGuidOrUndefined } from "@/utils/inputValidation"; import { boundTenantToDelegatedScope } from "@/utils/delegatedScope"; import { isHomeTenantTarget } from "@/utils/homeTenantScope"; @@ -83,8 +84,8 @@ const SIGNALR_DEBOUNCE_AGGREGATE_MS = 30_000; * load-more cursor and printed a fictional "Last 7 days" label. * * Refresh model: initial fetch on scope change + debounced refetch on every - * SignalR newSession/newevents in the active scope. On SignalR reconnect we - * also force a refetch so any messages missed during the outage are reflected. + * SignalR newSession/newevents in the active scope. After every connection gap + * we also force a refetch so any messages missed meanwhile are reflected. */ export function useDashboardStats({ tenantId, @@ -118,10 +119,6 @@ export function useDashboardStats({ // newevents in one second during a session storm) collapse into a single // backend call ~SIGNALR_DEBOUNCE_MS after the last event. const debounceTimerRef = useRef | null>(null); - // Tracks whether SignalR was connected on the previous render so we can - // distinguish "first connection" from "reconnect after outage" — the latter - // needs an explicit refetch to recover from missed messages. - const wasConnectedRef = useRef(false); const fetchStats = useCallback(async (): Promise => { if (disabledRef.current) return; @@ -213,7 +210,7 @@ export function useDashboardStats({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [tenantId, globalAdminMode, submittedTenantIdFilter, days, disabled]); - // SignalR-triggered debounced refetch + reconnect-recovery. + // SignalR-triggered debounced refetch. useEffect(() => { if (disabled) return; @@ -250,18 +247,10 @@ export function useDashboardStats({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [signalR.isConnected, disabled]); - // Reconnect-recovery: when SignalR transitions disconnected → connected and - // we already had an initial fetch, force a refetch to pick up any sessions - // we missed during the outage. - useEffect(() => { - if (disabled) return; - if (signalR.isConnected) { - const isReconnect = wasConnectedRef.current; - wasConnectedRef.current = true; - if (isReconnect) refresh(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [signalR.isConnected, disabled]); + // Refetch after every connection gap: sessions pushed meanwhile were missed. + useSignalRResync(() => { + if (!disabledRef.current) refresh(); + }); // Cleanup on unmount. useEffect(() => { diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx index 54df0b28..3b6f0daf 100644 --- a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx +++ b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx @@ -3,7 +3,7 @@ import { sessionUrl } from "@/lib/routes"; import { Suspense, useCallback, useEffect, useState, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useSignalR } from "../../contexts/SignalRContext"; +import { useSignalR, useSignalRResync } from "../../contexts/SignalRContext"; import { useTenant } from "../../contexts/TenantContext"; import { useAuth } from "../../contexts/AuthContext"; import { useNotifications } from "../../contexts/NotificationContext"; @@ -96,6 +96,12 @@ function DiagnosisContent() { } }, [sessionId, sessionTenantId, tenantId, globalAdminMode, getAccessToken, finishLoadingWhenSettled, addNotification]); + // After every connection gap the session object is re-read; events and analysis come through + // the join catch-up below. + useSignalRResync(() => { + if (sessionId) void fetchSessionDetails(); + }); + const fetchEvents = useCallback(async () => { const effectiveTenantId = sessionTenantId || tenantId; try { diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts b/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts index 9ef0b8e3..07c5d38c 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts +++ b/src/Web/autopilot-monitor-web/app/fleet-health/hooks/useFleetHealth.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { scopedApi } from "@/lib/scopedApi"; import { useLatest } from "@/hooks/useLatest"; +import { useSignalRResync } from "@/contexts/SignalRContext"; import type { SignalRMessageName } from "@/lib/signalrMessages"; import { ApiError, fetchJson } from "@/lib/apiClient"; @@ -57,7 +58,7 @@ const SIGNALR_DEBOUNCE_MS = 3000; * * Refresh model mirrors useDashboardStats: initial fetch on scope/days change + * debounced refetch on every in-scope SignalR newSession/newevents, with a - * forced refetch on reconnect to recover messages missed during an outage. + * forced refetch after every connection gap to recover messages missed meanwhile. */ export function useFleetHealth({ routeGlobal, @@ -82,7 +83,6 @@ export function useFleetHealth({ // Invalidates an in-flight fetch when scope/days shift mid-request. const fetchGenRef = useRef(0); const debounceTimerRef = useRef | null>(null); - const wasConnectedRef = useRef(false); const fetchData = useCallback(async (): Promise => { const myGen = ++fetchGenRef.current; @@ -186,15 +186,8 @@ export function useFleetHealth({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [signalR.isConnected]); - // Reconnect-recovery: refetch when SignalR transitions disconnected → connected. - useEffect(() => { - if (signalR.isConnected) { - const isReconnect = wasConnectedRef.current; - wasConnectedRef.current = true; - if (isReconnect) refresh(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [signalR.isConnected]); + // Refetch after every connection gap: changes pushed meanwhile were missed. + useSignalRResync(refresh); // Cleanup on unmount. useEffect(() => { diff --git a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts index 92d8d5b3..09ee3ebb 100644 --- a/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts +++ b/src/Web/autopilot-monitor-web/app/progress/hooks/useProgressSignalR.ts @@ -3,7 +3,7 @@ import { useEffect, useRef } from "react"; import { Session } from "@/types"; import type { SignalRMessageName } from "@/lib/signalrMessages"; -import type { JoinGroupOptions } from "@/contexts/SignalRContext"; +import { useSignalRResync, type JoinGroupOptions } from "@/contexts/SignalRContext"; import type { NotificationType } from "@/contexts/NotificationContext"; interface SignalRApi { @@ -41,7 +41,8 @@ interface UseProgressSignalRParams { * mark-succeeded/failed — arrive on the session group anyway) * - surfaces a refused join as a notification instead of a silently frozen page * (a swallowed join 403 is exactly how the c4dabeee regression stayed invisible) - * - listens for newevents / newSession / eventStream → debounced refetch + * - listens for newevents / newSession / eventStream → debounced refetch, and refetches after + * every connection gap * - cleans up groups + handlers on unmount / session change */ export function useProgressSignalR({ @@ -90,6 +91,10 @@ export function useProgressSignalR({ }; }, [isConnected, sessionId, sessionTenantId, sessionSerial, joinGroup, leaveGroup]); + // After every connection gap: a progress page left open across the end of an enrollment would + // otherwise keep the last state it saw. No-op while no session is selected. + useSignalRResync(scheduleFetchEvents); + useEffect(() => { const scheduleRefetch = (source: string, sessionId: string) => { if (!sessionRef.current || sessionId !== sessionRef.current.sessionId) return; diff --git a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionSignalR.ts b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionSignalR.ts index 9b075dd5..19e4918a 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionSignalR.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionSignalR.ts @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { Session, RuleResult } from "@/types"; import { isTerminalStatus } from "@/utils/sessionStatus"; import type { SignalRMessageName } from "@/lib/signalrMessages"; +import { useSignalRResync } from "@/contexts/SignalRContext"; import type { FetchEventsReason } from "./useSessionEvents"; interface SignalRApi { @@ -29,6 +30,7 @@ interface UseSessionSignalRParams { scheduleFetchEvents: (reason?: FetchEventsReason) => void; setSession: React.Dispatch>; setSessionTenantId: React.Dispatch>; + fetchSessionDetails: () => Promise; fetchAnalysisResults: (reanalyze?: boolean) => Promise; fetchVulnerabilityReport: (rescan?: boolean) => Promise; } @@ -50,6 +52,7 @@ export function applySessionUpdate(prev: Session, update: Partial): Ses * Owns the session detail page's SignalR integration: * - joins tenant + session groups using subscribe-then-fetch pattern * - listens for eventStream, newevents, ruleResultsReady, vulnerabilityReportReady + * - re-reads the session, analysis and vulnerability report after every connection gap * - cleans up groups + handlers on unmount / sessionId change */ export function useSessionSignalR({ @@ -65,11 +68,22 @@ export function useSessionSignalR({ scheduleFetchEvents, setSession, setSessionTenantId, + fetchSessionDetails, fetchAnalysisResults, fetchVulnerabilityReport, }: UseSessionSignalRParams): void { const { on, off, isConnected, joinGroup, leaveGroup } = signalR; + // After every connection gap. The events come through the join catch-up below; the session + // object (its status arrives as a newevents delta), the analysis and the vulnerability report + // only have their pushes, so they are re-read here. + useSignalRResync(() => { + if (!sessionIdRef.current) return; + void fetchSessionDetails(); + void fetchAnalysisResults(); + void fetchVulnerabilityReport(); + }); + // Join SignalR groups when connected (for multi-tenancy and cost optimization) // Uses "subscribe-then-fetch" pattern: join groups first, then re-fetch events // to catch anything that arrived before the group join completed. diff --git a/src/Web/autopilot-monitor-web/app/sessions/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/page.tsx index 3f568bfe..556422c5 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/page.tsx @@ -138,6 +138,7 @@ function SessionDetailContent() { scheduleFetchEvents: eventsApi.scheduleFetchEvents, setSession: detail.setSession, setSessionTenantId: detail.setSessionTenantId, + fetchSessionDetails: detail.fetchSessionDetails, fetchAnalysisResults: analysis.fetchAnalysisResults, fetchVulnerabilityReport: analysis.fetchVulnerabilityReport, }); diff --git a/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx b/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx index f3968b53..f9c05350 100644 --- a/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/GlobalNotificationContext.tsx @@ -2,7 +2,7 @@ import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { useAuth } from './AuthContext'; -import { useSignalR } from './SignalRContext'; +import { useSignalR, useSignalRResync } from './SignalRContext'; import { api } from '@/lib/api'; import type { GlobalNotificationDto, NotificationListResponse } from '@/utils/wire-types.generated'; import { fetchJson, fetchOk } from "@/lib/apiClient"; @@ -50,9 +50,8 @@ export function GlobalNotificationProvider({ children }: { children: React.React } }, [hasGlobalScope, getAccessToken]); - // Initial state hydration. Re-fetched on SignalR reconnect to recover any deltas - // pushed during the disconnect window. Without scope nothing is fetched — the - // provider value below masks the list to empty instead of clearing state here. + // Initial state hydration. Without scope nothing is fetched — the provider value below + // masks the list to empty instead of clearing state here. useEffect(() => { if (!hasGlobalScope) return; const run = async () => { @@ -62,11 +61,10 @@ export function GlobalNotificationProvider({ children }: { children: React.React void run(); }, [hasGlobalScope, fetchNotifications]); - useEffect(() => { - if (!connection || !hasGlobalScope) return; - const handler = () => { fetchNotifications(); }; - connection.onreconnected(handler); - }, [connection, hasGlobalScope, fetchNotifications]); + // Re-fetch after every connection gap — covers the deltas pushed while the connection was down. + useSignalRResync(() => { + if (hasGlobalScope) void fetchNotifications(); + }); // Re-fetch when globalAdminMode is toggled in localStorage useEffect(() => { diff --git a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx index 33df0e41..ada8fbb2 100644 --- a/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/SignalRContext.tsx @@ -48,6 +48,8 @@ interface SignalRContextType { isConnected: boolean; /** Groups the hub connection is a member of (joined, or a join in flight) — not the reference counts. */ joinedGroups: string[]; + /** Counts the ends of connection gaps; consumers read it through useSignalRResync, never directly. */ + resyncEpoch: number; } const SignalRContext = createContext(undefined); @@ -93,6 +95,22 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { // Set in onreconnecting, read in onreconnected so we can report downtime as a measurement. const disconnectStartedAtRef = useRef(null); const maxRetries = 3; + // Pushes only announce that data changed; after a gap in the connection every consumer re-reads + // (useSignalRResync). A gap ends with a reconnect, or with a start of a connection that was up + // before. Each end bumps resyncRequest; the epoch follows once the group joins of that moment have + // settled, so a push sent between a re-read and its group's rejoin cannot get lost. + const hasConnectedBeforeRef = useRef(false); + const [resyncRequest, setResyncRequest] = useState(0); + const [resyncEpoch, setResyncEpoch] = useState(0); + // Hub joins in flight per group (joinGroup and the reconnect rejoin), as never-rejecting promises. + const pendingJoinsRef = useRef>>(new Map()); + const trackJoin = useCallback((groupName: string, join: Promise) => { + const settled = join.then(() => undefined, () => undefined); + pendingJoinsRef.current.set(groupName, settled); + void settled.then(() => { + if (pendingJoinsRef.current.get(groupName) === settled) pendingJoinsRef.current.delete(groupName); + }); + }, []); // One stable token getter for every hub-side call (connection factory, join, leave, rejoin, the // deferred leave fired from a timer): always the latest getAccessToken without re-creating the @@ -145,6 +163,19 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { useEffect(() => groupRegistry.onLeave((groupName) => { void leaveHubGroup(groupName); }), [groupRegistry, leaveHubGroup]); + // Child effects run before the provider's: the consumers have issued the joins of the commit that + // ended the gap by now, so waiting for every pending join covers theirs too. + useEffect(() => { + if (resyncRequest === 0) return; + let cancelled = false; + const run = async () => { + await Promise.all(Array.from(pendingJoinsRef.current.values())); + if (!cancelled) setResyncEpoch((epoch) => epoch + 1); + }; + void run(); + return () => { cancelled = true; }; + }, [resyncRequest]); + useEffect(() => { // Only create connection if authenticated if (!isAuthenticated) { @@ -222,8 +253,8 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { groupRegistry.restartPendingLeaves(); syncJoinedGroups(); - const results = await Promise.allSettled(rejoinGroups.map((groupName) => - fetchOk(api.realtime.joinGroup(), getToken, { + const results = await Promise.allSettled(rejoinGroups.map((groupName) => { + const join = fetchOk(api.realtime.joinGroup(), getToken, { method: 'POST', // Re-present the stored serial proof for session groups: a roleless // Progress-Portal user's rejoin is refused without it. @@ -232,7 +263,10 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { groupName, serialNumber: joinSerialsRef.current.get(groupName), }), - }))); + }); + trackJoin(groupName, join); + return join; + })); let rejoined = 0; results.forEach((result, index) => { if (result.status === 'fulfilled') { @@ -254,6 +288,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { downtimeMs, transport: getTransportName(newConnection), }); + setResyncRequest((request) => request + 1); }); // Start connection @@ -263,6 +298,10 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { setConnectionState(HubConnectionState.Connected); setConnection(newConnection); retryCountRef.current = 0; + // A restart of a connection that was up before (accessRevoked, a resume) ends a gap. The + // consumers rejoin from the Connected transition committed together with this request. + if (hasConnectedBeforeRef.current) setResyncRequest((request) => request + 1); + hasConnectedBeforeRef.current = true; // Surfaces clients stuck on a fallback transport (e.g. corporate proxies blocking // WebSockets) — those sessions get slower live updates and produce long-poll 404 // noise in dependency telemetry. @@ -325,7 +364,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { connectionRef.current = null; } }; - }, [isAuthenticated, getToken, syncJoinedGroups, groupRegistry]); + }, [isAuthenticated, getToken, syncJoinedGroups, groupRegistry, trackJoin]); // Keyed on the connection object (set once start() succeeded, kept across the accessRevoked // stop/start of the same object): consumer effects that list on/off as dependencies register @@ -357,8 +396,10 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { } // One hub membership serves every holder: already a member, a join in flight, or a pending - // leave whose grace period the acquire above just cancelled. + // leave whose grace period the acquire above just cancelled. A join in flight is awaited, so a + // resolved joinGroup always means "member" (the subscribe-then-fetch consumers rely on it). if (joinedGroupsRef.current.has(groupName)) { + await pendingJoinsRef.current.get(groupName); return; } @@ -396,7 +437,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { } try { - await fetchOk(api.realtime.joinGroup(), getToken, { + const join = fetchOk(api.realtime.joinGroup(), getToken, { method: 'POST', body: jsonBody({ connectionId, @@ -404,6 +445,8 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { serialNumber: options?.serialNumber, }), }); + trackJoin(groupName, join); + await join; } catch (err) { if (!(err instanceof ApiError)) throw err; // Remove from Set if the join was refused (so we can retry). A refused join must be @@ -422,7 +465,7 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { joinSerialsRef.current.delete(groupName); syncJoinedGroups(); } - }, [groupRegistry, getToken, syncJoinedGroups, callerTenantId, callerRole, callerIsTenantAdmin, callerIsGlobalAdmin, callerIsGlobalReader]); + }, [groupRegistry, getToken, syncJoinedGroups, trackJoin, callerTenantId, callerRole, callerIsTenantAdmin, callerIsGlobalAdmin, callerIsGlobalReader]); const leaveGroup = useCallback((groupName: string): Promise => { // Only this consumer's reference is dropped here; the hub leave follows from the registry @@ -444,7 +487,8 @@ export function SignalRProvider({ children }: { children: React.ReactNode }) { leaveGroup, isConnected, joinedGroups, - }), [connection, connectionState, connectionId, on, off, invoke, joinGroup, leaveGroup, isConnected, joinedGroups]); + resyncEpoch, + }), [connection, connectionState, connectionId, on, off, invoke, joinGroup, leaveGroup, isConnected, joinedGroups, resyncEpoch]); return ( @@ -460,3 +504,20 @@ export function useSignalR() { } return context; } + +/** + * Calls `onResync` after every gap in the hub connection, once the rejoins have settled: the consumer + * re-reads whatever a push during the gap would have announced. Never on mount (the consumer's own + * initial fetch covers that). The one re-read path — consumers never subscribe to the connection's + * reconnect callbacks themselves. + */ +export function useSignalRResync(onResync: () => void): void { + const { resyncEpoch } = useSignalR(); + const onResyncRef = useLatest(onResync); + const seenEpochRef = useRef(resyncEpoch); + useEffect(() => { + if (seenEpochRef.current === resyncEpoch) return; + seenEpochRef.current = resyncEpoch; + onResyncRef.current(); + }, [resyncEpoch, onResyncRef]); +} diff --git a/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx b/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx index 0120f58a..bd03927d 100644 --- a/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx +++ b/src/Web/autopilot-monitor-web/contexts/TenantNotificationContext.tsx @@ -2,7 +2,7 @@ import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { useAuth } from './AuthContext'; -import { useSignalR } from './SignalRContext'; +import { useSignalR, useSignalRResync } from './SignalRContext'; import { canFetchTenantNotifications } from './tenantNotificationsGate'; import { api } from '@/lib/api'; import type { GlobalNotificationDto, NotificationListResponse } from '@/utils/wire-types.generated'; @@ -53,10 +53,8 @@ export function TenantNotificationProvider({ children }: { children: React.React } }, [canFetchNotifications, getAccessToken]); - // Initial state hydration. Runs once on mount and again after every SignalR reconnect to - // recover any deltas that were emitted while the SignalR connection was down. Without - // fetch rights nothing is fetched — the provider value below masks the list to empty - // instead of clearing state here. + // Initial state hydration. Without fetch rights nothing is fetched — the provider value below + // masks the list to empty instead of clearing state here. useEffect(() => { if (!canFetchNotifications) return; const run = async () => { @@ -66,14 +64,10 @@ export function TenantNotificationProvider({ children }: { children: React.React void run(); }, [canFetchNotifications, fetchNotifications]); - // Re-fetch on SignalR reconnect — covers the deltas pushed during the disconnect window. - useEffect(() => { - if (!connection || !canFetchNotifications) return; - const handler = () => { fetchNotifications(); }; - connection.onreconnected(handler); - // The signalr-js client does not expose an unsubscribe for onreconnected; rely on the - // SignalR connection lifecycle (one connection per app session) for cleanup. - }, [connection, canFetchNotifications, fetchNotifications]); + // Re-fetch after every connection gap — covers the deltas pushed while the connection was down. + useSignalRResync(() => { + if (canFetchNotifications) void fetchNotifications(); + }); // Group membership: Member-tier always; Admin-tier only for Tenant Admins / Global Admins. useEffect(() => {