Skip to content

Commit 5b85654

Browse files
committed
feat(frontend): move endpoint availability onto React Query
useEndpointConfig kept its own cache: a module-level globalFetchDone boolean, a mutable globalEndpointCache object, and a resetGlobalCache() called from the JWT listener. Which consumer mounted first decided who paid for the request, and the cache outlived nothing but a page reload. 251 lines to 101. One shared query for the whole availability map; each consumer projects the endpoints it asked for. Same return shape, so the 12 consumers are untouched. - Unknown endpoints and any failure still read as enabled. This fires before auth settles, and disabling every tool on a hiccup is worse than letting one call fail later. - retry is off for the availability map: the fallback is already the answer, so retrying only doubles a request that every logged-out visitor makes on load. - JWT change invalidates instead of mutating a module global. Desktop is untouched. It shadows this module entirely with a 482-line override whose orchestration — dependency-ready gating, backend-status and server-monitor subscriptions, timeout retries, SaaS routing optimism — is not a plain query, and it has no test coverage to convert against. Its own PR.
1 parent 77def07 commit 5b85654

4 files changed

Lines changed: 251 additions & 219 deletions

File tree

frontend/editor/src/core/api/config.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,34 @@
11
import apiClient from "@app/services/apiClient";
2+
import type { EndpointAvailabilityDetails } from "@app/types/endpointAvailability";
3+
4+
export type EndpointAvailabilityMap = Record<
5+
string,
6+
EndpointAvailabilityDetails
7+
>;
8+
9+
/**
10+
* Fires on app load before auth settles, so a 401 must not trigger the global
11+
* login redirect. Callers treat a failure as "assume enabled".
12+
*/
13+
export async function fetchEndpointsAvailability(): Promise<EndpointAvailabilityMap> {
14+
const response = await apiClient.get<EndpointAvailabilityMap>(
15+
"/api/v1/config/endpoints-availability",
16+
{ suppressErrorToast: true, skipAuthRedirect: true },
17+
);
18+
return Object.fromEntries(
19+
Object.entries(response.data).map(([name, detail]) => [
20+
name,
21+
{ enabled: detail?.enabled ?? true, reason: detail?.reason ?? null },
22+
]),
23+
);
24+
}
25+
26+
export async function fetchEndpointEnabled(endpoint: string): Promise<boolean> {
27+
const response = await apiClient.get<boolean>(
28+
`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`,
29+
);
30+
return response.data;
31+
}
232

