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:
Oliver Kieselbach
2026-10-09 15:17:49 +02:00
parent 03a8bf92c9
commit 4868a4448d
10 changed files with 138 additions and 81 deletions
@@ -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(() => {