mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
feat(editor): adopt TanStack Query, convert three read-only fetches (#7264)
# Description of Changes ## The problem The editor has no query client. ~295 `apiClient` call sites, each mount refetching what the last one just got, and three module-level caches reimplementing dedupe, retry and invalidation by hand — each shaped differently. The Processor (`frontend/editor/src/portal`) has run on TanStack Query since #7135. The editor never got it. ## End state The editor has a query client, and the three read-only fetch sites that convert safely now use it. `@tanstack/react-query` is already a dependency — no new package. **Foundation** | File | | |---|---| | `core/query/queryClient.ts` | `baseQueryOptions` + client factory. The portal now builds its client from the same options. `networkMode: "always"` — `navigator.onLine` describes internet reachability, which says nothing about a bundled backend on 127.0.0.1 or a self-hosted server on the LAN. | | `core/query/keys.ts` | `["editor", resource, ...params]` | | `core/query/staleTime.ts` + `desktop/query/staleTime.ts` | Config staleTime: `Infinity` on web, 5 min on desktop | | `core/api/config.ts`, `core/api/users.ts` | Fetch functions, mirroring `portal/api/*` | | `core/tests/utils/TestQueryProvider.tsx` | | | `desktop/components/DesktopQueryCacheReset.tsx` | | `QueryClientProvider` mounts at the top of `core/components/AppProviders.tsx`. That diff looks large but is one wrapper plus the reindent underneath it. **Converted.** All three keep their existing return shape, so no consumer changes. | | Before | |---|---| | `useFooterInfo` | Fetched twice — Footer and admin legal section | | `useGroupEnabled` | Refetched on every mount | | `UserSelector` | Refetched the whole roster on each of two mount sites, and again whenever `t` or `user` changed identity | **Desktop needs more than the provider.** `operationRouter` resolves the same relative path to the local bundled backend, a self-hosted server, or the SaaS backend. Query caches by key, not by resolved URL, so a cached entry can outlive the backend that filled it. `group-enabled` routes this way, so this PR introduces the hazard and carries the fix: `DesktopQueryCacheReset` calls `resetQueries()` when the connection mode changes or the self-hosted server goes up or down, and `CONFIG_STALE_TIME` is finite on desktop as a backstop. **Behaviour changes** - All three sites now retry once on failure (client default). None retried before, so a failing request sits in `loading` for one extra attempt plus backoff. - `staleTime: Infinity` on web means admin edits to legal links no longer appear on remount within a session. Saving those already prompts a restart, so this is accepted rather than incidental. - Desktop `useGroupEnabled` shows the *translated* offline reason on first render. The old code showed raw English for one render. - `UserSelector` drops three `console.log`s that were dumping user records to the console. ## Decisions **1. The foundation doesn't ship alone.** A provider nothing consumes gives a reviewer nothing to react to and rots if the follow-up stalls, so it lands with the cheapest safe conversions. **2. Hooks keep their existing return shape.** The alternative is switching to `{ data, isPending, error }` and updating consumers now. Cost of my choice: we carry a `loading`-shaped façade indefinitely, and consumers don't get `isFetching`/`refetch` without a second pass. Taken because it's what keeps each later migration a one-file diff. **3. Shared defaults, separate instances.** The editor and the Processor mount as *sibling* routes, not nested — they never coexist in one tree. Both clients now come from the same `baseQueryOptions`, so behaviour can't drift. A single shared instance would only buy cache surviving navigation between the two products, which is worth little while they share no keys, and it breaks the contract three portal tests rely on (`createPortalQueryClient()` returning a fresh client per test). That belongs in the collapse PR. Consequence meanwhile: the desktop reset covers the editor client only — harmless, since the portal isn't in desktop builds. **4. The desktop reset is wholesale.** A mode switch already remounts the SaaS provider tree, so there's nothing to preserve, and an allowlist of "mode-sensitive" keys would be a trap every new query has to remember to join. ## Coming next Ordered by consumers per line changed. | PR | Scope | |---|---| | 2 | `AppConfigContext` + `useEndpointConfig` — ~80 consumers, deletes ~200 lines of hand-rolled cache, retry and dedupe | | 3 | `useAdminSettings` (20 consumers) and the config sections | | 4 | Polling loops → `refetchInterval` | | 5 | Finish the Processor's remaining files, collapse to one client | | 6 | Tool execution — mutation state only, narrowly scoped | Not in scope, deliberately: `usePdfLibLinks` (its cache is a refcounted ArrayBuffer lifetime manager), thumbnail hooks, watched-folder IndexedDB reads, the desktop health monitors. Unifying `endpointAvailabilityService` / `saasAppConfigService` with the query cache would mean handing `operationRouter` a query client — its own PR if a second reason appears. ## Testing `task frontend:check` green: 1666 tests across 191 files, typecheck on all five flavours, eslint `--max-warnings=0`, dpdm, prettier. New tests cover request de-duplication, per-group key isolation, the desktop offline short-circuit, and the cache reset. The reset test was verified to fail against the `clear()` implementation it replaced. `UserSelector` has no test beyond its existing stories. One existing test needed a wrapper: `Login.test.tsx` renders `<Login />` in isolation, and `AuthLayout` → `Footer` → `useFooterInfo` now needs a client. The real `/login` route is already inside `AppProviders`, so this is test isolation, not a runtime gap. Rollback is a clean revert — nothing persists outside the React tree.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
export interface FooterInfo {
|
||||
analyticsEnabled?: boolean;
|
||||
termsAndConditions?: string;
|
||||
privacyPolicy?: string;
|
||||
accessibilityStatement?: string;
|
||||
cookiePolicy?: string;
|
||||
impressum?: string;
|
||||
}
|
||||
|
||||
/** Public — no authentication required. */
|
||||
export async function fetchFooterInfo(): Promise<FooterInfo> {
|
||||
try {
|
||||
const response = await apiClient.get<FooterInfo>(
|
||||
"/api/v1/ui-data/footer-info",
|
||||
{ suppressErrorToast: true },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Toasts are suppressed here, so the failure would otherwise be silent.
|
||||
console.error("[api/config] footer-info failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGroupEnabled(group: string): Promise<boolean> {
|
||||
const response = await apiClient.get<boolean>(
|
||||
`/api/v1/config/group-enabled?group=${encodeURIComponent(group)}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { UserSummary } from "@app/types/signingSession";
|
||||
|
||||
export async function fetchUsers(): Promise<UserSummary[]> {
|
||||
const response = await apiClient.get<UserSummary[]>("/api/v1/user/users");
|
||||
// A proxy can answer 200 with an HTML login page; callers assume an array.
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ReactNode, useEffect } from "react";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createAppQueryClient } from "@app/query/queryClient";
|
||||
import { ThemeProvider } from "@app/components/shared/ThemeProvider";
|
||||
import { FileContextProvider } from "@app/contexts/FileContext";
|
||||
import { NavigationProvider } from "@app/contexts/NavigationContext";
|
||||
@@ -119,70 +121,73 @@ export function AppProviders({
|
||||
appConfigRetryOptions,
|
||||
appConfigProviderProps,
|
||||
}: AppProvidersProps) {
|
||||
const [queryClient] = useState(createAppQueryClient);
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<ThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<BannerProvider>
|
||||
<AppConfigProvider
|
||||
retryOptions={appConfigRetryOptions}
|
||||
{...appConfigProviderProps}
|
||||
>
|
||||
<PosthogTrackingInitializer />
|
||||
<ScarfTrackingInitializer />
|
||||
<AppConfigLoader />
|
||||
<ServerDefaultsSync />
|
||||
{/* Auto-popup on startup when a newer Stirling-PDF release is available.
|
||||
No-ops inside Tauri — the desktop popup handles that flow. */}
|
||||
<UpdateStartupPopup />
|
||||
<FileContextProvider
|
||||
enableUrlSync={true}
|
||||
enablePersistence={true}
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PreferencesProvider>
|
||||
<ThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<BannerProvider>
|
||||
<AppConfigProvider
|
||||
retryOptions={appConfigRetryOptions}
|
||||
{...appConfigProviderProps}
|
||||
>
|
||||
<FolderProvider>
|
||||
<AppInitializer />
|
||||
<BrandingAssetManager />
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<SigningOverlayProvider>
|
||||
<RedactionProvider>
|
||||
<FormFillProvider>
|
||||
<AnnotationProvider>
|
||||
<WorkbenchBarProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
<FolderFileContextProvider>
|
||||
{children}
|
||||
</FolderFileContextProvider>
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</WorkbenchBarProvider>
|
||||
</AnnotationProvider>
|
||||
</FormFillProvider>
|
||||
</RedactionProvider>
|
||||
</SigningOverlayProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FolderProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</BannerProvider>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
</PreferencesProvider>
|
||||
<PosthogTrackingInitializer />
|
||||
<ScarfTrackingInitializer />
|
||||
<AppConfigLoader />
|
||||
<ServerDefaultsSync />
|
||||
{/* Auto-popup on startup when a newer Stirling-PDF release is available.
|
||||
No-ops inside Tauri — the desktop popup handles that flow. */}
|
||||
<UpdateStartupPopup />
|
||||
<FileContextProvider
|
||||
enableUrlSync={true}
|
||||
enablePersistence={true}
|
||||
>
|
||||
<FolderProvider>
|
||||
<AppInitializer />
|
||||
<BrandingAssetManager />
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<SigningOverlayProvider>
|
||||
<RedactionProvider>
|
||||
<FormFillProvider>
|
||||
<AnnotationProvider>
|
||||
<WorkbenchBarProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
<FolderFileContextProvider>
|
||||
{children}
|
||||
</FolderFileContextProvider>
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</WorkbenchBarProvider>
|
||||
</AnnotationProvider>
|
||||
</FormFillProvider>
|
||||
</RedactionProvider>
|
||||
</SigningOverlayProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FolderProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</BannerProvider>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
</PreferencesProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MultiSelect, Loader, Text, Stack } from "@mantine/core";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { UserSummary } from "@app/types/signingSession";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { fetchUsers } from "@app/api/users";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { qk } from "@app/query/keys";
|
||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||
|
||||
interface UserSelectorProps {
|
||||
@@ -30,71 +31,51 @@ const UserSelector = ({
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [selectData, setSelectData] = useState<GroupedData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stringValue, setStringValue] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
data: users,
|
||||
isPending: loading,
|
||||
error,
|
||||
} = useQuery({ queryKey: qk.users(), queryFn: fetchUsers });
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/api/v1/user/users");
|
||||
console.log("Users API response:", response.data);
|
||||
const fetchedUsers = response.data || [];
|
||||
if (!error) return;
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("common.error"),
|
||||
body: t("certSign.collab.userSelector.loadError", "Failed to load users"),
|
||||
});
|
||||
}, [error, t]);
|
||||
|
||||
// Process selectData inside useEffect - group by team
|
||||
const usersByTeam: Record<string, SelectItem[]> = {};
|
||||
const currentUserId = user?.id ? parseInt(user.id, 10) : null;
|
||||
const selectData = useMemo<GroupedData[]>(() => {
|
||||
const usersByTeam: Record<string, SelectItem[]> = {};
|
||||
const currentUserId = user?.id ? parseInt(user.id, 10) : null;
|
||||
|
||||
fetchedUsers
|
||||
.filter((u: UserSummary) => u && u.userId && u.username)
|
||||
.filter((u: UserSummary) => u.userId !== currentUserId) // Exclude current user
|
||||
.filter((u: UserSummary) => u.teamName?.toLowerCase() !== "internal") // Exclude internal users
|
||||
.forEach((user: UserSummary) => {
|
||||
const teamName =
|
||||
user.teamName ||
|
||||
t("certSign.collab.userSelector.noTeam", "No Team");
|
||||
if (!usersByTeam[teamName]) {
|
||||
usersByTeam[teamName] = [];
|
||||
}
|
||||
const displayName = user.displayName || user.username || "Unknown";
|
||||
const username = user.username || "unknown";
|
||||
const label =
|
||||
displayName !== username
|
||||
? `${displayName} (@${username})`
|
||||
: displayName;
|
||||
usersByTeam[teamName].push({
|
||||
value: String(user.userId),
|
||||
label,
|
||||
});
|
||||
});
|
||||
(users ?? [])
|
||||
.filter((u) => u && u.userId && u.username)
|
||||
.filter((u) => u.userId !== currentUserId)
|
||||
.filter((u) => u.teamName?.toLowerCase() !== "internal")
|
||||
.forEach((u) => {
|
||||
const teamName =
|
||||
u.teamName || t("certSign.collab.userSelector.noTeam", "No Team");
|
||||
if (!usersByTeam[teamName]) {
|
||||
usersByTeam[teamName] = [];
|
||||
}
|
||||
const displayName = u.displayName || u.username || "Unknown";
|
||||
const username = u.username || "unknown";
|
||||
const label =
|
||||
displayName !== username
|
||||
? `${displayName} (@${username})`
|
||||
: displayName;
|
||||
usersByTeam[teamName].push({ value: String(u.userId), label });
|
||||
});
|
||||
|
||||
// Convert to Mantine's grouped format
|
||||
const processed: GroupedData[] = Object.entries(usersByTeam).map(
|
||||
([teamName, items]) => ({
|
||||
group: teamName,
|
||||
items: items.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
}),
|
||||
);
|
||||
|
||||
console.log("Processed selectData:", processed);
|
||||
setSelectData(processed);
|
||||
} catch (error) {
|
||||
console.error("Failed to load users:", error);
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: t("common.error"),
|
||||
body: t(
|
||||
"certSign.collab.userSelector.loadError",
|
||||
"Failed to load users",
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsers();
|
||||
}, [t, user]);
|
||||
return Object.entries(usersByTeam).map(([teamName, items]) => ({
|
||||
group: teamName,
|
||||
items: items.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
}));
|
||||
}, [users, user, t]);
|
||||
|
||||
// Process stringValue when value prop changes
|
||||
useEffect(() => {
|
||||
@@ -102,7 +83,6 @@ const UserSelector = ({
|
||||
const result = safeValue
|
||||
.map((id) => (id != null ? id.toString() : ""))
|
||||
.filter(Boolean);
|
||||
console.log("stringValue for MultiSelect:", result);
|
||||
setStringValue(result);
|
||||
}, [value]);
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider";
|
||||
import { useFooterInfo } from "@app/hooks/useFooterInfo";
|
||||
import { fetchFooterInfo } from "@app/api/config";
|
||||
|
||||
vi.mock("@app/api/config", () => ({ fetchFooterInfo: vi.fn() }));
|
||||
|
||||
const mockFetch = vi.mocked(fetchFooterInfo);
|
||||
|
||||
describe("useFooterInfo", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns the server's footer config", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
analyticsEnabled: true,
|
||||
privacyPolicy: "/privacy",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFooterInfo(), {
|
||||
wrapper: TestQueryProvider,
|
||||
});
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.footerInfo).toEqual({
|
||||
analyticsEnabled: true,
|
||||
privacyPolicy: "/privacy",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to analytics-off rather than null when the fetch fails", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("offline"));
|
||||
|
||||
const { result } = renderHook(() => useFooterInfo(), {
|
||||
wrapper: TestQueryProvider,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeTruthy());
|
||||
expect(result.current.footerInfo).toEqual({ analyticsEnabled: false });
|
||||
});
|
||||
|
||||
it("shares one request between the footer and the legal section", async () => {
|
||||
mockFetch.mockResolvedValue({ analyticsEnabled: false });
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({ footer: useFooterInfo(), legal: useFooterInfo() }),
|
||||
{ wrapper: TestQueryProvider },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.footer.loading).toBe(false));
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,24 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchFooterInfo, type FooterInfo } from "@app/api/config";
|
||||
import { qk } from "@app/query/keys";
|
||||
import { CONFIG_STALE_TIME } from "@app/query/staleTime";
|
||||
|
||||
export interface FooterInfo {
|
||||
analyticsEnabled?: boolean;
|
||||
termsAndConditions?: string;
|
||||
privacyPolicy?: string;
|
||||
accessibilityStatement?: string;
|
||||
cookiePolicy?: string;
|
||||
impressum?: string;
|
||||
}
|
||||
export type { FooterInfo };
|
||||
|
||||
/**
|
||||
* Hook to fetch public footer configuration data.
|
||||
* This endpoint is always accessible without authentication.
|
||||
*/
|
||||
const FALLBACK: FooterInfo = { analyticsEnabled: false };
|
||||
|
||||
/** Public footer config, shared by Footer and the admin legal section. */
|
||||
export function useFooterInfo() {
|
||||
const [footerInfo, setFooterInfo] = useState<FooterInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const { data, isPending, error } = useQuery({
|
||||
queryKey: qk.footerInfo(),
|
||||
queryFn: fetchFooterInfo,
|
||||
staleTime: CONFIG_STALE_TIME,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFooterInfo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.get<FooterInfo>(
|
||||
"/api/v1/ui-data/footer-info",
|
||||
{
|
||||
suppressErrorToast: true,
|
||||
} as any,
|
||||
);
|
||||
setFooterInfo(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("[useFooterInfo] Failed to fetch footer info:", err);
|
||||
setError(err as Error);
|
||||
// Set defaults on error
|
||||
setFooterInfo({
|
||||
analyticsEnabled: false,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFooterInfo();
|
||||
}, []);
|
||||
|
||||
return { footerInfo, loading, error };
|
||||
return {
|
||||
// Callers render legal links off this, so a failure must still yield an object.
|
||||
footerInfo: data ?? (error ? FALLBACK : null),
|
||||
loading: isPending,
|
||||
error: (error as Error | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider";
|
||||
import { useGroupEnabled } from "@app/hooks/useGroupEnabled";
|
||||
import { fetchGroupEnabled } from "@app/api/config";
|
||||
|
||||
vi.mock("@app/api/config", () => ({ fetchGroupEnabled: vi.fn() }));
|
||||
|
||||
const mockFetch = vi.mocked(fetchGroupEnabled);
|
||||
|
||||
describe("useGroupEnabled", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("reports null while loading, then the server's answer", async () => {
|
||||
mockFetch.mockResolvedValue(true);
|
||||
|
||||
const { result } = renderHook(() => useGroupEnabled("ImageMagick"), {
|
||||
wrapper: TestQueryProvider,
|
||||
});
|
||||
|
||||
expect(result.current.enabled).toBeNull();
|
||||
await waitFor(() => expect(result.current.enabled).toBe(true));
|
||||
expect(mockFetch).toHaveBeenCalledWith("ImageMagick");
|
||||
});
|
||||
|
||||
it("reads a failed check as disabled", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("boom"));
|
||||
|
||||
const { result } = renderHook(() => useGroupEnabled("ImageMagick"), {
|
||||
wrapper: TestQueryProvider,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.enabled).toBe(false));
|
||||
});
|
||||
|
||||
it("serves a second consumer of the same group from cache", async () => {
|
||||
mockFetch.mockResolvedValue(true);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
a: useGroupEnabled("ImageMagick"),
|
||||
b: useGroupEnabled("ImageMagick"),
|
||||
}),
|
||||
{ wrapper: TestQueryProvider },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.a.enabled).toBe(true));
|
||||
expect(result.current.b.enabled).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps distinct groups on distinct keys", async () => {
|
||||
mockFetch.mockImplementation((group: string) =>
|
||||
Promise.resolve(group === "ImageMagick"),
|
||||
);
|
||||
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
magick: useGroupEnabled("ImageMagick"),
|
||||
calibre: useGroupEnabled("Calibre"),
|
||||
}),
|
||||
{ wrapper: TestQueryProvider },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.magick.enabled).toBe(true));
|
||||
await waitFor(() => expect(result.current.calibre.enabled).toBe(false));
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +1,21 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchGroupEnabled } from "@app/api/config";
|
||||
import { qk } from "@app/query/keys";
|
||||
import { CONFIG_STALE_TIME } from "@app/query/staleTime";
|
||||
import type { GroupEnabledResult } from "@app/types/groupEnabled";
|
||||
|
||||
export type { GroupEnabledResult };
|
||||
|
||||
/**
|
||||
* Checks whether a named feature group is enabled on the backend.
|
||||
* Returns { enabled: null } while loading, then true/false with an optional reason.
|
||||
*/
|
||||
/** Null while loading; a failed check reads as disabled. */
|
||||
export function useGroupEnabled(group: string): GroupEnabledResult {
|
||||
const [result, setResult] = useState<GroupEnabledResult>({
|
||||
enabled: null,
|
||||
unavailableReason: null,
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: qk.groupEnabled(group),
|
||||
queryFn: () => fetchGroupEnabled(group),
|
||||
staleTime: CONFIG_STALE_TIME,
|
||||
});
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.get<boolean>(
|
||||
`/api/v1/config/group-enabled?group=${encodeURIComponent(group)}`,
|
||||
)
|
||||
.then((res) => {
|
||||
if (isMountedRef.current)
|
||||
setResult({ enabled: res.data, unavailableReason: null });
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMountedRef.current)
|
||||
setResult({ enabled: false, unavailableReason: null });
|
||||
});
|
||||
}, [group]);
|
||||
|
||||
return result;
|
||||
return {
|
||||
enabled: isPending ? null : (data ?? false),
|
||||
unavailableReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Editor query keys: ["editor", <resource>, ...params]. */
|
||||
export const qk = {
|
||||
footerInfo: () => ["editor", "footerInfo"] as const,
|
||||
groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const,
|
||||
users: () => ["editor", "users"] as const,
|
||||
} as const;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { QueryClient, type DefaultOptions } from "@tanstack/react-query";
|
||||
|
||||
// networkMode "always": navigator.onLine tracks the internet, not a backend on
|
||||
// 127.0.0.1 or the LAN. On the default, losing Wi-Fi strands every query.
|
||||
export const baseQueryOptions: DefaultOptions["queries"] = {
|
||||
staleTime: 30_000,
|
||||
gcTime: 5 * 60_000,
|
||||
retry: 1,
|
||||
networkMode: "always",
|
||||
refetchOnWindowFocus: false,
|
||||
};
|
||||
|
||||
export function createAppQueryClient(): QueryClient {
|
||||
return new QueryClient({ defaultOptions: { queries: baseQueryOptions } });
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Server config, fixed for the session. Nothing auth-scoped — no login/logout invalidation exists. */
|
||||
export const CONFIG_STALE_TIME = Infinity;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/** Fresh client per test: retries off so failures surface immediately. */
|
||||
export function TestQueryProvider({ children }: { children: ReactNode }) {
|
||||
const [client] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
|
||||
}),
|
||||
);
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
|
||||
import { DesktopConfigSync } from "@app/components/DesktopConfigSync";
|
||||
import { DesktopQueryCacheReset } from "@app/components/DesktopQueryCacheReset";
|
||||
import { DesktopBannerInitializer } from "@app/components/DesktopBannerInitializer";
|
||||
import { SaveShortcutListener } from "@app/components/SaveShortcutListener";
|
||||
import { DesktopOnboardingModal } from "@app/components/DesktopOnboardingModal";
|
||||
@@ -325,6 +326,8 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
autoFetch: false,
|
||||
}}
|
||||
>
|
||||
{/* Also here: the auth check below switches mode pre-authChecked. */}
|
||||
<DesktopQueryCacheReset />
|
||||
<div style={{ minHeight: "100vh" }} />
|
||||
{updatePopupModal}
|
||||
</ProprietaryAppProviders>
|
||||
@@ -350,6 +353,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT)),
|
||||
}}
|
||||
>
|
||||
<DesktopQueryCacheReset />
|
||||
<SaaSTeamProvider key={appKey}>
|
||||
<DesktopConfigSync />
|
||||
<DesktopBannerInitializer />
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
import { DesktopQueryCacheReset } from "@app/components/DesktopQueryCacheReset";
|
||||
|
||||
type Listener = (config: { mode: string }) => void;
|
||||
type ServerListener = (state: { status: string }) => void;
|
||||
|
||||
const modeListeners = new Set<Listener>();
|
||||
const serverListeners = new Set<ServerListener>();
|
||||
|
||||
vi.mock("@app/services/connectionModeService", () => ({
|
||||
connectionModeService: {
|
||||
subscribeToModeChanges: (listener: Listener) => {
|
||||
modeListeners.add(listener);
|
||||
return () => modeListeners.delete(listener);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@app/services/selfHostedServerMonitor", () => ({
|
||||
selfHostedServerMonitor: {
|
||||
subscribe: (listener: ServerListener) => {
|
||||
serverListeners.add(listener);
|
||||
listener({ status: "online" }); // matches the real replay-on-attach
|
||||
return () => serverListeners.delete(listener);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function renderWithConsumer(queryFn: () => Promise<string>) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
const seen: (string | undefined)[] = [];
|
||||
|
||||
function Consumer() {
|
||||
const { data } = useQuery({ queryKey: ["editor", "probe"], queryFn });
|
||||
seen.push(data);
|
||||
return <span>{data ?? "pending"}</span>;
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<DesktopQueryCacheReset />
|
||||
<Consumer />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe("DesktopQueryCacheReset", () => {
|
||||
beforeEach(() => {
|
||||
modeListeners.clear();
|
||||
serverListeners.clear();
|
||||
});
|
||||
|
||||
it("refetches a mounted query when the connection mode changes", async () => {
|
||||
const queryFn = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValueOnce("local-backend")
|
||||
.mockResolvedValueOnce("self-hosted-backend");
|
||||
|
||||
const seen = renderWithConsumer(queryFn);
|
||||
await waitFor(() => expect(seen).toContain("local-backend"));
|
||||
|
||||
modeListeners.forEach((l) => l({ mode: "selfhosted" }));
|
||||
|
||||
// Must refetch, not just evict — this is what clear() got wrong.
|
||||
await waitFor(() => expect(seen).toContain("self-hosted-backend"));
|
||||
expect(queryFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("refetches when the self-hosted server flips online/offline", async () => {
|
||||
const queryFn = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValueOnce("server")
|
||||
.mockResolvedValueOnce("local-fallback");
|
||||
|
||||
const seen = renderWithConsumer(queryFn);
|
||||
await waitFor(() => expect(seen).toContain("server"));
|
||||
|
||||
serverListeners.forEach((l) => l({ status: "offline" }));
|
||||
|
||||
await waitFor(() => expect(seen).toContain("local-fallback"));
|
||||
});
|
||||
|
||||
it("ignores the replayed state the monitor emits on subscribe", async () => {
|
||||
const queryFn = vi.fn<() => Promise<string>>().mockResolvedValue("once");
|
||||
|
||||
renderWithConsumer(queryFn);
|
||||
await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(queryFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resets on recovery, not on the checking state in between", async () => {
|
||||
const queryFn = vi.fn<() => Promise<string>>().mockResolvedValue("x");
|
||||
|
||||
renderWithConsumer(queryFn);
|
||||
await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(1));
|
||||
|
||||
const emit = (status: string) =>
|
||||
serverListeners.forEach((l) => l({ status }));
|
||||
|
||||
emit("offline");
|
||||
await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(2));
|
||||
|
||||
// A monitor restart while still down must not read as recovery — otherwise
|
||||
// the reset lands here and the real online transition is skipped.
|
||||
emit("checking");
|
||||
expect(queryFn).toHaveBeenCalledTimes(2);
|
||||
|
||||
emit("online");
|
||||
await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(3));
|
||||
});
|
||||
|
||||
it("does not reset when the monitor stops while offline", async () => {
|
||||
const queryFn = vi.fn<() => Promise<string>>().mockResolvedValue("cached");
|
||||
|
||||
renderWithConsumer(queryFn);
|
||||
await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(1));
|
||||
|
||||
serverListeners.forEach((l) => l({ status: "offline" }));
|
||||
await waitFor(() => expect(queryFn).toHaveBeenCalledTimes(2));
|
||||
|
||||
serverListeners.forEach((l) => l({ status: "idle" }));
|
||||
expect(queryFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { connectionModeService } from "@app/services/connectionModeService";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
|
||||
/**
|
||||
* Drops cached responses when the backend behind them changes. operationRouter
|
||||
* resolves the same path to the bundled backend, a self-hosted server or SaaS,
|
||||
* so a mode switch or the server going up/down invalidates every key.
|
||||
*/
|
||||
export function DesktopQueryCacheReset() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
// resetQueries, not clear(): clear() evicts without notifying mounted
|
||||
// observers, so a panel keeps rendering the old backend's answer.
|
||||
const reset = () => void queryClient.resetQueries();
|
||||
const unsubscribeMode = connectionModeService.subscribeToModeChanges(reset);
|
||||
|
||||
// idle/checking say nothing about reachability — treating them as "not
|
||||
// offline" would reset on offline→checking and then skip the real recovery.
|
||||
let wasOffline: boolean | null = null;
|
||||
const unsubscribeServer = selfHostedServerMonitor.subscribe(
|
||||
({ status }) => {
|
||||
if (status !== "online" && status !== "offline") return;
|
||||
const isOffline = status === "offline";
|
||||
if (wasOffline !== null && wasOffline !== isOffline) reset();
|
||||
wasOffline = isOffline;
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribeMode();
|
||||
unsubscribeServer();
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider";
|
||||
import { useGroupEnabled } from "@app/hooks/useGroupEnabled";
|
||||
import { fetchGroupEnabled } from "@app/api/config";
|
||||
|
||||
vi.mock("@app/api/config", () => ({ fetchGroupEnabled: vi.fn() }));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (_k: string, fallback: string) => fallback }),
|
||||
}));
|
||||
|
||||
let status = "online";
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
vi.mock("@app/services/selfHostedServerMonitor", () => ({
|
||||
selfHostedServerMonitor: {
|
||||
getSnapshot: () => ({ status }),
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function setStatus(next: string) {
|
||||
status = next;
|
||||
act(() => listeners.forEach((l) => l()));
|
||||
}
|
||||
|
||||
const mockFetch = vi.mocked(fetchGroupEnabled);
|
||||
|
||||
describe("desktop useGroupEnabled", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
status = "online";
|
||||
});
|
||||
|
||||
it("skips the request entirely when the server is offline", async () => {
|
||||
status = "offline";
|
||||
|
||||
const { result } = renderHook(() => useGroupEnabled("ImageMagick"), {
|
||||
wrapper: TestQueryProvider,
|
||||
});
|
||||
|
||||
expect(result.current.enabled).toBe(false);
|
||||
expect(result.current.unavailableReason).toBeTruthy();
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reflects the server going offline after a successful check", async () => {
|
||||
mockFetch.mockResolvedValue(true);
|
||||
|
||||
const { result } = renderHook(() => useGroupEnabled("ImageMagick"), {
|
||||
wrapper: TestQueryProvider,
|
||||
});
|
||||
await waitFor(() => expect(result.current.enabled).toBe(true));
|
||||
|
||||
setStatus("offline");
|
||||
|
||||
expect(result.current.enabled).toBe(false);
|
||||
expect(result.current.unavailableReason).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,46 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { fetchGroupEnabled } from "@app/api/config";
|
||||
import { selfHostedServerMonitor } from "@app/services/selfHostedServerMonitor";
|
||||
import { qk } from "@app/query/keys";
|
||||
import { CONFIG_STALE_TIME } from "@app/query/staleTime";
|
||||
import type { GroupEnabledResult } from "@app/types/groupEnabled";
|
||||
|
||||
const OFFLINE_REASON_FALLBACK =
|
||||
"Requires your Stirling-PDF server (currently offline)";
|
||||
|
||||
/**
|
||||
* Desktop override: skips the network request entirely when the self-hosted
|
||||
* server is confirmed offline, returning a reason string matching the tool panel.
|
||||
*/
|
||||
// A boolean, not the monitor's state object — that is reassigned every poll.
|
||||
const subscribeToMonitor = (onChange: () => void) =>
|
||||
selfHostedServerMonitor.subscribe(onChange);
|
||||
const getIsOffline = () =>
|
||||
selfHostedServerMonitor.getSnapshot().status === "offline";
|
||||
|
||||
/** Desktop override: skips the request when the self-hosted server is offline. */
|
||||
export function useGroupEnabled(group: string): GroupEnabledResult {
|
||||
const { t } = useTranslation();
|
||||
// Initialise synchronously so the first render already reflects offline state —
|
||||
// avoids a flash where the option appears enabled before the effect runs.
|
||||
// Use OFFLINE_REASON_FALLBACK directly so unavailableReason is non-null from
|
||||
// the very first render when offline (t() is not available in useState initialiser).
|
||||
const [result, setResult] = useState<GroupEnabledResult>(() => {
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
if (status === "offline") {
|
||||
return { enabled: false, unavailableReason: OFFLINE_REASON_FALLBACK };
|
||||
}
|
||||
return { enabled: null, unavailableReason: null };
|
||||
const isOffline = useSyncExternalStore(subscribeToMonitor, getIsOffline);
|
||||
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: qk.groupEnabled(group),
|
||||
queryFn: () => fetchGroupEnabled(group),
|
||||
staleTime: CONFIG_STALE_TIME,
|
||||
enabled: !isOffline,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { status } = selfHostedServerMonitor.getSnapshot();
|
||||
if (status === "offline") {
|
||||
setResult({
|
||||
enabled: false,
|
||||
unavailableReason: t(
|
||||
"toolPanel.fullscreen.selfHostedOffline",
|
||||
OFFLINE_REASON_FALLBACK,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Before the query: a disabled query stays isPending, which would read as loading forever.
|
||||
if (isOffline) {
|
||||
return {
|
||||
enabled: false,
|
||||
unavailableReason: t(
|
||||
"toolPanel.fullscreen.selfHostedOffline",
|
||||
OFFLINE_REASON_FALLBACK,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
apiClient
|
||||
.get<boolean>(
|
||||
`/api/v1/config/group-enabled?group=${encodeURIComponent(group)}`,
|
||||
)
|
||||
.then((res) => setResult({ enabled: res.data, unavailableReason: null }))
|
||||
.catch(() => setResult({ enabled: false, unavailableReason: null }));
|
||||
}, [group, t]);
|
||||
|
||||
return result;
|
||||
return {
|
||||
enabled: isPending ? null : (data ?? false),
|
||||
unavailableReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "@core/query/staleTime";
|
||||
|
||||
// Desktop keys don't pin a backend (see DesktopQueryCacheReset), so expire as a
|
||||
// backstop. 5 min matches endpointAvailabilityService and saasAppConfigService.
|
||||
export const CONFIG_STALE_TIME = 5 * 60_000;
|
||||
@@ -30,8 +30,6 @@ function ThemedSuiProvider({ children }: { children: ReactNode }) {
|
||||
* self-hosted mounts the account-link layer, SaaS does not.
|
||||
*/
|
||||
export function PortalApp() {
|
||||
// One client for the portal's lifetime. Sits above the router so its cache
|
||||
// survives view navigation. Cheap and inert when no query hooks are mounted.
|
||||
const [queryClient] = useState(createPortalQueryClient);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -1,35 +1,15 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { baseQueryOptions } from "@app/query/queryClient";
|
||||
|
||||
/**
|
||||
* The portal's TanStack Query client, mounted once at the portal root
|
||||
* (PortalApp) so its cache lives above the router — data survives navigating
|
||||
* away and back. staleTime 30s: a return visit within 30s serves cache with no
|
||||
* network call, then revalidates in the background. Focus refetch is off — admin
|
||||
* screens don't need polling.
|
||||
*/
|
||||
let current: QueryClient | null = null;
|
||||
|
||||
/** Own instance, shared defaults — the portal and editor are sibling routes. */
|
||||
export function createPortalQueryClient(): QueryClient {
|
||||
current = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
gcTime: 5 * 60_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
current = new QueryClient({ defaultOptions: { queries: baseQueryOptions } });
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* The client created by {@link createPortalQueryClient}, or null if none has
|
||||
* been mounted yet. Lets a non-hook module (the SaaS usersBackend's resolveTeam)
|
||||
* read/populate the shared cache via ensureQueryData when the portal is mounted,
|
||||
* while still working — via a direct fetch — when it isn't (e.g. a unit test
|
||||
* that exercises the adapter without the provider).
|
||||
*/
|
||||
/** Null until the portal mounts, so resolveTeam can fall back to a direct fetch. */
|
||||
export function tryGetPortalQueryClient(): QueryClient | null {
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Login from "@app/routes/Login";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { springAuth } from "@app/auth/spring/springAuthClient";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { configureSpringAuth } from "@app/auth/config";
|
||||
import type { AxiosInstance } from "axios";
|
||||
@@ -92,11 +93,14 @@ vi.mock("react-router-dom", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Test wrapper with MantineProvider
|
||||
// AuthLayout renders <Footer>, which reads useFooterInfo. In the real router
|
||||
// /login sits inside AppProviders, which supplies the client.
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>
|
||||
<PreferencesProvider>{children}</PreferencesProvider>
|
||||
</MantineProvider>
|
||||
<TestQueryProvider>
|
||||
<MantineProvider>
|
||||
<PreferencesProvider>{children}</PreferencesProvider>
|
||||
</MantineProvider>
|
||||
</TestQueryProvider>
|
||||
);
|
||||
|
||||
describe("Login", () => {
|
||||
|
||||
Reference in New Issue
Block a user