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;