Fix online board live refresh parity

This commit is contained in:
2026-05-06 22:49:15 +03:00
parent 1d32c5d0c6
commit 53b48a62dd
11 changed files with 240 additions and 32 deletions
@@ -385,7 +385,7 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
flights: `${flightId.carrier}${flightId.flightNumber}${flightId.suffix ?? ""}`, flights: `${flightId.carrier}${flightId.flightNumber}${flightId.suffix ?? ""}`,
dates: `${flightId.date.slice(0, 4)}-${flightId.date.slice(4, 6)}-${flightId.date.slice(6, 8)}T00:00:00`, dates: `${flightId.date.slice(0, 4)}-${flightId.date.slice(4, 6)}-${flightId.date.slice(6, 8)}T00:00:00`,
}; };
const { flight: firstFlight, allFlights, daysOfFlight, loading, error } = useFlightDetails(detailsParams); const { flight: firstFlight, allFlights, daysOfFlight, loading, error, refresh } = useFlightDetails(detailsParams);
// Pick the flight matching the URL's flightId (date-based match). The API // Pick the flight matching the URL's flightId (date-based match). The API
// response may contain multiple flights with the same flight number on // response may contain multiple flights with the same flight number on
@@ -395,8 +395,9 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
// Live updates via SignalR // Live updates via SignalR
const { flight: liveFlight, connectionStatus } = useLiveFlightDetails( const { flight: liveFlight, connectionStatus } = useLiveFlightDetails(
flightId, flight?.flightId ?? flightId,
flight, flight,
refresh,
); );
const displayFlight = connectionStatus === "live" && liveFlight ? liveFlight : flight; const displayFlight = connectionStatus === "live" && liveFlight ? liveFlight : flight;
@@ -375,6 +375,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
const { flights: liveFlights, connectionStatus } = useLiveBoardSearch( const { flights: liveFlights, connectionStatus } = useLiveBoardSearch(
liveBoardParams, liveBoardParams,
flights, flights,
refresh,
); );
// Calendar days // Calendar days
@@ -8,7 +8,7 @@
* @module * @module
*/ */
import { useState, useEffect, useRef } from "react"; import { useCallback, useState, useEffect, useRef } from "react";
import { useApiClient } from "@/shared/api/provider.js"; import { useApiClient } from "@/shared/api/provider.js";
import { getFlightDetails } from "../api.js"; import { getFlightDetails } from "../api.js";
import type { FlightDetailsParams } from "../api.js"; import type { FlightDetailsParams } from "../api.js";
@@ -21,6 +21,7 @@ export interface UseFlightDetailsResult {
daysOfFlight: string[]; daysOfFlight: string[];
loading: boolean; loading: boolean;
error: ApiError | null; error: ApiError | null;
refresh: () => void;
} }
/** /**
@@ -35,10 +36,15 @@ export function useFlightDetails(
const [daysOfFlight, setDaysOfFlight] = useState<string[]>([]); const [daysOfFlight, setDaysOfFlight] = useState<string[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<ApiError | null>(null); const [error, setError] = useState<ApiError | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const paramsRef = useRef(params); const paramsRef = useRef(params);
paramsRef.current = params; paramsRef.current = params;
const refresh = useCallback(() => {
setRefreshKey((k) => k + 1);
}, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
@@ -62,7 +68,7 @@ export function useFlightDetails(
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [client, params.flights, params.dates]); }, [client, params.flights, params.dates, refreshKey]);
return { return {
flight: allFlights[0] ?? null, flight: allFlights[0] ?? null,
@@ -70,5 +76,6 @@ export function useFlightDetails(
daysOfFlight, daysOfFlight,
loading, loading,
error, error,
refresh,
}; };
} }
@@ -30,6 +30,7 @@ function createMockHub() {
return { return {
start: vi.fn().mockResolvedValue(undefined), start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined),
invoke: vi.fn().mockResolvedValue(undefined),
on: vi.fn((method: string, handler: (...args: unknown[]) => void) => { on: vi.fn((method: string, handler: (...args: unknown[]) => void) => {
const list = handlers[method] ?? []; const list = handlers[method] ?? [];
handlers[method] = list; handlers[method] = list;
@@ -179,7 +180,7 @@ describe("useLiveBoardSearch", () => {
expect(result.current.flights).toEqual(initial); expect(result.current.flights).toEqual(initial);
}); });
it("subscribes to the correct channel", async () => { it("subscribes to Angular TrackerHub refresh and invokes SubscribeDate", async () => {
const { hub } = installMockHub(HUB_URL); const { hub } = installMockHub(HUB_URL);
const params: LiveBoardSearchParams = { const params: LiveBoardSearchParams = {
date: "20250115", date: "20250115",
@@ -193,17 +194,19 @@ describe("useLiveBoardSearch", () => {
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
}); });
expect(hub.on).toHaveBeenCalledWith( expect(hub.on).toHaveBeenCalledWith("RefreshDate", expect.any(Function));
"board:20250115:SVO:LED", expect(hub.invoke).toHaveBeenCalledWith(
expect.any(Function), "SubscribeDate",
"20250115",
"SVO",
"LED",
); );
}); });
it("updates flights when a SignalR message arrives", async () => { it("updates flights when a legacy array message arrives", async () => {
const { hub } = installMockHub(HUB_URL); const { hub } = installMockHub(HUB_URL);
const params: LiveBoardSearchParams = { date: "20250115" }; const params: LiveBoardSearchParams = { date: "20250115" };
const initial = [makeFlight("f1")]; const initial = [makeFlight("f1")];
const channel = "board:20250115::";
const { result } = renderHook(() => const { result } = renderHook(() =>
useLiveBoardSearch(params, initial), useLiveBoardSearch(params, initial),
@@ -215,12 +218,30 @@ describe("useLiveBoardSearch", () => {
const updated = [makeFlight("f2")]; const updated = [makeFlight("f2")];
act(() => { act(() => {
hub._simulateMessage(channel, updated); hub._simulateMessage("RefreshDate", updated);
}); });
expect(result.current.flights).toEqual(updated); expect(result.current.flights).toEqual(updated);
}); });
it("refreshes API data when TrackerHub sends RefreshDate", async () => {
const { hub } = installMockHub(HUB_URL);
const params: LiveBoardSearchParams = { date: "20250115" };
const onRefresh = vi.fn();
renderHook(() => useLiveBoardSearch(params, [], onRefresh));
await act(async () => {
await vi.runAllTimersAsync();
});
act(() => {
hub._simulateMessage("RefreshDate", "20250115");
});
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("returns idle connectionStatus during SSR", () => { it("returns idle connectionStatus during SSR", () => {
const origWindow = globalThis.window; const origWindow = globalThis.window;
// @ts-expect-error -- intentionally deleting window for SSR simulation // @ts-expect-error -- intentionally deleting window for SSR simulation
@@ -49,13 +49,24 @@ export function buildBoardChannelKey(params: LiveBoardSearchParams): string {
export function useLiveBoardSearch( export function useLiveBoardSearch(
params: LiveBoardSearchParams, params: LiveBoardSearchParams,
initialFlights: ISimpleFlight[], initialFlights: ISimpleFlight[],
onRefresh?: () => void,
): UseLiveBoardSearchResult { ): UseLiveBoardSearchResult {
const config = useMemo<UseLiveFlightsConfig<LiveBoardSearchParams>>( const config = useMemo<UseLiveFlightsConfig<LiveBoardSearchParams>>(
() => ({ () => ({
hubUrl: getEnv().SIGNALR_HUB_URL, hubUrl: getEnv().SIGNALR_HUB_URL,
channelKey: buildBoardChannelKey, channelKey: buildBoardChannelKey,
subscription: {
eventName: "RefreshDate",
invokeMethodName: "SubscribeDate",
args: (p) => [p.date, p.departure, p.arrival],
},
onMessage: (message) => {
if (Array.isArray(message)) return message;
onRefresh?.();
return undefined;
},
}), }),
[], [onRefresh],
); );
const { data, connectionStatus } = useLiveFlights< const { data, connectionStatus } = useLiveFlights<
@@ -9,6 +9,7 @@ import {
} from "@/shared/signalr/connection.js"; } from "@/shared/signalr/connection.js";
import { import {
buildFlightChannelKey, buildFlightChannelKey,
buildTrackerFlightSubscriptionKey,
useLiveFlightDetails, useLiveFlightDetails,
} from "./useLiveFlightDetails.js"; } from "./useLiveFlightDetails.js";
import type { ISimpleFlight, IFlightId, IFlightLeg, IParsedFlightId } from "../types.js"; import type { ISimpleFlight, IFlightId, IFlightLeg, IParsedFlightId } from "../types.js";
@@ -32,6 +33,7 @@ function createMockHub() {
return { return {
start: vi.fn().mockResolvedValue(undefined), start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined),
invoke: vi.fn().mockResolvedValue(undefined),
on: vi.fn((method: string, handler: (...args: unknown[]) => void) => { on: vi.fn((method: string, handler: (...args: unknown[]) => void) => {
const list = handlers[method] ?? []; const list = handlers[method] ?? [];
handlers[method] = list; handlers[method] = list;
@@ -160,6 +162,33 @@ describe("buildFlightChannelKey", () => {
}); });
}); });
describe("buildTrackerFlightSubscriptionKey", () => {
it("uses compact URL date when dateLT is unavailable", () => {
const id: IParsedFlightId = {
carrier: "SU",
flightNumber: "100",
suffix: "A",
date: "20250115",
};
expect(buildTrackerFlightSubscriptionKey(id)).toBe("SU100A@20250115");
});
it("uses API dateLT as-is when available", () => {
const id: IFlightId = {
carrier: "SU",
flightNumber: "0100",
suffix: "",
date: "2025-01-15",
dateLT: "2025-01-15T00:00:00",
};
expect(buildTrackerFlightSubscriptionKey(id)).toBe(
"SU0100@2025-01-15T00:00:00",
);
});
});
describe("useLiveFlightDetails", () => { describe("useLiveFlightDetails", () => {
const HUB_URL = "https://hub.test/tracker"; const HUB_URL = "https://hub.test/tracker";
@@ -211,13 +240,13 @@ describe("useLiveFlightDetails", () => {
expect(result.current.flight).toBeNull(); expect(result.current.flight).toBeNull();
}); });
it("subscribes to the correct channel", async () => { it("subscribes to Angular TrackerHub refresh and invokes Subscribe", async () => {
const { hub } = installMockHub(HUB_URL); const { hub } = installMockHub(HUB_URL);
const id: IParsedFlightId = { const id: IParsedFlightId = {
carrier: "SU", carrier: "SU",
flightNumber: "100", flightNumber: "100",
suffix: "A", suffix: "A",
date: "2025-01-15", date: "20250115",
}; };
renderHook(() => useLiveFlightDetails(id, null)); renderHook(() => useLiveFlightDetails(id, null));
@@ -226,20 +255,17 @@ describe("useLiveFlightDetails", () => {
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
}); });
expect(hub.on).toHaveBeenCalledWith( expect(hub.on).toHaveBeenCalledWith("Refresh", expect.any(Function));
"flight:SU100A@2025-01-15", expect(hub.invoke).toHaveBeenCalledWith("Subscribe", "SU100A@20250115");
expect.any(Function),
);
}); });
it("updates flight when a SignalR message arrives", async () => { it("updates flight when a legacy array message arrives", async () => {
const { hub } = installMockHub(HUB_URL); const { hub } = installMockHub(HUB_URL);
const id: IParsedFlightId = { const id: IParsedFlightId = {
carrier: "SU", carrier: "SU",
flightNumber: "100", flightNumber: "100",
date: "2025-01-15", date: "2025-01-15",
}; };
const channel = "flight:SU100@2025-01-15";
const initial = makeFlight("f1"); const initial = makeFlight("f1");
const { result } = renderHook(() => const { result } = renderHook(() =>
@@ -252,13 +278,35 @@ describe("useLiveFlightDetails", () => {
const updated = [makeFlight("f2")]; const updated = [makeFlight("f2")];
act(() => { act(() => {
hub._simulateMessage(channel, updated); hub._simulateMessage("Refresh", updated);
}); });
// useLiveFlights replaces the full array; our hook takes [0] // useLiveFlights replaces the full array; our hook takes [0]
expect(result.current.flight).toEqual(updated[0]); expect(result.current.flight).toEqual(updated[0]);
}); });
it("refreshes API data when TrackerHub sends Refresh", async () => {
const { hub } = installMockHub(HUB_URL);
const id: IParsedFlightId = {
carrier: "SU",
flightNumber: "100",
date: "20250115",
};
const onRefresh = vi.fn();
renderHook(() => useLiveFlightDetails(id, null, onRefresh));
await act(async () => {
await vi.runAllTimersAsync();
});
act(() => {
hub._simulateMessage("Refresh", "SU0100@20250115");
});
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("returns idle connectionStatus during SSR", () => { it("returns idle connectionStatus during SSR", () => {
const origWindow = globalThis.window; const origWindow = globalThis.window;
// @ts-expect-error -- intentionally deleting window for SSR simulation // @ts-expect-error -- intentionally deleting window for SSR simulation
@@ -16,7 +16,7 @@ import {
type UseLiveFlightsConfig, type UseLiveFlightsConfig,
} from "@/shared/hooks/useLiveFlights.js"; } from "@/shared/hooks/useLiveFlights.js";
import type { ConnectionStatus } from "@/shared/signalr/connection.js"; import type { ConnectionStatus } from "@/shared/signalr/connection.js";
import type { ISimpleFlight, IParsedFlightId } from "../types.js"; import type { ISimpleFlight, IFlightId, IParsedFlightId } from "../types.js";
import { getEnv } from "@/env/index.js"; import { getEnv } from "@/env/index.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -32,24 +32,42 @@ export interface UseLiveFlightDetailsResult {
// Channel key builder (exported for testing) // Channel key builder (exported for testing)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function buildFlightChannelKey(id: IParsedFlightId): string { type LiveFlightId = IParsedFlightId | IFlightId;
export function buildFlightChannelKey(id: LiveFlightId): string {
return `flight:${id.carrier}${id.flightNumber}${id.suffix ?? ""}@${id.date}`; return `flight:${id.carrier}${id.flightNumber}${id.suffix ?? ""}@${id.date}`;
} }
export function buildTrackerFlightSubscriptionKey(id: LiveFlightId): string {
const date = "dateLT" in id && id.dateLT ? id.dateLT : id.date.replace(/-/g, "");
return `${id.carrier}${id.flightNumber}${id.suffix ?? ""}@${date}`;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Hook // Hook
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function useLiveFlightDetails( export function useLiveFlightDetails(
id: IParsedFlightId, id: LiveFlightId,
initialFlight: ISimpleFlight | null, initialFlight: ISimpleFlight | null,
onRefresh?: () => void,
): UseLiveFlightDetailsResult { ): UseLiveFlightDetailsResult {
const config = useMemo<UseLiveFlightsConfig<IParsedFlightId>>( const config = useMemo<UseLiveFlightsConfig<LiveFlightId>>(
() => ({ () => ({
hubUrl: getEnv().SIGNALR_HUB_URL, hubUrl: getEnv().SIGNALR_HUB_URL,
channelKey: buildFlightChannelKey, channelKey: buildFlightChannelKey,
subscription: {
eventName: "Refresh",
invokeMethodName: "Subscribe",
args: (flightId) => [buildTrackerFlightSubscriptionKey(flightId)],
},
onMessage: (message) => {
if (Array.isArray(message)) return message;
onRefresh?.();
return undefined;
},
}), }),
[], [onRefresh],
); );
// useLiveFlights expects an array — wrap/unwrap the single flight // useLiveFlights expects an array — wrap/unwrap the single flight
@@ -59,7 +77,7 @@ export function useLiveFlightDetails(
); );
const { data, connectionStatus } = useLiveFlights< const { data, connectionStatus } = useLiveFlights<
IParsedFlightId, LiveFlightId,
ISimpleFlight ISimpleFlight
>(id, initialData, config); >(id, initialData, config);
+32
View File
@@ -16,6 +16,7 @@ function createMockHub() {
return { return {
start: vi.fn().mockResolvedValue(undefined), start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined),
invoke: vi.fn().mockResolvedValue(undefined),
on: vi.fn((method: string, handler: (...args: unknown[]) => void) => { on: vi.fn((method: string, handler: (...args: unknown[]) => void) => {
const list = handlers[method] ?? []; const list = handlers[method] ?? [];
handlers[method] = list; handlers[method] = list;
@@ -110,6 +111,37 @@ describe("useLiveFlights", () => {
expect(result.current.data).toEqual(newData); expect(result.current.data).toEqual(newData);
}); });
it("invokes subscription method and lets onMessage trigger a refresh", async () => {
const { hub } = installMockHub(defaultConfig.hubUrl);
const onMessage = vi.fn();
const config = {
...defaultConfig,
subscription: {
eventName: "RefreshDate",
invokeMethodName: "SubscribeDate",
args: (p: { route: string }) => [p.route],
},
onMessage,
};
renderHook(() =>
useLiveFlights({ route: "SVO-LED" }, [], config),
);
await act(async () => {
await vi.runAllTimersAsync();
});
expect(hub.on).toHaveBeenCalledWith("RefreshDate", expect.any(Function));
expect(hub.invoke).toHaveBeenCalledWith("SubscribeDate", "SVO-LED");
act(() => {
hub._simulateMessage("RefreshDate", "20260506");
});
expect(onMessage).toHaveBeenCalledWith("20260506");
});
it("cleans up subscription on unmount", async () => { it("cleans up subscription on unmount", async () => {
const { hub } = installMockHub(defaultConfig.hubUrl); const { hub } = installMockHub(defaultConfig.hubUrl);
+32 -5
View File
@@ -4,9 +4,17 @@ import {
type ConnectionStatus, type ConnectionStatus,
} from "../signalr/connection.js"; } from "../signalr/connection.js";
export interface LiveSubscription<TParams> {
eventName: string;
invokeMethodName: string;
args: (params: TParams) => unknown[];
}
export interface UseLiveFlightsConfig<TParams> { export interface UseLiveFlightsConfig<TParams> {
hubUrl: string; hubUrl: string;
channelKey: (params: TParams) => string; channelKey: (params: TParams) => string;
subscription?: LiveSubscription<TParams>;
onMessage?: (message: unknown) => void | unknown[];
} }
export interface UseLiveFlightsResult<TData> { export interface UseLiveFlightsResult<TData> {
@@ -26,15 +34,18 @@ export function useLiveFlights<TParams, TData>(
initialData: TData[], initialData: TData[],
config: UseLiveFlightsConfig<TParams>, config: UseLiveFlightsConfig<TParams>,
): UseLiveFlightsResult<TData> { ): UseLiveFlightsResult<TData> {
const [data, setData] = useState<TData[]>(initialData); const [data, setData] = useState<TData[] | null>(null);
const [connectionStatus, setConnectionStatus] = const [connectionStatus, setConnectionStatus] =
useState<ConnectionStatus>("idle"); useState<ConnectionStatus>("idle");
// Keep a stable reference to initialData for the SSR short-circuit // Keep a stable reference to initialData for the SSR short-circuit
const initialDataRef = useRef(initialData); const initialDataRef = useRef(initialData);
initialDataRef.current = initialData; initialDataRef.current = initialData;
const onMessageRef = useRef(config.onMessage);
onMessageRef.current = config.onMessage;
const isClient = typeof window !== "undefined"; const isClient = typeof window !== "undefined";
const paramsKey = config.channelKey(params);
useEffect(() => { useEffect(() => {
if (!isClient) return; if (!isClient) return;
@@ -43,8 +54,9 @@ export function useLiveFlights<TParams, TData>(
// real SIGNALR_HUB_URL (same-origin or CORS-enabled). // real SIGNALR_HUB_URL (same-origin or CORS-enabled).
if (!config.hubUrl) return; if (!config.hubUrl) return;
const channel = config.channelKey(params); const channel = config.subscription?.eventName ?? paramsKey;
const connection = getSharedConnection({ hubUrl: config.hubUrl }); const connection = getSharedConnection({ hubUrl: config.hubUrl });
setData(null);
// Sync current status // Sync current status
setConnectionStatus(connection.status); setConnectionStatus(connection.status);
@@ -54,18 +66,33 @@ export function useLiveFlights<TParams, TData>(
}); });
const unsubChannel = connection.subscribe(channel, (message) => { const unsubChannel = connection.subscribe(channel, (message) => {
setData(message as TData[]); const nextData = onMessageRef.current?.(message);
if (Array.isArray(nextData)) {
setData(nextData as TData[]);
return;
}
if (!onMessageRef.current) {
setData(message as TData[]);
}
}); });
const subscription = config.subscription;
if (subscription) {
void connection.invoke(
subscription.invokeMethodName,
...subscription.args(params),
);
}
return () => { return () => {
unsubChannel(); unsubChannel();
unsubStatus(); unsubStatus();
}; };
}, [isClient, config.hubUrl, config.channelKey, params]); }, [isClient, config.hubUrl, config.subscription, paramsKey]);
if (!isClient) { if (!isClient) {
return { data: initialData, connectionStatus: "idle" }; return { data: initialData, connectionStatus: "idle" };
} }
return { data, connectionStatus }; return { data: data ?? initialData, connectionStatus };
} }
+17
View File
@@ -17,6 +17,7 @@ function createMockHub() {
const hub = { const hub = {
start: vi.fn().mockResolvedValue(undefined), start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined),
invoke: vi.fn().mockResolvedValue(undefined),
on: vi.fn((method: string, handler: (...args: unknown[]) => void) => { on: vi.fn((method: string, handler: (...args: unknown[]) => void) => {
const list = handlers[method] ?? []; const list = handlers[method] ?? [];
handlers[method] = list; handlers[method] = list;
@@ -233,6 +234,22 @@ describe("SignalRConnection", () => {
expect(hub.stop).toHaveBeenCalledTimes(1); expect(hub.stop).toHaveBeenCalledTimes(1);
expect(conn.status).toBe("idle"); expect(conn.status).toBe("idle");
}); });
it("invokes hub methods after the lazy connection starts", async () => {
const hub = createMockHub();
const conn = createConnection(hub);
await conn.invoke("SubscribeDate", "20260506", "MOW", "LED");
await vi.runAllTimersAsync();
expect(hub.start).toHaveBeenCalledTimes(1);
expect(hub.invoke).toHaveBeenCalledWith(
"SubscribeDate",
"20260506",
"MOW",
"LED",
);
});
}); });
describe("getSharedConnection", () => { describe("getSharedConnection", () => {
+25
View File
@@ -26,6 +26,7 @@ type MessageHandler = (message: unknown) => void;
interface HubConnectionLike { interface HubConnectionLike {
start(): Promise<void>; start(): Promise<void>;
stop(): Promise<void>; stop(): Promise<void>;
invoke(methodName: string, ...args: unknown[]): Promise<unknown>;
on(methodName: string, handler: (...args: unknown[]) => void): void; on(methodName: string, handler: (...args: unknown[]) => void): void;
off(methodName: string, handler: (...args: unknown[]) => void): void; off(methodName: string, handler: (...args: unknown[]) => void): void;
onclose(callback: (error?: Error) => void): void; onclose(callback: (error?: Error) => void): void;
@@ -93,6 +94,30 @@ export class SignalRConnection {
}; };
} }
/**
* Invoke a hub method after the lazy connection has started.
* Returns false when the hub cannot be reached, so callers can degrade
* quietly while keeping already-fetched data visible.
*/
async invoke(methodName: string, ...args: unknown[]): Promise<boolean> {
if (!this.buildPromise) {
this.buildPromise = this.buildAndStart();
}
await this.buildPromise;
if (this._status !== "live" || !this.connection) {
return false;
}
try {
await this.connection.invoke(methodName, ...args);
return true;
} catch {
this.setStatus("offline");
return false;
}
}
/** Listen for connection status changes. Returns an unsubscribe function. */ /** Listen for connection status changes. Returns an unsubscribe function. */
onStatusChange(handler: StatusHandler): () => void { onStatusChange(handler: StatusHandler): () => void {
this.statusHandlers.add(handler); this.statusHandlers.add(handler);