Fix online board live refresh parity
This commit is contained in:
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user