perf(tbank-connection): poll screen sync every five seconds #47

Merged
ksv741 merged 1 commits from codex/reduce-screen-sync-polling into develop 2026-10-07 14:56:40 +03:00
2 changed files with 162 additions and 31 deletions
@@ -420,7 +420,7 @@ describe("page-owned screen synchronization", () => {
it.each(["leave", "logout"])( it.each(["leave", "logout"])(
"publishes completed empty coverage while history runs, then stops on %s", "publishes completed empty coverage while history runs, then stops on %s",
async (boundary) => { async (boundary) => {
vi.useFakeTimers({ toFake: ["Date"] }); vi.useFakeTimers();
try { try {
auth(); auth();
@@ -429,15 +429,13 @@ describe("page-owned screen synchronization", () => {
const modes: (null | string)[] = []; const modes: (null | string)[] = [];
const snapshot = () => const snapshot = () =>
resource( resource(isCompleted ? [] : null, {
isCompleted dataRevision: isCompleted ? "covered" : "initial",
? [] sync: { errorCode: null, id: "history", state: "running" },
: null, unavailableReason: isCompleted
{ ? undefined
dataRevision: isCompleted ? "covered" : "initial", : "CALENDAR_OR_ACCOUNT_HISTORY",
sync: { errorCode: null, id: "history", state: "running" }, });
},
);
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => { http.get("*/api/v1/brokerage-accounts", ({ request }) => {
@@ -472,31 +470,26 @@ describe("page-owned screen synchronization", () => {
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => await vi.waitFor(() => expect(modes).toEqual([null, "observe"]));
expect(result.current.accounts.meta?.sync.id).toBe("history"), expect(polls).toBe(1);
);
await waitFor(() => expect(polls).toBeGreaterThan(0));
expect(result.current.sync.refreshing).toBe(true); expect(result.current.sync.refreshing).toBe(true);
expect(result.current.accounts.data).toBeUndefined(); expect(result.current.accounts.data).toBeUndefined();
isCompleted = true;
vi.setSystemTime(Date.now() + 61_000);
await waitFor(() => expect(result.current.accounts.data).toEqual([]), {
timeout: 3000,
});
expect(result.current.sync.refreshing).toBe(true);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 1100));
});
expect(modes[0]).toBeNull(); isCompleted = true;
expect(modes.slice(1).every(mode => mode === "observe")).toBe(true); await act(() => vi.advanceTimersByTimeAsync(5000));
await vi.waitFor(() =>
expect(result.current.accounts.data).toEqual([]),
);
expect(result.current.sync.refreshing).toBe(true);
expect(modes).toEqual([null, "observe", "observe"]);
if (boundary === "leave") unmount(); if (boundary === "leave") unmount();
else act(() => configureAuthClient()); else act(() => configureAuthClient());
const stopped = polls;
await act(async () => { await act(() => vi.advanceTimersByTimeAsync(60_000));
await new Promise(resolve => setTimeout(resolve, 1100)); expect(polls).toBe(2);
}); expect(modes).toHaveLength(3);
expect(polls).toBe(stopped);
} }
finally { finally {
vi.useRealTimers(); vi.useRealTimers();
@@ -504,6 +497,144 @@ describe("page-owned screen synchronization", () => {
}, },
); );
it("runs the next status poll and observe read only after five seconds", async () => {
auth();
const modes: (null | string)[] = [];
const pollGaps: number[] = [];
let polls = 0;
let previousPollAt = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(
resource([], {
sync: { errorCode: null, id: "history", state: "running" },
}),
);
}),
http.get("*/api/v1/tbank-sync/:id", () => {
const now = Date.now();
if (previousPollAt) pollGaps.push(now - previousPollAt);
previousPollAt = now;
polls++;
return HttpResponse.json({
errorCode: null,
id: "history",
now: "2026-09-25T00:00:00Z",
scope: null,
state: "running",
});
}),
);
vi.useFakeTimers();
try {
const { unmount } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await vi.waitFor(() => expect(modes).toEqual([null, "observe"]));
expect(polls).toBe(1);
await act(() => vi.advanceTimersByTimeAsync(2500));
expect(polls).toBe(1);
expect(modes).toEqual([null, "observe"]);
await act(() => vi.advanceTimersByTimeAsync(5000));
await vi.waitFor(() => expect(polls).toBe(2));
expect(modes).toEqual([null, "observe", "observe"]);
expect(pollGaps).toHaveLength(1);
expect(pollGaps[0]).toBeGreaterThanOrEqual(5000);
unmount();
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(polls).toBe(2);
expect(modes).toHaveLength(3);
}
finally {
vi.useRealTimers();
}
});
it("observes resources immediately when a status response turns terminal", async () => {
auth();
const modes: (null | string)[] = [];
let isCompleted = false;
let polls = 0;
const meta = () =>
resource([], {
dataRevision: isCompleted ? "covered" : "initial",
sync: { errorCode: null, id: "history", state: "running" },
});
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(meta());
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
return HttpResponse.json({
errorCode: null,
id: "history",
now: "2026-09-25T00:00:00Z",
scope: null,
state: isCompleted ? "succeeded" : "running",
});
}),
);
vi.useFakeTimers();
try {
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await vi.waitFor(() => expect(modes).toEqual([null, "observe"]));
expect(polls).toBe(1);
expect(result.current.sync.refreshing).toBe(true);
isCompleted = true;
await act(() => vi.advanceTimersByTimeAsync(5000));
await vi.waitFor(() => expect(modes).toEqual([null, "observe", "observe"]));
expect(polls).toBe(2);
expect(result.current.accounts.meta?.dataRevision).toBe("covered");
expect(result.current.sync.refreshing).toBe(false);
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(polls).toBe(2);
expect(modes).toHaveLength(3);
}
finally {
vi.useRealTimers();
}
});
it("clears private snapshots when status confirms access loss", async () => { it("clears private snapshots when status confirms access loss", async () => {
auth(); auth();
server.use( server.use(
@@ -29,7 +29,7 @@ type ScreenResource = {
refetch: () => Promise<unknown>; refetch: () => Promise<unknown>;
}; };
const STATUS_INTERVAL_MS = 1000; const STATUS_INTERVAL_MS = 5000;
const FIRST_SOURCE_RECOVERY_DELAY_MS = 5000; const FIRST_SOURCE_RECOVERY_DELAY_MS = 5000;
const SECOND_SOURCE_RECOVERY_DELAY_MS = 15_000; const SECOND_SOURCE_RECOVERY_DELAY_MS = 15_000;