feat(editor): move signing sessions onto TanStack Query (#7436)

# Description of Changes

Step 4 of the TanStack Query rollout, and the first of the polling
hooks. Follows #7264, #7283, #7285.

## The problem

`useSigningSessions` hand-rolled its own fetch, loading state and
`setInterval`. Two consequences:

- **A raw `setInterval` keeps polling a hidden tab.** Browsers throttle
background timers, they do not stop them, so a backgrounded editor with
Shared Sign open keeps hitting both endpoints for as long as it is open.
- **No tests.** The hook had none, and its quietest behaviour (below) is
the easiest thing to break without noticing.

## End state

One query behind `qk.signingSessions()`, with the polling lifecycle
handed to the library:

- Polling stops while the tab is hidden, and refetches on return rather
than leaving data up to a full interval stale.
- Mounts render from cache while they revalidate, so moving between the
tool picker and the signing tool no longer flashes an empty list.
- 12 tests where there were none.

Same return shape, so no consumer files change.

### What this is not

This is not a deduplication win. The three consumers are never mounted
at the same time: `ToolPanel` renders the tool picker or the active tool
and never both, so the badge cannot be on screen with either of the
others, and `SharedSigningLauncher` and `useSigningSessionController`
sit inside two different tools. The shared key earns its keep on cache
reuse across those transitions, not on concurrent fetches.

## The bit worth reviewing

The hand-rolled `{ silent: true }` flag encoded three states, and no
single Query flag reproduces them:

| | Spinner | Toast on failure |
|---|---|---|
| First load | yes | yes |
| Background poll | no | no |
| Explicit refetch | **yes** | **yes** |

`isLoading` is false during an explicit refetch when data is already on
screen; `isFetching` is true during a background poll. Neither matches,
so the user-initiated case is tracked with a small flag and the failure
toast is gated on `isLoadingError` plus the explicit path.

## Testing

Twelve tests. Rather than trust them, each claim was checked by breaking
the implementation and confirming the relevant test fails:

| Mutation | Caught by |
|---|---|
| `refetchIntervalInBackground: true` | hidden-tab test |
| Drop `refetchOnWindowFocus` | returns-to-view test |
| Drop the user-initiated spinner flag | manual-refresh test |
| Toast on every error | background-failure-is-silent test |
| Give each observer its own key | dedupe test |

Three things worth knowing for the next conversion:

- **`waitFor` flushes renders.** Recording an index *after*
`waitFor(callCount === 2)` skips past the in-flight render, so a "did
the spinner flip on" assertion passes vacuously. The marker has to go
before the poll.
- **Fake timers hide in-flight state.** The fetch settles inside the
same `act()`, so the intermediate render never happens. That test uses
real timers and a held-open promise.
- **`visibilitychange` has to bubble.** query-core listens for it on
`window`, and the real event bubbles from `document`. A test helper
dispatching a non-bubbling event never reaches the focus manager, and
the pause behaviour still appears to work because `refetchInterval`
reads `document.visibilityState` directly at tick time rather than
through the event.

**One claim is deliberately unguarded.** `isLoading` vs `isFetching` for
a background poll produces no re-render at all, so there is nothing
observable for a test to assert and no user-visible difference to
protect.

## Pre-existing failures

`task frontend:check` passes typecheck, lint and oxfmt, and 2363 of 2365
editor tests. The two failures, `workbenchSession.test.ts` and
`notificationActions.test.tsx`, fail identically with this branch's
changes reverted and are untouched by it.

## Scope

This is one of five pollers. The remaining four, `useLocalFolderPoller`,
`WatchedFolderWorkbenchView`, `SessionDetailPanel` and cloud
`TeamSection`, are separate files with their own consumers and follow
separately, now that the silent-refresh pattern has a worked example.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
ConnorYoh
2026-08-29 10:35:40 +00:00
committed by GitHub
co-authored by Anthony Stirling
parent c22d9ecf58
commit ead8a536d2
4 changed files with 400 additions and 73 deletions
+21
View File
@@ -0,0 +1,21 @@
import apiClient from "@app/services/apiClient";
import type {
SignRequestSummary,
SessionSummary,
} from "@app/types/signingSession";
export interface SigningSessions {
signRequests: SignRequestSummary[];
mySessions: SessionSummary[];
}
/** The two lists the signing UI always needs together. */
export async function fetchSigningSessions(): Promise<SigningSessions> {
const [requests, sessions] = await Promise.all([
apiClient.get<SignRequestSummary[]>(
"/api/v1/security/cert-sign/sign-requests",
),
apiClient.get<SessionSummary[]>("/api/v1/security/cert-sign/sessions"),
]);
return { signRequests: requests.data, mySessions: sessions.data };
}
@@ -0,0 +1,319 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { baseQueryOptions } from "@app/query/queryClient";
import { TestQueryProvider } from "@app/tests/utils/TestQueryProvider";
import { useSigningSessions } from "@app/hooks/signing/useSigningSessions";
import { fetchSigningSessions } from "@app/api/signing";
import { alert } from "@app/components/toast";
import { expectConsole } from "@app/tests/failOnConsole";
vi.mock("@app/api/signing", () => ({ fetchSigningSessions: vi.fn() }));
vi.mock("@app/components/toast", () => ({ alert: vi.fn() }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (_k: string, fallback?: string) => fallback ?? _k,
}),
}));
const mockFetch = vi.mocked(fetchSigningSessions);
const mockAlert = vi.mocked(alert);
const EMPTY = { signRequests: [], mySessions: [] };
function setVisibility(state: "visible" | "hidden") {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => state,
});
// Bubbles, as the real event does: query-core listens for it on window.
document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
}
describe("useSigningSessions", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetch.mockResolvedValue(EMPTY);
});
afterEach(() => {
vi.useRealTimers();
setVisibility("visible");
});
it("dedupes concurrent observers of the same key", async () => {
const { result } = renderHook(
() => ({
badge: useSigningSessions({
enabled: true,
autoRefreshInterval: 60000,
}),
launcher: useSigningSessions({ enabled: true }),
controller: useSigningSessions({
enabled: true,
autoRefreshInterval: 15000,
}),
}),
{ wrapper: TestQueryProvider },
);
await waitFor(() => expect(result.current.badge.loading).toBe(false));
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("does not fetch while disabled", async () => {
vi.useFakeTimers();
const { result } = renderHook(
() => useSigningSessions({ enabled: false, autoRefreshInterval: 15000 }),
{ wrapper: TestQueryProvider },
);
expect(mockFetch).not.toHaveBeenCalled();
await act(async () => {
vi.advanceTimersByTime(60000);
});
expect(mockFetch).not.toHaveBeenCalled();
expect(result.current.signRequests).toEqual([]);
});
it("starts fetching when enabled flips on", async () => {
const { result, rerender } = renderHook(
({ on }: { on: boolean }) => useSigningSessions({ enabled: on }),
{ wrapper: TestQueryProvider, initialProps: { on: false } },
);
expect(mockFetch).not.toHaveBeenCalled();
rerender({ on: true });
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1));
await waitFor(() => expect(result.current.loading).toBe(false));
});
it("polls on the interval", async () => {
vi.useFakeTimers();
const { result } = renderHook(
() => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
{ wrapper: TestQueryProvider },
);
expect(result.current.loading).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(mockFetch).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(15000);
});
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("does not raise the spinner while a background poll is in flight", async () => {
// Real timers, a held-open poll, and every render recorded. Asserting on
// result.current alone is not enough: waitFor returns as soon as the fetch
// count moves, before React has re-rendered, so a spinner that did flip on
// would be missed.
const seen: boolean[] = [];
const { result } = renderHook(
() => {
const state = useSigningSessions({
enabled: true,
autoRefreshInterval: 50,
});
seen.push(state.loading);
return state;
},
{ wrapper: TestQueryProvider },
);
await waitFor(() => expect(result.current.loading).toBe(false));
// Marked before the poll: waitFor flushes renders, so recording after it
// would skip straight past the in-flight one.
const fromPollStart = seen.length;
let release: (v: unknown) => void = () => {};
mockFetch.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve;
}) as never,
);
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2));
// Give React room to render the in-flight state, if it produces one.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
});
// Mid-poll: this is what the old `silent` flag bought.
expect(seen.slice(fromPollStart)).not.toContain(true);
expect(result.current.loading).toBe(false);
await act(async () => {
release(EMPTY);
});
});
it("shows the spinner for a user-initiated refresh, not a background poll", async () => {
// Real timers: the in-flight window has to be observable, which is exactly
// what a fake-timer act() hides.
const { result } = renderHook(() => useSigningSessions({ enabled: true }), {
wrapper: TestQueryProvider,
});
await waitFor(() => expect(result.current.loading).toBe(false));
let release: (v: unknown) => void = () => {};
mockFetch.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve;
}) as never,
);
let done: Promise<void>;
act(() => {
done = result.current.refetch();
});
await waitFor(() => expect(result.current.loading).toBe(true));
await act(async () => {
release(EMPTY);
await done;
});
expect(result.current.loading).toBe(false);
});
it("toasts a first-load failure", async () => {
expectConsole.error(/Failed to fetch signing data/);
mockFetch.mockRejectedValue(new Error("down"));
const { result } = renderHook(() => useSigningSessions({ enabled: true }), {
wrapper: TestQueryProvider,
});
await waitFor(() => expect(result.current.error).toBeTruthy());
expect(mockAlert).toHaveBeenCalledTimes(1);
});
it("stays silent when a background poll fails after a success", async () => {
vi.useFakeTimers();
mockFetch.mockResolvedValueOnce(EMPTY);
const { result } = renderHook(
() => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
{ wrapper: TestQueryProvider },
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.loading).toBe(false);
expect(mockAlert).not.toHaveBeenCalled();
mockFetch.mockRejectedValue(new Error("flaky"));
await act(async () => {
await vi.advanceTimersByTimeAsync(15000);
});
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockAlert).not.toHaveBeenCalled();
});
it("toasts an explicit refetch failure even with data on screen", async () => {
expectConsole.error(/Failed to fetch signing data/);
const { result } = renderHook(() => useSigningSessions({ enabled: true }), {
wrapper: TestQueryProvider,
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(mockAlert).not.toHaveBeenCalled();
mockFetch.mockRejectedValue(new Error("nope"));
await act(async () => {
await result.current.refetch();
});
expect(mockAlert).toHaveBeenCalledTimes(1);
});
it("stops polling while the tab is hidden", async () => {
vi.useFakeTimers();
const { result } = renderHook(
() => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
{ wrapper: TestQueryProvider },
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.loading).toBe(false);
expect(mockFetch).toHaveBeenCalledTimes(1);
setVisibility("hidden");
await act(async () => {
await vi.advanceTimersByTimeAsync(60000);
});
// Four intervals elapsed with the tab in the background.
expect(mockFetch).toHaveBeenCalledTimes(1);
setVisibility("visible");
await act(async () => {
await vi.advanceTimersByTimeAsync(15000);
});
expect(mockFetch.mock.calls.length).toBeGreaterThan(1);
});
it("refetches on becoming visible rather than waiting out the interval", async () => {
vi.useFakeTimers();
// The app client turns focus refetching off globally; TestQueryProvider
// does not, and would pass this on the library default alone.
const client = new QueryClient({
defaultOptions: {
queries: { ...baseQueryOptions, retry: false, gcTime: Infinity },
},
});
const { result } = renderHook(
() => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
{
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
),
},
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(result.current.loading).toBe(false);
expect(mockFetch).toHaveBeenCalledTimes(1);
setVisibility("hidden");
await act(async () => {
await vi.advanceTimersByTimeAsync(60000);
});
expect(mockFetch).toHaveBeenCalledTimes(1);
setVisibility("visible");
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("stops polling once unmounted", async () => {
vi.useFakeTimers();
const { unmount } = renderHook(
() => useSigningSessions({ enabled: true, autoRefreshInterval: 15000 }),
{ wrapper: TestQueryProvider },
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(mockFetch).toHaveBeenCalledTimes(1);
unmount();
await act(async () => {
await vi.advanceTimersByTimeAsync(60000);
});
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
@@ -1,9 +1,14 @@
import { useState, useCallback, useEffect } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import apiClient from "@app/services/apiClient";
import { fetchSigningSessions } from "@app/api/signing";
import { qk } from "@app/query/keys";
import { alert } from "@app/components/toast";
import { SignRequestSummary, SessionSummary } from "@app/types/signingSession";
const EMPTY_REQUESTS: SignRequestSummary[] = [];
const EMPTY_SESSIONS: SessionSummary[] = [];
export interface UseSigningSessionsOptions {
enabled?: boolean;
autoRefreshInterval?: number; // milliseconds, 0 to disable
@@ -18,8 +23,8 @@ export interface UseSigningSessionsResult {
}
/**
* Hook to fetch signing sessions data (sign requests and user's sessions).
* Supports auto-refresh for real-time updates.
* Signing sessions. Background polls never raise the spinner or a toast; only a
* first load or an explicit refetch does.
*/
export const useSigningSessions = (
options: UseSigningSessionsOptions = {},
@@ -27,83 +32,64 @@ export const useSigningSessions = (
const { enabled = true, autoRefreshInterval = 0 } = options;
const { t } = useTranslation();
const [signRequests, setSignRequests] = useState<SignRequestSummary[]>([]);
const [mySessions, setMySessions] = useState<SessionSummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const { data, isLoading, isLoadingError, error, refetch } = useQuery({
queryKey: qk.signingSessions(),
queryFn: fetchSigningSessions,
enabled,
staleTime: 0,
refetchInterval: autoRefreshInterval > 0 ? autoRefreshInterval : false,
refetchIntervalInBackground: false,
// The interval pauses while unfocused, so returning has to catch up: the
// client-wide default of false would hold stale data until the next tick.
refetchOnWindowFocus: autoRefreshInterval > 0,
});
const fetchData = useCallback(
async (opts?: { silent?: boolean }) => {
if (!enabled) return;
const notifyFailure = useCallback(() => {
console.error("Failed to fetch signing data");
alert({
alertType: "warning",
title: t("common.error"),
body: t("certSign.fetchFailed", "Failed to load signing data"),
expandable: false,
durationMs: 2500,
});
}, [t]);
// Background auto-refreshes pass { silent: true } to skip the loading spinner
// and failure toasts; only the initial load and explicit refetch surface errors.
const silent = opts?.silent ?? false;
if (!silent) setLoading(true);
setError(null);
try {
const [requestsResponse, sessionsResponse] = await Promise.all([
apiClient.get<SignRequestSummary[]>(
"/api/v1/security/cert-sign/sign-requests",
),
apiClient.get<SessionSummary[]>(
"/api/v1/security/cert-sign/sessions",
),
]);
setSignRequests(requestsResponse.data);
setMySessions(sessionsResponse.data);
} catch (err) {
const errorObj =
err instanceof Error
? err
: new Error("Failed to fetch signing data");
setError(errorObj);
console.error("Failed to fetch signing data:", err);
if (!silent) {
alert({
alertType: "warning",
title: t("common.error"),
body: t("certSign.fetchFailed", "Failed to load signing data"),
expandable: false,
durationMs: 2500,
});
}
} finally {
if (!silent) setLoading(false);
}
},
[enabled, t],
);
// Initial fetch
// isLoadingError is "failed with nothing cached", i.e. a first load. A poll
// that fails after a success keeps the old data and stays silent.
const reportedRef = useRef(false);
useEffect(() => {
if (enabled) {
fetchData();
}
}, [enabled, fetchData]);
// Auto-refresh
useEffect(() => {
if (!enabled || !autoRefreshInterval || autoRefreshInterval <= 0) {
if (!isLoadingError) {
reportedRef.current = false;
return;
}
if (reportedRef.current) return;
reportedRef.current = true;
notifyFailure();
}, [isLoadingError, notifyFailure]);
const interval = setInterval(() => {
fetchData({ silent: true });
}, autoRefreshInterval);
// Neither isLoading nor isFetching alone matches the old `silent` flag: a
// user-initiated refresh showed the spinner even with data on screen, a
// background poll never did. isFetching cannot tell them apart, so track it.
const [refreshing, setRefreshing] = useState(false);
return () => clearInterval(interval);
}, [enabled, autoRefreshInterval, fetchData]);
const explicitRefetch = useCallback(async () => {
setRefreshing(true);
try {
const result = await refetch();
// Reported here rather than by the effect: a user-initiated refresh
// should say so even when stale data is already on screen.
if (result.error && !reportedRef.current) notifyFailure();
} finally {
setRefreshing(false);
}
}, [refetch, notifyFailure]);
return {
signRequests,
mySessions,
loading,
error,
refetch: fetchData,
signRequests: data?.signRequests ?? EMPTY_REQUESTS,
mySessions: data?.mySessions ?? EMPTY_SESSIONS,
loading: isLoading || refreshing,
error: (error as Error | null) ?? null,
refetch: explicitRefetch,
};
};
+1
View File
@@ -8,6 +8,7 @@ export const qk = {
["editor", "endpointEnabled", endpoint] as const,
footerInfo: () => ["editor", "footerInfo"] as const,
groupEnabled: (group: string) => ["editor", "groupEnabled", group] as const,
signingSessions: () => ["editor", "signingSessions"] as const,
/** Keyed on the asking identity: two users must never share one answer. */
portalAccess: (userId: string | null) =>
["editor", "portalAccess", userId] as const,