mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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<void> => {
|
||||
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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const wasConnectedRef = useRef(false);
|
||||
|
||||
const fetchData = useCallback(async (): Promise<void> => {
|
||||
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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<React.SetStateAction<Session | null>>;
|
||||
setSessionTenantId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
fetchSessionDetails: () => Promise<void>;
|
||||
fetchAnalysisResults: (reanalyze?: boolean) => Promise<void>;
|
||||
fetchVulnerabilityReport: (rescan?: boolean) => Promise<void>;
|
||||
}
|
||||
@@ -50,6 +52,7 @@ export function applySessionUpdate(prev: Session, update: Partial<Session>): 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.
|
||||
|
||||
@@ -138,6 +138,7 @@ function SessionDetailContent() {
|
||||
scheduleFetchEvents: eventsApi.scheduleFetchEvents,
|
||||
setSession: detail.setSession,
|
||||
setSessionTenantId: detail.setSessionTenantId,
|
||||
fetchSessionDetails: detail.fetchSessionDetails,
|
||||
fetchAnalysisResults: analysis.fetchAnalysisResults,
|
||||
fetchVulnerabilityReport: analysis.fetchVulnerabilityReport,
|
||||
});
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<SignalRContextType | undefined>(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<number | null>(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<Map<string, Promise<void>>>(new Map());
|
||||
const trackJoin = useCallback((groupName: string, join: Promise<unknown>) => {
|
||||
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<SignalRJoinGroupRequest>({
|
||||
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<void> => {
|
||||
// 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 (
|
||||
<SignalRContext.Provider value={value}>
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user