refactor: drop dead query helpers and simplify read deadline (WealthLedger/frontend#141)
This commit is contained in:
@@ -6,10 +6,7 @@ import { createQueryWrapper } from "@/test/query";
|
|||||||
import { resource } from "@/test/resource";
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
import {
|
import { useAuthenticatedQuery } from "./authenticated-query";
|
||||||
useAuthenticatedQueries,
|
|
||||||
useAuthenticatedQuery,
|
|
||||||
} from "./authenticated-query";
|
|
||||||
import { apiQueryClient, configureAuthClient } from "./client";
|
import { apiQueryClient, configureAuthClient } from "./client";
|
||||||
|
|
||||||
const useSingleQuery = () =>
|
const useSingleQuery = () =>
|
||||||
@@ -17,58 +14,49 @@ const useSingleQuery = () =>
|
|||||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
|
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
|
||||||
);
|
);
|
||||||
|
|
||||||
const useMultipleQueries = () =>
|
|
||||||
useAuthenticatedQueries([
|
|
||||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
|
|
||||||
])[0];
|
|
||||||
|
|
||||||
describe("useAuthenticatedQuery", () => {
|
describe("useAuthenticatedQuery", () => {
|
||||||
it.each(["single", "multiple"])(
|
it("keeps protected queries idle without a session and loads after session configuration", async () => {
|
||||||
"keeps %s protected queries idle without a session and loads after session configuration",
|
const requests = vi.fn();
|
||||||
async (mode) => {
|
server.use(
|
||||||
const requests = vi.fn();
|
http.get("*/api/v1/brokerage-accounts", () => {
|
||||||
server.use(
|
requests();
|
||||||
http.get("*/api/v1/brokerage-accounts", () => {
|
|
||||||
requests();
|
|
||||||
|
|
||||||
return HttpResponse.json(
|
return HttpResponse.json(
|
||||||
resource([
|
resource([
|
||||||
{
|
{
|
||||||
id: "account-1",
|
id: "account-1",
|
||||||
name: "Основной",
|
name: "Основной",
|
||||||
openedOn: "2024-08-13",
|
openedOn: "2024-08-13",
|
||||||
type: "BROKERAGE",
|
type: "BROKERAGE",
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(useSingleQuery, {
|
||||||
mode === "single" ? useSingleQuery : useMultipleQueries,
|
wrapper: createQueryWrapper().wrapper,
|
||||||
{ wrapper: createQueryWrapper().wrapper },
|
});
|
||||||
);
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
expect(requests).not.toHaveBeenCalled();
|
expect(requests).not.toHaveBeenCalled();
|
||||||
expect(result.current?.fetchStatus).toBe("idle");
|
expect(result.current?.fetchStatus).toBe("idle");
|
||||||
|
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "token",
|
accessToken: () => "token",
|
||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
refresh: () => Promise.resolve(true),
|
refresh: () => Promise.resolve(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current?.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current?.isSuccess).toBe(true));
|
||||||
expect(requests).toHaveBeenCalledOnce();
|
expect(requests).toHaveBeenCalledOnce();
|
||||||
expect(result.current?.data?.data).toEqual([
|
expect(result.current?.data?.data).toEqual([
|
||||||
{
|
{
|
||||||
id: "account-1",
|
id: "account-1",
|
||||||
name: "Основной",
|
name: "Основной",
|
||||||
openedOn: "2024-08-13",
|
openedOn: "2024-08-13",
|
||||||
type: "BROKERAGE",
|
type: "BROKERAGE",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
type QueryKey,
|
type QueryKey,
|
||||||
useQueries,
|
|
||||||
useQuery,
|
useQuery,
|
||||||
type UseQueryOptions,
|
type UseQueryOptions,
|
||||||
type UseQueryResult,
|
type UseQueryResult,
|
||||||
@@ -12,25 +11,6 @@ import {
|
|||||||
subscribeAuthenticatedSession,
|
subscribeAuthenticatedSession,
|
||||||
} from "./client";
|
} from "./client";
|
||||||
|
|
||||||
function useAuthenticatedQueries<TData, TError, TQueryKey extends QueryKey>(
|
|
||||||
queries: UseQueryOptions<TData, TError, TData, TQueryKey>[],
|
|
||||||
) {
|
|
||||||
const hasSession = useHasAuthenticatedSession();
|
|
||||||
|
|
||||||
return useQueries({
|
|
||||||
queries: queries.map(
|
|
||||||
(options): UseQueryOptions<TData, TError, TData, TQueryKey> => ({
|
|
||||||
...options,
|
|
||||||
enabled: query =>
|
|
||||||
hasSession
|
|
||||||
&& (typeof options.enabled === "function"
|
|
||||||
? options.enabled(query)
|
|
||||||
: (options.enabled ?? true)),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function useAuthenticatedQuery<
|
function useAuthenticatedQuery<
|
||||||
TQueryFunctionData = unknown,
|
TQueryFunctionData = unknown,
|
||||||
TError = Error,
|
TError = Error,
|
||||||
@@ -58,4 +38,4 @@ function useHasAuthenticatedSession() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useAuthenticatedQueries, useAuthenticatedQuery };
|
export { useAuthenticatedQuery };
|
||||||
|
|||||||
+27
-28
@@ -208,51 +208,50 @@ function parseJson(body: string): unknown {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readAttempt(request: Request): Promise<ReadAttempt> {
|
async function readAttempt(request: Request): Promise<ReadAttempt> {
|
||||||
const controller = new AbortController();
|
// Manual timeout (not AbortSignal.timeout) so fake-timer tests can advance it.
|
||||||
const failure: { reason?: "abort" | "timeout" } = {};
|
const timeout = new AbortController();
|
||||||
|
const timer = setTimeout(() => timeout.abort(), GET_DEADLINE_MS);
|
||||||
const { promise: deadline, reject: rejectDeadline }
|
const signal = AbortSignal.any([request.signal, timeout.signal]);
|
||||||
= Promise.withResolvers<never>();
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
failure.reason = "timeout";
|
|
||||||
controller.abort();
|
|
||||||
rejectDeadline(abortError());
|
|
||||||
}, GET_DEADLINE_MS);
|
|
||||||
|
|
||||||
const abort = () => {
|
|
||||||
failure.reason = "abort";
|
|
||||||
controller.abort();
|
|
||||||
rejectDeadline(abortError());
|
|
||||||
};
|
|
||||||
|
|
||||||
request.signal.addEventListener("abort", abort, { once: true });
|
|
||||||
const timedRequest = new Request(request, { signal: controller.signal });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await Promise.race([fetch(timedRequest), deadline]);
|
// MSW keeps a stalled body readable after abort, so keep the race.
|
||||||
const body = await Promise.race([response.text(), deadline]);
|
const response = await Promise.race([
|
||||||
const problem = parseJson(body);
|
fetch(new Request(request, { signal })),
|
||||||
|
rejectOnAbort(signal),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const body = await Promise.race([response.text(), rejectOnAbort(signal)]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
problem,
|
problem: parseJson(body),
|
||||||
reason: "transient",
|
reason: "transient",
|
||||||
response: new Response(body, response),
|
response: new Response(body, response),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
if (failure.reason === "abort") return { reason: "abort" };
|
if (request.signal.aborted) return { reason: "abort" };
|
||||||
if (failure.reason === "timeout" || isNetworkFailure(error))
|
// Timeout or network failure: retryable.
|
||||||
|
if (timeout.signal.aborted || isNetworkFailure(error))
|
||||||
return { reason: "transient" };
|
return { reason: "transient" };
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timer);
|
||||||
request.signal.removeEventListener("abort", abort);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rejectOnAbort(signal: AbortSignal): Promise<never> {
|
||||||
|
return new Promise((_resolve, reject) => {
|
||||||
|
// The signal is either already aborted or one of two parents firing once.
|
||||||
|
signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => reject(abortError()),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function retryAfterMs(response: Response): number | undefined {
|
function retryAfterMs(response: Response): number | undefined {
|
||||||
const header = response.headers.get("Retry-After");
|
const header = response.headers.get("Retry-After");
|
||||||
if (!header) return undefined;
|
if (!header) return undefined;
|
||||||
|
|||||||
@@ -37,38 +37,6 @@ function auth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("persistent resource query lifecycle", () => {
|
describe("persistent resource query lifecycle", () => {
|
||||||
it("observes once into the canonical cache without another import", async () => {
|
|
||||||
auth();
|
|
||||||
const modes: (null | string)[] = [];
|
|
||||||
let observations = 0;
|
|
||||||
server.use(
|
|
||||||
http.get(
|
|
||||||
"*/api/v1/brokerage-accounts/:accountId/portfolio",
|
|
||||||
({ request }) => {
|
|
||||||
const mode = new URL(request.url).searchParams.get("sync");
|
|
||||||
modes.push(mode);
|
|
||||||
if (mode) observations += 1;
|
|
||||||
|
|
||||||
return HttpResponse.json(
|
|
||||||
resource(snapshot, {
|
|
||||||
dataRevision: observations > 0 ? "new" : "old",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const { queryClient, wrapper } = createQueryWrapper();
|
|
||||||
const { result } = renderHook(() => usePrivateResource("a"), { wrapper });
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
||||||
await act(() => result.current.observe());
|
|
||||||
await waitFor(() => expect(result.current.meta?.dataRevision).toBe("new"));
|
|
||||||
|
|
||||||
expect(modes).toEqual([null, "observe"]);
|
|
||||||
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
|
|
||||||
expect(
|
|
||||||
queryClient.getQueryData(resourceOptions("a").queryKey),
|
|
||||||
).toMatchObject({ data: snapshot, meta: { dataRevision: "new" } });
|
|
||||||
});
|
|
||||||
it("keeps successful HTTP state distinct from missing or failed source data", async () => {
|
it("keeps successful HTTP state distinct from missing or failed source data", async () => {
|
||||||
auth();
|
auth();
|
||||||
server.use(
|
server.use(
|
||||||
@@ -136,47 +104,6 @@ describe("persistent resource query lifecycle", () => {
|
|||||||
expect(result.current.isSuccess).toBe(true);
|
expect(result.current.isSuccess).toBe(true);
|
||||||
expect(result.current.isPending).toBe(false);
|
expect(result.current.isPending).toBe(false);
|
||||||
});
|
});
|
||||||
it("keeps the observed snapshot when an older query finishes later", async () => {
|
|
||||||
auth();
|
|
||||||
|
|
||||||
const { promise: blocked, resolve: release }
|
|
||||||
= Promise.withResolvers<void>();
|
|
||||||
|
|
||||||
let isStarted = false;
|
|
||||||
server.use(
|
|
||||||
http.get(
|
|
||||||
"*/api/v1/brokerage-accounts/:accountId/portfolio",
|
|
||||||
async ({ request }) => {
|
|
||||||
if (!new URL(request.url).searchParams.has("sync")) {
|
|
||||||
isStarted = true;
|
|
||||||
await blocked;
|
|
||||||
|
|
||||||
return HttpResponse.json(resource({ ...snapshot, isStale: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return HttpResponse.json(resource(snapshot));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => usePrivateResource("a"), {
|
|
||||||
wrapper: createQueryWrapper().wrapper,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(isStarted).toBe(true));
|
|
||||||
// Capture the observation promise before releasing the old handler; the
|
|
||||||
// final assertion must follow its settlement, not the handler release.
|
|
||||||
let observation: Promise<unknown> = Promise.resolve();
|
|
||||||
await act(async () => {
|
|
||||||
observation = result.current.observe() ?? Promise.resolve();
|
|
||||||
await observation;
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
release();
|
|
||||||
await observation;
|
|
||||||
});
|
|
||||||
expect(result.current.data).toEqual(snapshot);
|
|
||||||
});
|
|
||||||
it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
|
it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
|
||||||
auth();
|
auth();
|
||||||
let isDenied = false;
|
let isDenied = false;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
type QueryClient,
|
type QueryClient,
|
||||||
type QueryFunctionContext,
|
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
type UseQueryOptions,
|
type UseQueryOptions,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
@@ -9,7 +8,6 @@ import { useEffect } from "react";
|
|||||||
import type { components, paths } from "./generated/schema";
|
import type { components, paths } from "./generated/schema";
|
||||||
|
|
||||||
import { useAuthenticatedQuery } from "./authenticated-query";
|
import { useAuthenticatedQuery } from "./authenticated-query";
|
||||||
import { getAuthenticatedSessionSnapshot } from "./client";
|
|
||||||
import { apiErrorCode, isBrokerageAccessLoss } from "./problem-details";
|
import { apiErrorCode, isBrokerageAccessLoss } from "./problem-details";
|
||||||
|
|
||||||
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
|
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
|
||||||
@@ -31,7 +29,7 @@ type ResourcePath = {
|
|||||||
type ResourceQueryKey = readonly [
|
type ResourceQueryKey = readonly [
|
||||||
"get",
|
"get",
|
||||||
ResourcePath,
|
ResourcePath,
|
||||||
{ params?: { path?: object; query?: { sync?: "observe" } } }?,
|
{ params?: { path?: object; query?: object } }?,
|
||||||
];
|
];
|
||||||
|
|
||||||
function invalidateResourceAccess(
|
function invalidateResourceAccess(
|
||||||
@@ -57,22 +55,6 @@ function invalidateResourceAccess(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep endpoint and parameters intact. The generated queryFn accepts the
|
|
||||||
// optional sync mode even for a cache key originally created without init.
|
|
||||||
function observationKey<K extends readonly unknown[]>(key: K): K {
|
|
||||||
const [method, path, init] = key as unknown as [
|
|
||||||
string,
|
|
||||||
string,
|
|
||||||
{ params?: { path?: object; query?: object } }?,
|
|
||||||
];
|
|
||||||
|
|
||||||
return [method, path, {
|
|
||||||
...init,
|
|
||||||
params: { ...init?.params, query: { ...init?.params?.query, sync: "observe" } },
|
|
||||||
}] as unknown as K;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observation changes transport mode, never the generated cache identity.
|
|
||||||
function useResourceQuery<
|
function useResourceQuery<
|
||||||
T extends Resource,
|
T extends Resource,
|
||||||
E = unknown,
|
E = unknown,
|
||||||
@@ -92,50 +74,17 @@ function useResourceQuery<
|
|||||||
if (group) invalidateResourceAccess(client, group, query.error);
|
if (group) invalidateResourceAccess(client, group, query.error);
|
||||||
}, [client, group, query.error]);
|
}, [client, group, query.error]);
|
||||||
|
|
||||||
const observe = async () => {
|
|
||||||
const queryFunction = options.queryFn;
|
|
||||||
if (
|
|
||||||
typeof queryFunction !== "function"
|
|
||||||
|| !query.isEnabled
|
|
||||||
|| !getAuthenticatedSessionSnapshot()
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
// Cancellation is synchronous; start the replacement in the same boundary.
|
|
||||||
void client.cancelQueries({ exact: true, queryKey: options.queryKey });
|
|
||||||
|
|
||||||
return client.query({
|
|
||||||
...options,
|
|
||||||
queryFn: async (context: QueryFunctionContext<K>) => {
|
|
||||||
const queryKey = observationKey(context.queryKey);
|
|
||||||
|
|
||||||
return queryFunction({ ...context, queryKey });
|
|
||||||
},
|
|
||||||
retry: false,
|
|
||||||
// Keep the cache envelope independent of observer projection.
|
|
||||||
select: undefined,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const data: T["data"] | undefined = query.data?.data;
|
const data: T["data"] | undefined = query.data?.data;
|
||||||
const meta: T["meta"] | undefined = query.data?.meta;
|
const meta: T["meta"] | undefined = query.data?.meta;
|
||||||
|
|
||||||
// Project resource fields; refetch still returns the transport envelope.
|
// Project resource fields; refetch still returns the transport envelope.
|
||||||
|
// queryKey is kept for screen-sync cache bookkeeping.
|
||||||
return {
|
return {
|
||||||
|
// eslint-disable-next-line @tanstack/query/no-rest-destructuring -- Single projection point; consumers already depend on the whole query state.
|
||||||
|
...query,
|
||||||
data: data ?? undefined,
|
data: data ?? undefined,
|
||||||
error: query.error,
|
|
||||||
fetchStatus: query.fetchStatus,
|
|
||||||
isEnabled: query.isEnabled,
|
|
||||||
isError: query.isError,
|
|
||||||
isFetching: query.isFetching,
|
|
||||||
isLoading: query.isLoading,
|
|
||||||
isPending: query.isPending,
|
|
||||||
isSuccess: query.isSuccess,
|
|
||||||
meta,
|
meta,
|
||||||
observe,
|
|
||||||
queryKey: options.queryKey,
|
queryKey: options.queryKey,
|
||||||
refetch: query.refetch,
|
|
||||||
status: query.status,
|
|
||||||
sync: query.data && "sync" in query.data ? query.data.sync : undefined,
|
sync: query.data && "sync" in query.data ? query.data.sync : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user