diff --git a/frontend/editor/src/desktop/hooks/useEndpointConfig.test.tsx b/frontend/editor/src/desktop/hooks/useEndpointConfig.test.tsx index 049166455d..25935acd49 100644 --- a/frontend/editor/src/desktop/hooks/useEndpointConfig.test.tsx +++ b/frontend/editor/src/desktop/hooks/useEndpointConfig.test.tsx @@ -9,6 +9,8 @@ import { } from "vitest"; import { renderHook, waitFor, act } from "@testing-library/react"; import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; import { useEndpointEnabled, useMultipleEndpointsEnabled, @@ -113,6 +115,8 @@ vi.mock("@app/services/endpointAvailabilityService", () => ({ })); const NONE: string[] = []; +const NARROW = ["merge"]; +const WIDE = ["merge", "ocr", "compress"]; function appConfig(dependenciesReady: boolean) { return { data: { dependenciesReady } }; @@ -152,6 +156,11 @@ describe("desktop useMultipleEndpointsEnabled", () => { await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.endpointStatus).toEqual({ merge: true, ocr: false }); + // Once, not twice: the readiness effect must not invalidate on mount. + const availabilityCalls = mockGet.mock.calls.filter((c) => + String(c[0]).includes("endpoints-availability"), + ); + expect(availabilityCalls).toHaveLength(1); }); it("marks locally-disabled endpoints available in SaaS mode", async () => { @@ -308,6 +317,46 @@ describe("desktop useMultipleEndpointsEnabled", () => { await waitFor(() => expect(result.current.endpointStatus.merge).toBe(true)); }); + it("resolves each consumer's own endpoints when they ask for disjoint sets", async () => { + // The offline path resolves only the endpoints it is handed. If consumers + // shared one cache entry, the second would project endpoints nobody + // checked and read them as available. + mode = "selfhosted"; + selfHostedStatus = "offline"; + mockLocalSupport.mockImplementation((endpoint: string) => + Promise.resolve(endpoint === "merge"), + ); + + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook( + () => ({ + narrow: useMultipleEndpointsEnabled(NARROW), + wide: useMultipleEndpointsEnabled(WIDE), + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.wide.loading).toBe(false)); + + // ocr and compress were genuinely probed and are unsupported locally. + expect(result.current.wide.endpointStatus).toEqual({ + merge: true, + ocr: false, + compress: false, + }); + expect(mockLocalSupport).toHaveBeenCalledWith("ocr", expect.anything()); + expect(mockLocalSupport).toHaveBeenCalledWith( + "compress", + expect.anything(), + ); + }); + it("reports nothing loading when asked for no endpoints", async () => { // Stable reference: the current hook keys effects on the array identity. const { result } = renderHook(() => useMultipleEndpointsEnabled(NONE), { diff --git a/frontend/editor/src/desktop/hooks/useEndpointConfig.ts b/frontend/editor/src/desktop/hooks/useEndpointConfig.ts index 1a12afa8a8..b8cce7af48 100644 --- a/frontend/editor/src/desktop/hooks/useEndpointConfig.ts +++ b/frontend/editor/src/desktop/hooks/useEndpointConfig.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, + useRef, useState, useSyncExternalStore, } from "react"; @@ -77,11 +78,15 @@ export function useEndpointEnabled(endpoint: string): { retryDelay: RETRY_DELAY_MS, }); - // Re-run only when readiness or the endpoint changes; queryClient/queryKey - // are stable and deliberately excluded. + // Skipped on mount for the same reason as above: the query is already running. + const seenReadiness = useRef(null); + const readinessMark = `${backendOnline}:${offline}:${endpoint}`; useEffect(() => { - if (ready) void queryClient.invalidateQueries({ queryKey }); - }, [backendOnline, offline, endpoint]); + const first = seenReadiness.current === null; + seenReadiness.current = readinessMark; + if (first || !ready) return; + void queryClient.invalidateQueries({ queryKey }); + }, [readinessMark]); return { enabled: endpoint ? (data ?? true) : null, @@ -105,12 +110,16 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { const { ready, backendOnline, offline } = useBackendReadiness(); const wanted = endpoints ?? []; const key = wanted.join(","); - const queryKey = qk.endpointsAvailability(); + // Keyed by the endpoint set, not shared across consumers. A constant key + // would assert the value is caller-independent, and on two paths it is not: + // the self-hosted-offline check and the legacy ?endpoints= fallback both + // resolve only the endpoints they were handed. Consumers pass disjoint sets, + // so a shared entry would leave the second consumer's endpoints unresolved + // and reading as available. Prefix invalidation below still covers every set. + const queryKey = [...qk.endpointsAvailability(), key]; const { data, isPending, refetch } = useQuery({ queryKey, - // key drives the legacy fallback param and the offline per-endpoint checks; - // the shared cache entry is still the whole map, projected per consumer. queryFn: () => resolveEndpointsAvailability(key ? key.split(",") : []), enabled: wanted.length > 0 && ready, staleTime: CONFIG_STALE_TIME, @@ -118,16 +127,26 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): { retryDelay: RETRY_DELAY_MS, }); - // A reconnect (readiness flips) forces the swap from offline to remote data. + // A reconnect forces the swap from offline to remote data. Skipped on mount: + // the query is already fetching, and invalidating would double the request. + const seenReadiness = useRef(null); + const readinessMark = `${backendOnline}:${offline}`; useEffect(() => { - if (ready) void queryClient.invalidateQueries({ queryKey }); - }, [backendOnline, offline]); + const first = seenReadiness.current === null; + seenReadiness.current = readinessMark; + if (first || !ready) return; + void queryClient.invalidateQueries({ + queryKey: qk.endpointsAvailability(), + }); + }, [readinessMark]); const projected = useMemo(() => { const status: Record = {}; const details: Record = {}; if (!data) return { status, details }; for (const endpoint of key ? key.split(",") : []) { + // Safe because the entry is per-set: a miss here means the server does + // not know the endpoint, which the old code also treated as available. const detail = data[endpoint] ?? OPTIMISTIC; status[endpoint] = detail.enabled; details[endpoint] = detail;