333
export interface FooterInfo {
434
analyticsEnabled?: boolean;
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { renderHook, waitFor, act } from "@testing-library/react";
3+
import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider";
4+
import {
5+
useEndpointEnabled,
6+
useMultipleEndpointsEnabled,
7+
} from "@app/hooks/useEndpointConfig";
8+
import {
9+
fetchEndpointEnabled,
10+
fetchEndpointsAvailability,
11+
} from "@app/api/config";
12+
13+
vi.mock("@app/api/config", () => ({
14+
fetchEndpointEnabled: vi.fn(),
15+
fetchEndpointsAvailability: vi.fn(),
16+
}));
17+
18+
const mockOne = vi.mocked(fetchEndpointEnabled);
19+
const mockAll = vi.mocked(fetchEndpointsAvailability);
20+
21+
describe("useEndpointEnabled", () => {
22+
beforeEach(() => vi.clearAllMocks());
23+
24+
it("reports null while loading, then the server's answer", async () => {
25+
mockOne.mockResolvedValue(false);
26+
27+
const { result } = renderHook(() => useEndpointEnabled("ocr-pdf"), {
28+
wrapper: TestQueryProvider,
29+
});
30+
31+
expect(result.current.enabled).toBeNull();
32+
await waitFor(() => expect(result.current.enabled).toBe(false));
33+
});
34+
35+
it("stays null on failure rather than claiming disabled", async () => {
36+
mockOne.mockRejectedValue(new Error("boom"));
37+
38+
const { result } = renderHook(() => useEndpointEnabled("ocr-pdf"), {
39+
wrapper: TestQueryProvider,
40+
});
41+
42+
await waitFor(() => expect(result.current.error).toBe("boom"));
43+
expect(result.current.enabled).toBeNull();
44+
});
45+
46+
it("does not fetch without an endpoint", () => {
47+
const { result } = renderHook(() => useEndpointEnabled(""), {
48+
wrapper: TestQueryProvider,
49+
});
50+
51+
expect(result.current.loading).toBe(false);
52+
expect(mockOne).not.toHaveBeenCalled();
53+
});
54+
});
55+
56+
describe("useMultipleEndpointsEnabled", () => {
57+
beforeEach(() => vi.clearAllMocks());
58+
59+
it("projects the shared map onto the requested endpoints", async () => {
60+
mockAll.mockResolvedValue({
61+
"ocr-pdf": { enabled: false, reason: "DEPENDENCY" },
62+
"add-stamp": { enabled: true, reason: null },
63+
});
64+
65+
const { result } = renderHook(
66+
() => useMultipleEndpointsEnabled(["ocr-pdf"]),
67+
{ wrapper: TestQueryProvider },
68+
);
69+
70+
await waitFor(() =>
71+
expect(result.current.endpointStatus).toEqual({ "ocr-pdf": false }),
72+
);
73+
expect(result.current.endpointDetails["ocr-pdf"].reason).toBe("DEPENDENCY");
74+
});
75+
76+
it("serves every consumer from one request", async () => {
77+
mockAll.mockResolvedValue({ "ocr-pdf": { enabled: true, reason: null } });
78+
79+
const { result } = renderHook(
80+
() => ({
81+
a: useMultipleEndpointsEnabled(["ocr-pdf"]),
82+
b: useMultipleEndpointsEnabled(["ocr-pdf", "add-stamp"]),
83+
}),
84+
{ wrapper: TestQueryProvider },
85+
);
86+
87+
await waitFor(() => expect(result.current.a.loading).toBe(false));
88+
expect(mockAll).toHaveBeenCalledTimes(1);
89+
});
90+
91+
it("treats unknown endpoints as enabled", async () => {
92+
mockAll.mockResolvedValue({});
93+
94+
const { result } = renderHook(
95+
() => useMultipleEndpointsEnabled(["brand-new-tool"]),
96+
{ wrapper: TestQueryProvider },
97+
);
98+
99+
await waitFor(() =>
100+
expect(result.current.endpointStatus).toEqual({ "brand-new-tool": true }),
101+
);
102+
});
103+
104+
it("falls back to enabled when the check fails", async () => {
105+
mockAll.mockRejectedValue(
106+
Object.assign(new Error("unauthorised"), { response: { status: 401 } }),
107+
);
108+
109+
const { result } = renderHook(
110+
() => useMultipleEndpointsEnabled(["ocr-pdf", "add-stamp"]),
111+
{ wrapper: TestQueryProvider },
112+
);
113+
114+
await waitFor(() =>
115+
expect(result.current.endpointStatus).toEqual({
116+
"ocr-pdf": true,
117+
"add-stamp": true,
118+
}),
119+
);
120+
// The fallback is the answer, so no retry.
121+
expect(mockAll).toHaveBeenCalledTimes(1);
122+
});
123+
124+
it("does not fetch for an empty endpoint list", () => {
125+
const { result } = renderHook(() => useMultipleEndpointsEnabled([]), {
126+
wrapper: TestQueryProvider,
127+
});
128+
129+
expect(result.current.loading).toBe(false);
130+
expect(mockAll).not.toHaveBeenCalled();
131+
});
132+
133+
it("refetches when a JWT becomes available", async () => {
134+
mockAll.mockResolvedValue({ "ocr-pdf": { enabled: true, reason: null } });
135+
136+
const { result } = renderHook(
137+
() => useMultipleEndpointsEnabled(["ocr-pdf"]),
138+
{ wrapper: TestQueryProvider },
139+
);
140+
await waitFor(() => expect(result.current.loading).toBe(false));
141+
142+
await act(async () => {
143+
window.dispatchEvent(new CustomEvent("jwt-available"));
144+
await new Promise((resolve) => setTimeout(resolve, 0));
145+
});
146+
147+
await waitFor(() => expect(mockAll).toHaveBeenCalledTimes(2));
148+
});
149+
});

0 commit comments

Comments
 (0)