chore: finish frontend ESLint migration #18

Enable every plugin recommended preset and replace Biome with ESLint formatting.

Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
2026-09-28 16:36:40 +03:00
committed by ksv741
parent 57630102e4
commit 72d7835227
253 changed files with 10059 additions and 8864 deletions
+16 -13
View File
@@ -1,15 +1,26 @@
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import {
useAuthenticatedQueries,
useAuthenticatedQuery,
} from "./authenticated-query";
import { apiQueryClient, configureAuthClient } from "./client";
const useSingleQuery = () =>
useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
);
const useMultipleQueries = () =>
useAuthenticatedQueries([
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
])[0];
describe("useAuthenticatedQuery", () => {
it.each(["single", "multiple"])(
"keeps %s protected queries idle without a session and loads after session configuration",
@@ -23,35 +34,27 @@ describe("useAuthenticatedQuery", () => {
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
type: "BROKERAGE",
},
]),
);
}),
);
const useSingleQuery = () =>
useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
);
const useMultipleQueries = () =>
useAuthenticatedQueries([
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"),
])[0];
const { result } = renderHook(
mode === "single" ? useSingleQuery : useMultipleQueries,
{ wrapper: createQueryWrapper().wrapper },
);
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise(resolve => setTimeout(resolve, 0));
expect(requests).not.toHaveBeenCalled();
expect(result.current.fetchStatus).toBe("idle");
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
@@ -60,8 +63,8 @@ describe("useAuthenticatedQuery", () => {
{
id: "account-1",
name: "Основной",
type: "BROKERAGE",
openedOn: "2024-08-13",
type: "BROKERAGE",
},
]);
},
+27 -26
View File
@@ -1,32 +1,51 @@
import {
type QueryKey,
type UseQueryOptions,
type UseQueryResult,
useQueries,
useQuery,
type UseQueryOptions,
type UseQueryResult,
} from "@tanstack/react-query";
import { useSyncExternalStore } from "react";
import {
getAuthenticatedSessionSnapshot,
subscribeAuthenticatedSession,
} 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<
TQueryFnData = unknown,
TQueryFunctionData = unknown,
TError = Error,
TData = TQueryFnData,
TData = TQueryFunctionData,
TQueryKey extends QueryKey = QueryKey,
>(
options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
options: UseQueryOptions<TQueryFunctionData, TError, TData, TQueryKey>,
): UseQueryResult<TData, TError> {
const hasSession = useHasAuthenticatedSession();
const enabled = options.enabled;
return useQuery({
...options,
enabled: (query) =>
hasSession &&
(typeof enabled === "function" ? enabled(query) : (enabled ?? true)),
enabled: query =>
hasSession
&& (typeof enabled === "function" ? enabled(query) : (enabled ?? true)),
});
}
@@ -38,22 +57,4 @@ function useHasAuthenticatedSession() {
);
}
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)),
}),
),
});
}
export { useAuthenticatedQueries, useAuthenticatedQuery };
+13 -13
View File
@@ -1,15 +1,17 @@
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { apiClient, configureAuthClient } from "./client";
describe("apiClient authentication", () => {
it("refreshes once and retries an unauthorized protected request", async () => {
let token = "expired";
const refresh = vi.fn(async () => {
const refresh = vi.fn(() => {
token = "fresh";
return true;
return Promise.resolve(true);
});
const authorization: string[] = [];
server.use(
@@ -20,7 +22,7 @@ describe("apiClient authentication", () => {
: HttpResponse.json(resource([]));
}),
);
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() });
configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh });
const { data } = await apiClient.GET("/api/v1/brokerage-accounts");
@@ -38,8 +40,8 @@ describe("apiClient authentication", () => {
);
configureAuthClient({
accessToken: () => "expired",
refresh: async () => false,
clear,
refresh: () => Promise.resolve(false),
});
await apiClient.GET("/api/v1/brokerage-accounts");
@@ -48,7 +50,7 @@ describe("apiClient authentication", () => {
});
it("does not attach Bearer auth to authentication endpoints", async () => {
let authorization: string | null = null;
let authorization: null | string = /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null;
server.use(
http.post("*/api/v1/auth/refresh", ({ request }) => {
authorization = request.headers.get("Authorization");
@@ -57,8 +59,8 @@ describe("apiClient authentication", () => {
);
configureAuthClient({
accessToken: () => "token",
refresh: vi.fn(),
clear: vi.fn(),
refresh: vi.fn(),
});
await apiClient.POST("/api/v1/auth/refresh");
@@ -68,10 +70,8 @@ describe("apiClient authentication", () => {
it("discards a retried request response after another session takes over", async () => {
let generation = 0;
let calls = 0;
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
server.use(
http.get("*/api/v1/brokerage-accounts", async () => {
if (++calls === 1)
@@ -82,9 +82,9 @@ describe("apiClient authentication", () => {
);
configureAuthClient({
accessToken: () => "synthetic",
generation: () => generation,
refresh: async () => true,
clear: vi.fn(),
generation: () => generation,
refresh: () => Promise.resolve(true),
});
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
const response = expect(
+58 -59
View File
@@ -2,65 +2,44 @@ import createClient from "openapi-fetch";
import createQueryClient from "openapi-react-query";
import type { paths } from "./generated/schema";
import { apiErrorCode } from "./problem-details";
const HTTP_UNAUTHORIZED = 401;
const configuredBaseUrl: unknown = import.meta.env.VITE_API_BASE_URL;
const apiBaseUrl =
typeof configuredBaseUrl === "string"
const apiBaseUrl
= typeof configuredBaseUrl === "string"
? configuredBaseUrl
: "http://localhost:3000";
type AuthHandlers = {
generation?: () => number;
accessToken: () => string | null;
refresh: () => Promise<boolean>;
accessToken: () => null | string | undefined;
clear: () => void;
generation?: () => number;
refresh: () => Promise<boolean>;
};
const inFlight = new Set<{ pathname: string; invalidated: boolean }>();
const latestReads = new Map<string, { sequence: number; pending: number }>();
function invalidateApiRequests(matches: (pathname: string) => boolean) {
const inFlight = new Set<{ invalidated: boolean; pathname: string }>();
const latestReads = new Map<string, { pending: number; sequence: number }>();
function invalidateApiRequests(isMatches: (pathname: string) => boolean) {
for (const request of inFlight)
if (matches(request.pathname)) request.invalidated = true;
if (isMatches(request.pathname)) request.invalidated = true;
}
let auth: AuthHandlers | null = null;
let hasAuthenticatedSession = false;
const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = { hasSession: false };
const authSessionListeners = new Set<() => void>();
function setAuthenticatedSession(next: boolean) {
if (next === hasAuthenticatedSession) return;
hasAuthenticatedSession = next;
for (const listener of authSessionListeners) listener();
}
function configureAuthClient(handlers: AuthHandlers | null) {
if (!handlers) invalidateApiRequests(() => true);
auth = handlers;
setAuthenticatedSession(Boolean(auth?.accessToken()));
}
function getAuthenticatedSessionSnapshot() {
return hasAuthenticatedSession;
}
function subscribeAuthenticatedSession(listener: () => void) {
authSessionListeners.add(listener);
return () => authSessionListeners.delete(listener);
}
async function authenticatedFetch(request: Request): Promise<Response> {
const url = new URL(request.url);
url.searchParams.delete("sync");
url.searchParams.sort();
const key = `${request.method} ${url}`;
const ticket = { pathname: url.pathname, invalidated: false };
const read =
request.method === "GET"
? (latestReads.get(key) ?? { sequence: 0, pending: 0 })
: null;
const ticket = { invalidated: false, pathname: url.pathname };
const read
= request.method === "GET"
? (latestReads.get(key) ?? { pending: 0, sequence: 0 })
: undefined;
if (read) {
read.sequence += 1;
read.pending += 1;
@@ -73,12 +52,19 @@ async function authenticatedFetch(request: Request): Promise<Response> {
request,
() => !ticket.invalidated && (!read || sequence === read.sequence),
);
} finally {
}
finally {
inFlight.delete(ticket);
if (read && --read.pending === 0) latestReads.delete(key);
}
}
function configureAuthClient(handlers?: AuthHandlers) {
if (!handlers) invalidateApiRequests(() => true);
sessionState.handlers = handlers;
setAuthenticatedSession(Boolean(sessionState.handlers?.accessToken()));
}
async function fetchCurrent(
request: Request,
isLatest: () => boolean,
@@ -86,45 +72,43 @@ async function fetchCurrent(
const isAuthRequest = new URL(request.url).pathname.startsWith(
"/api/v1/auth/",
);
const session = auth;
const session = sessionState.handlers;
const generation = session?.generation?.();
const isCurrent = () =>
(session?.generation
? generation === session.generation() &&
generation === auth?.generation?.()
: session === auth) && isLatest();
? generation === session.generation()
&& generation === sessionState.handlers?.generation?.()
: session === sessionState.handlers) && isLatest();
const token = session?.accessToken();
const withToken = (source: Request, value: string) => {
const headers = new Headers(source.headers);
headers.set("Authorization", `Bearer ${value}`);
return new Request(source, { headers });
};
const authorized =
token && !isAuthRequest ? withToken(request, token) : request;
const authorized
= token && !isAuthRequest ? withToken(request, token) : request;
const retryRequest = authorized.clone();
const response = await fetch(authorized);
await response.clone().arrayBuffer();
if (!isAuthRequest && !isCurrent())
throw new DOMException("Session changed", "AbortError");
if (
response.status !== HTTP_UNAUTHORIZED ||
isAuthRequest ||
!auth ||
apiErrorCode(
await response
.clone()
.json()
.catch(() => null),
) !== "UNAUTHORIZED"
)
if (isAuthRequest || !sessionState.handlers || response.status !== HTTP_UNAUTHORIZED)
return response;
let problem: unknown;
try {
problem = await response.clone().json();
}
catch {
return response;
}
if (apiErrorCode(problem) !== "UNAUTHORIZED") return response;
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
if (!(await auth.refresh())) {
if (isCurrent()) auth.clear();
if (!(await sessionState.handlers.refresh())) {
if (isCurrent()) sessionState.handlers.clear();
return response;
}
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
const refreshedToken = auth.accessToken();
const refreshedToken = sessionState.handlers.accessToken();
if (!refreshedToken) return response;
const retried = await fetch(withToken(retryRequest, refreshedToken));
await retried.clone().arrayBuffer();
@@ -132,6 +116,21 @@ async function fetchCurrent(
return retried;
}
function hasAuthenticatedSession() {
return sessionState.hasSession;
}
function setAuthenticatedSession(hasSession: boolean) {
if (hasSession === sessionState.hasSession) return;
sessionState.hasSession = hasSession;
for (const listener of authSessionListeners) listener();
}
function subscribeAuthenticatedSession(listener: () => void) {
authSessionListeners.add(listener);
return () => authSessionListeners.delete(listener);
}
const apiClient = createClient<paths>({
baseUrl: apiBaseUrl,
credentials: "include",
@@ -144,7 +143,7 @@ export {
apiClient,
apiQueryClient,
configureAuthClient,
getAuthenticatedSessionSnapshot,
hasAuthenticatedSession as getAuthenticatedSessionSnapshot,
invalidateApiRequests,
subscribeAuthenticatedSession,
};
+63 -61
View File
@@ -1,15 +1,15 @@
import type { QueryClient } from "@tanstack/react-query";
import {
import { type QueryClient,
type QueryFunctionContext,
type QueryKey,
type UseQueryOptions,
type UseQueryResult,
useQueryClient,
} from "@tanstack/react-query";
type UseQueryOptions,
type UseQueryResult } from "@tanstack/react-query";
import { useEffect } from "react";
import type { components } from "./generated/schema";
import { useAuthenticatedQuery } from "./authenticated-query";
import { getAuthenticatedSessionSnapshot } from "./client";
import type { components } from "./generated/schema";
import { apiErrorCode } from "./problem-details";
const accessEpochs = new WeakMap<QueryClient, Map<string, number>>();
@@ -18,31 +18,6 @@ type Resource = {
data: unknown;
meta: components["schemas"]["InvestmentResourceMetaDto"];
};
function unwrapResourceResult<T extends Resource, E>(
result: UseQueryResult<T, E>,
) {
const missing = result.isSuccess && result.data.data === null;
const data: T["data"] | undefined = result.data?.data;
const meta: T["meta"] | undefined = result.data?.meta;
const status: UseQueryResult<T, E>["status"] = missing
? "pending"
: result.status;
// Project resource fields; refetch still returns the transport envelope.
return {
...result,
transport: {
status: result.status,
fetchStatus: result.fetchStatus,
error: result.error,
},
data: data ?? undefined,
meta,
isPending: result.isPending || missing,
isSuccess: result.isSuccess && !missing,
status,
};
}
function invalidateResourceAccess(
client: QueryClient,
group: string,
@@ -50,10 +25,10 @@ function invalidateResourceAccess(
) {
if (
![
"NOT_FOUND",
"FORBIDDEN",
"TBANK_NOT_CONNECTED",
"INVALID_TBANK_TOKEN",
"NOT_FOUND",
"TBANK_NOT_CONNECTED",
].includes(apiErrorCode(error) ?? "")
)
return;
@@ -69,19 +44,44 @@ function invalidateResourceAccess(
cached.setState({
data: undefined,
dataUpdatedAt: 0,
status: "error",
error: error as Error,
fetchStatus: "idle",
status: "error",
});
}
function unwrapResourceResult<T extends Resource, E>(
result: UseQueryResult<T, E>,
) {
const isMissing = result.isSuccess && result.data.data === null;
const data: T["data"] | undefined = result.data?.data;
const meta: T["meta"] | undefined = result.data?.meta;
const status: UseQueryResult<T, E>["status"] = isMissing
? "pending"
: result.status;
// Project resource fields; refetch still returns the transport envelope.
return {
...result,
data: data ?? undefined,
isPending: result.isPending || isMissing,
isSuccess: result.isSuccess && !isMissing,
meta,
status,
transport: {
error: result.error,
fetchStatus: result.fetchStatus,
status: result.status,
},
};
}
// Observation changes transport mode, never the generated cache identity.
function useResourceQuery<T extends Resource, E, K extends QueryKey>(
options: UseQueryOptions<T, E, T, K>,
) {
const client = useQueryClient();
const group =
typeof options.meta?.accessGroup === "string"
const group
= typeof options.meta?.accessGroup === "string"
? options.meta.accessGroup
: undefined;
let epochs = accessEpochs.get(client);
@@ -90,40 +90,38 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
accessEpochs.set(client, epochs);
}
const groupEpochs = epochs;
const queryFn = options.queryFn;
const guarded =
typeof queryFn === "function"
const queryFunction = options.queryFn;
const guarded
= typeof queryFunction === "function"
? async (context: QueryFunctionContext<K>) => {
const epoch = group ? (groupEpochs.get(group) ?? 0) : 0;
const result = await queryFn(context);
if (group && epoch !== (groupEpochs.get(group) ?? 0))
throw new DOMException("Access changed", "AbortError");
return result;
}
: queryFn;
const epoch = group ? (groupEpochs.get(group) ?? 0) : 0;
const result = await queryFunction(context);
if (group && epoch !== (groupEpochs.get(group) ?? 0))
throw new DOMException("Access changed", "AbortError");
return result;
}
: queryFunction;
const query = useAuthenticatedQuery({ ...options, queryFn: guarded });
useEffect(() => {
if (group) invalidateResourceAccess(client, group, query.error);
}, [client, group, query.error]);
const observe = async (expectedStateVersion?: string) => {
const queryFn = guarded;
const queryFunction = guarded;
if (
!query.isEnabled ||
!getAuthenticatedSessionSnapshot() ||
typeof queryFn !== "function"
typeof queryFunction !== "function"
|| !query.isEnabled
|| !getAuthenticatedSessionSnapshot()
)
return;
// Cancellation is synchronous; start the replacement in the same boundary.
void client.cancelQueries({ queryKey: options.queryKey, exact: true });
return client.fetchQuery({
void client.cancelQueries({ exact: true, queryKey: options.queryKey });
return client.query({
...options,
staleTime: 0,
retry: false,
queryFn: async (context: QueryFunctionContext<K>) => {
const [method, path, input] = context.queryKey;
const init = input as
| { params?: { query?: Record<string, unknown> } }
| undefined;
| undefined
| { params?: { query?: Record<string, unknown> } };
const queryKey = [
method,
path,
@@ -135,25 +133,29 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
},
},
] as unknown as K;
const response = await queryFn({ ...context, queryKey });
const response = await queryFunction({ ...context, queryKey });
if (
expectedStateVersion &&
response.meta.stateVersion !== expectedStateVersion
expectedStateVersion
&& response.meta.stateVersion !== expectedStateVersion
) {
// One more observational read; never turn a stale notification into an import.
const current = await queryFn({ ...context, queryKey });
const current = await queryFunction({ ...context, queryKey });
if (current.meta.stateVersion !== expectedStateVersion)
throw new DOMException("Resource changed", "AbortError");
return current;
}
return response;
},
retry: false,
// Observation validates raw resource metadata, before any observer projection.
select: undefined,
staleTime: 0,
});
};
return {
...unwrapResourceResult(query),
queryKey: options.queryKey,
observe,
queryKey: options.queryKey,
};
}