refactor!: simplify query and session ownership
Frontend verify / commits (pull_request) Failing after 27s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped

Use Query cancellation and native mutation results instead of HTTP
registries, access epochs and copied scope state.
Keep local auth ordering and mutation identity fences.
Allow staged generated files to remain excluded from ESLint.

Backend contract: WealthLedger/backend@d698ed9

BREAKING CHANGE: drop investment stateVersion/progress/resources
from the client contract and use data/metadata readiness instead of
rewritten query status. Cross-tab auth may require re-login.
This commit is contained in:
2026-10-03 11:45:04 +03:00
parent 6a89b08ee5
commit 69997075e5
25 changed files with 151 additions and 551 deletions
+3 -8
View File
@@ -38,14 +38,10 @@ function useAuthProviderVm() {
const authQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const refreshPromiseRef = useRef<Promise<boolean> | undefined>(undefined);
// Cookie-writing requests share ordering, including across tabs with Web Locks.
// Order cookie-writing requests within this tab. Other tabs may reauthenticate.
const serialize = useCallback(<T>(run: () => Promise<T>): Promise<T> => {
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
const next = authQueueRef.current.then(async () =>
"locks" in navigator
? navigator.locks.request("wealth-ledger-refresh", run)
: run(),
);
const next = authQueueRef.current.then(run);
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
authQueueRef.current = next.catch(() => {});
@@ -110,8 +106,7 @@ function useAuthProviderVm() {
}
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
}).finally(() => {
if (refreshPromiseRef.current === pending)
refreshPromiseRef.current = undefined;
refreshPromiseRef.current = undefined;
});
refreshPromiseRef.current = pending;
@@ -34,7 +34,7 @@ function section(
Boolean(query.data)
&& (query.isFetching
|| query.meta?.sync.state === "running"
|| query.meta?.sync.state === "queued"),
),
retry: () => {
void (retry ?? query.refetch)();
},
@@ -2,7 +2,6 @@ import type { QueryClient } from "@tanstack/react-query";
import {
apiQueryClient,
invalidateApiRequests,
type paths,
} from "@/shared/api";
@@ -27,7 +26,6 @@ function commitTbankConnectionBoundary(
queryClient: QueryClient,
status: TbankConnectionStatus,
) {
invalidateApiRequests(isInvestmentPath);
queryClient.setQueryData(tbankConnectionQueryKey, status);
void queryClient.resetQueries({
predicate: ({ queryKey }) => {
@@ -21,7 +21,7 @@ function auth() {
}
describe("page-owned screen synchronization", () => {
it("ignores a late sync acceptance after the account scope changes", async () => {
it("keeps the current scope when an older sync acceptance arrives last", async () => {
auth();
const { promise: blocked, resolve: release }
@@ -37,7 +37,7 @@ describe("page-owned screen synchronization", () => {
};
posted.push(scope.accountId);
await blocked;
if (scope.accountId === "old") await blocked;
return HttpResponse.json(
{ id: scope.accountId, now: "2026-09-25T00:00:00Z", scope },
@@ -49,7 +49,6 @@ describe("page-owned screen synchronization", () => {
return HttpResponse.json({
id: params.id,
resources: [],
state: "succeeded",
});
}),
@@ -69,12 +68,13 @@ describe("page-owned screen synchronization", () => {
});
await waitFor(() => expect(posted).toEqual(["old"]));
rerender({ accountId: "new" });
await act(() => result.current.refresh());
await waitFor(() => expect(polled).toEqual(["new"]));
await act(async () => {
release();
await pending;
});
await act(() => result.current.refresh());
await waitFor(() => expect(polled).toEqual(["new"]));
expect(polled).toEqual(["new"]);
expect(posted).toEqual(["old", "new"]);
});
it.each([false, true])(
@@ -91,7 +91,7 @@ describe("page-owned screen synchronization", () => {
return isUnavailable
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
resource([], { stateVersion: String(modes.length) }),
resource([], { dataRevision: String(modes.length) }),
);
}),
http.post("*/api/v1/tbank-sync", () => {
@@ -129,7 +129,7 @@ describe("page-owned screen synchronization", () => {
isUnavailable = false;
await act(() => result.current.sync.retry());
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(result.current.accounts.meta?.stateVersion).toBe(
expect(result.current.accounts.meta?.dataRevision).toBe(
cached ? "3" : "2",
);
expect(modes).toEqual(
@@ -169,15 +169,15 @@ describe("page-owned screen synchronization", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", async ({ request }) => {
if (boundary === "connection" && isChanged && !request.signal.aborted)
return HttpResponse.json(resource([], { stateVersion: "current" }));
return HttpResponse.json(resource([], { dataRevision: "current" }));
if (!new URL(request.url).searchParams.has("sync"))
return isChanged
? HttpResponse.json(resource([], { stateVersion: "current" }))
? HttpResponse.json(resource([], { dataRevision: "current" }))
: HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 });
isRecovering = true;
await blocked;
return HttpResponse.json(resource([], { stateVersion: "old" }));
return HttpResponse.json(resource([], { dataRevision: "old" }));
}),
);
const { queryClient, wrapper } = createQueryWrapper();
@@ -247,11 +247,11 @@ describe("page-owned screen synchronization", () => {
if (boundary === "connection")
await waitFor(() =>
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(result.current.accounts.meta?.stateVersion).toBe("current"),
expect(result.current.accounts.meta?.dataRevision).toBe("current"),
);
expect(published).not.toContainEqual(
expect.objectContaining({
meta: expect.objectContaining({ stateVersion: "old" }) as unknown,
meta: expect.objectContaining({ dataRevision: "old" }) as unknown,
}),
);
unsubscribe();
@@ -281,7 +281,6 @@ describe("page-owned screen synchronization", () => {
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({
id: "recover",
resources: [resource([]).meta],
state: "succeeded",
});
}),
@@ -316,7 +315,8 @@ describe("page-owned screen synchronization", () => {
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(result.current.accounts.data).toEqual([]);
expect(modes).toEqual([null, "observe"]);
expect(modes[0]).toBeNull();
expect(modes.slice(1).every(mode => mode === "observe")).toBe(true);
expect(
queryClient
.getQueryCache()
@@ -332,7 +332,7 @@ describe("page-owned screen synchronization", () => {
let polls = 0;
const meta = () =>
resource([], { stateVersion: isCompleted ? "2" : "1" }).meta;
resource([], { dataRevision: isCompleted ? "2" : "1" }).meta;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
@@ -361,8 +361,6 @@ describe("page-owned screen synchronization", () => {
errorCode: null,
id: "screen",
now: "2026-09-25T00:00:00Z",
progress: { completed: 1, discovering: false, total: 1 },
resources: [meta()],
scope: { screen: "accounts" },
state: "succeeded",
},
@@ -390,7 +388,7 @@ describe("page-owned screen synchronization", () => {
]);
});
await waitFor(() =>
expect(result.current.accounts.meta?.stateVersion).toBe("2"),
expect(result.current.accounts.meta?.dataRevision).toBe("2"),
);
expect(bodies).toEqual([{ screen: "accounts" }]);
@@ -427,7 +425,7 @@ describe("page-owned screen synchronization", () => {
? []
: null,
{
stateVersion: isCompleted ? "covered" : "initial",
dataRevision: isCompleted ? "covered" : "initial",
sync: { errorCode: null, id: "history", state: "running" },
},
);
@@ -446,12 +444,6 @@ describe("page-owned screen synchronization", () => {
errorCode: null,
id: "history",
now: "2026-09-25T00:00:00Z",
progress: {
completed: isCompleted ? 1 : 0,
discovering: true,
total: 2,
},
resources: [snapshot().meta],
scope: null,
state: "running",
},
@@ -487,7 +479,8 @@ describe("page-owned screen synchronization", () => {
await new Promise(resolve => setTimeout(resolve, 1100));
});
expect(modes).toEqual([null, "observe"]);
expect(modes[0]).toBeNull();
expect(modes.slice(1).every(mode => mode === "observe")).toBe(true);
if (boundary === "leave") unmount();
else act(() => configureAuthClient());
const stopped = polls;
@@ -545,8 +538,8 @@ describe("page-owned screen synchronization", () => {
[],
{
dataRevision: isObserved ? "covered" : "initial",
resourceKey: "operations",
stateVersion: isObserved ? "covered" : "initial",
sync: {
errorCode: null,
id: "dependency",
@@ -570,7 +563,7 @@ describe("page-owned screen synchronization", () => {
dependencies: [
{
...dependency,
stateVersion: isObserved ? "covered" : "initial",
dataRevision: isObserved ? "covered" : "initial",
},
],
resourceKey: "projection",
@@ -585,8 +578,6 @@ describe("page-owned screen synchronization", () => {
errorCode: null,
id: "dependency",
now: "2026-09-25T00:00:00Z",
progress: { completed: 1, discovering: false, total: 1 },
resources: [{ ...dependency, stateVersion: "covered" }],
scope: null,
state: "succeeded",
},
@@ -1,5 +1,5 @@
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import {
ApiErrorCode,
@@ -8,103 +8,49 @@ import {
invalidateResourceAccess,
type InvestmentResourceMeta,
isApiErrorCode,
type paths,
useAuthenticatedQueries,
} from "@/shared/api";
import { type InvestmentScreen, useTbankSync } from "./sync";
type InvestmentScreen
= paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
type ScreenResource = {
isError: boolean;
isFetching: boolean;
meta?: InvestmentResourceMeta;
observe: (version?: string) => Promise<unknown>;
observe: () => Promise<unknown>;
queryKey: QueryKey;
transport: {
fetchStatus: "fetching"
| "idle"
| "paused";
status: "error"
| "pending"
| "success";
};
};
const STATUS_INTERVAL_MS = 1000;
function observeResource(
resource: ScreenResource,
latest: Map<string, InvestmentResourceMeta>,
completedId: string | undefined,
observed: Map<string, string>,
) {
if (!resource.meta) {
if (!completedId || resource.transport.fetchStatus !== "idle") return;
const key = `query:${JSON.stringify(resource.queryKey)}`;
if (observed.get(key) === completedId) return;
observed.set(key, completedId);
void resource.observe().catch(() => {});
return;
}
const changed = [
resource.meta,
...(resource.meta.dependencies ?? []),
].flatMap((current) => {
const notified = latest.get(current.resourceKey);
return notified && notified.stateVersion !== current.stateVersion
? [notified]
: [];
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
const mutation = apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
retry: false,
});
if (changed.length === 0) return;
const notification = JSON.stringify(
changed.map(meta => [meta.resourceKey, meta.stateVersion]),
);
if (observed.get(resource.meta.resourceKey) === notification) return;
observed.set(resource.meta.resourceKey, notification);
const own = changed.find(
meta => meta.resourceKey === resource.meta?.resourceKey,
);
void resource.observe(own?.stateVersion).catch(() => {});
}
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
const mutation = useTbankSync();
const client = useQueryClient();
const deniedRef = useRef(new Set<unknown>());
const scopeKey = JSON.stringify(scope);
const [requested, setRequested] = useState<null
| {
id: string;
scope: string;
}>(null);
const requestInFlightRef = useRef<null | string>(null);
const currentScopeRef = useRef(scopeKey);
useLayoutEffect(() => {
currentScopeRef.current = scopeKey;
}, [scopeKey]);
const isCurrentMutation
= JSON.stringify(mutation.variables?.body) === scopeKey;
const observedRef = useRef(new Map<string, string>());
const observedRef = useRef("");
const ids = [
...new Set([
...(requested?.scope === scopeKey ? [requested.id] : []),
...(isCurrentMutation && mutation.data ? [mutation.data.id] : []),
...resources
.flatMap(({ meta }) =>
meta ? [meta, ...(meta.dependencies ?? [])] : [],
)
.flatMap(meta =>
meta.sync.id
&& (meta.sync.state === "running" || meta.sync.state === "queued")
&& meta.sync.state === "running"
? [meta.sync.id]
: [],
),
@@ -140,33 +86,29 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
invalidateResourceAccess(client, "brokerage", status.error);
}
const latest = new Map<string, InvestmentResourceMeta>();
if (statuses.every(status => !status.data)) return;
const orderedStatuses = statuses.toSorted(
(a, b) => a.dataUpdatedAt - b.dataUpdatedAt,
const observation = JSON.stringify([
scopeKey,
statuses.map(status => [
status.data?.id,
status.data?.state,
status.dataUpdatedAt,
]),
resources.map(resource => resource.queryKey),
]);
if (observedRef.current === observation) return;
observedRef.current = observation;
const isCompleted = statuses.every(
status => status.data && status.data.state !== "running",
);
for (const status of orderedStatuses) {
const statusResources = status.data?.resources ?? [];
for (const meta of statusResources) {
latest.set(meta.resourceKey, meta);
const dependencies = meta.dependencies ?? [];
for (const dependency of dependencies)
latest.set(dependency.resourceKey, dependency);
}
}
const completed = statuses.find(
status =>
requested?.scope === scopeKey
&& status.data?.id === requested.id
&& status.data.state !== "running",
)?.data;
for (const resource of resources)
observeResource(resource, latest, completed?.id, observedRef.current);
}, [client, statuses, resources, requested, scopeKey]);
if (isCompleted || !resource.isFetching)
void resource.observe().catch(() => {});
}, [client, statuses, resources, scopeKey]);
const isRefreshing
= (isCurrentMutation && mutation.isPending)
@@ -179,7 +121,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
return (
!status?.isError
&& (!status?.data || status.data.state === "running")
&& (meta?.sync.state === "running" || meta?.sync.state === "queued")
&& meta?.sync.state === "running"
);
});
@@ -188,12 +130,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
requestInFlightRef.current = scopeKey;
try {
const accepted = await mutation.mutateAsync({ body: scope });
if (
currentScopeRef.current === scopeKey
&& requestInFlightRef.current === scopeKey
)
setRequested({ id: accepted.id, scope: scopeKey });
await mutation.mutateAsync({ body: scope });
}
catch {
/*
@@ -212,7 +149,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
// A source failure (or explicit refresh with no read error) still needs a sync.
const retry = async () => {
const failed = resources.filter(
resource => resource.transport.status === "error",
resource => resource.isError,
);
const failedStatuses = statuses.filter(status => status.isError);
@@ -238,4 +175,4 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
};
}
export { useScreenSync };
export { type InvestmentScreen, useScreenSync };
@@ -8,13 +8,14 @@ import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import type { InvestmentScreen } from "./screen-sync";
import { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
import { type InvestmentScreen, useTbankSync } from "./sync";
describe("sync connection and session boundaries", () => {
it.each(["connection", "session"])(
"discards pending status and mutation after %s changes",
"keeps old status out of the cache after %s changes",
async (boundary) => {
let generation = 0;
configureAuthClient({
@@ -57,8 +58,6 @@ describe("sync connection and session boundaries", () => {
...accepted,
errorCode: null,
now,
progress: { completed: 1, discovering: false, total: 1 },
resources: [],
state: "succeeded",
},
);
@@ -84,7 +83,11 @@ describe("sync connection and session boundaries", () => {
const accounts = useBrokerageAccounts();
useScreenSync({ screen: "accounts" }, [accounts]);
return { mutation: useTbankSync() };
return {
mutation: apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
retry: false,
}),
};
},
{ wrapper },
);
@@ -95,10 +98,8 @@ describe("sync connection and session boundaries", () => {
mutation = result.current.mutation.mutateAsync({ body: screen });
});
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
const discarded = expect(mutation).rejects.toMatchObject({
name: "AbortError",
});
// eslint-disable-next-line unicorn/prefer-await -- Handle rejection before changing the boundary and releasing the pending response.
const settled = mutation.catch(() => {});
await waitFor(() => expect(started).toBe(2));
await act(async () => {
@@ -121,8 +122,7 @@ describe("sync connection and session boundaries", () => {
release();
await blocked;
});
await discarded;
expect(result.current.mutation.data).toBeUndefined();
await settled;
const key = apiQueryClient.queryOptions(
"get",
@@ -1,12 +0,0 @@
import { apiQueryClient, type paths } from "@/shared/api";
type InvestmentScreen
= paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
function useTbankSync() {
return apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
retry: false,
});
}
export { type InvestmentScreen, useTbankSync };