From 74f8a72ed55af2348fa3668f57312f4fdfc1e8a8 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 7 Oct 2026 14:03:55 +0300 Subject: [PATCH] perf(tbank-connection): poll screen sync every five seconds During active screen sync, status checks and observe reads run every 5s instead of 1s; terminal state observes resources immediately. OpenSpec change: WealthLedger/specs#21 --- .../model/screen-sync.test.tsx | 191 +++++++++++++++--- .../tbank-connection/model/screen-sync.ts | 2 +- 2 files changed, 162 insertions(+), 31 deletions(-) diff --git a/src/features/tbank-connection/model/screen-sync.test.tsx b/src/features/tbank-connection/model/screen-sync.test.tsx index aef200c..be495f5 100644 --- a/src/features/tbank-connection/model/screen-sync.test.tsx +++ b/src/features/tbank-connection/model/screen-sync.test.tsx @@ -420,7 +420,7 @@ describe("page-owned screen synchronization", () => { it.each(["leave", "logout"])( "publishes completed empty coverage while history runs, then stops on %s", async (boundary) => { - vi.useFakeTimers({ toFake: ["Date"] }); + vi.useFakeTimers(); try { auth(); @@ -429,15 +429,13 @@ describe("page-owned screen synchronization", () => { const modes: (null | string)[] = []; const snapshot = () => - resource( - isCompleted - ? [] - : null, - { - dataRevision: isCompleted ? "covered" : "initial", - sync: { errorCode: null, id: "history", state: "running" }, - }, - ); + resource(isCompleted ? [] : null, { + dataRevision: isCompleted ? "covered" : "initial", + sync: { errorCode: null, id: "history", state: "running" }, + unavailableReason: isCompleted + ? undefined + : "CALENDAR_OR_ACCOUNT_HISTORY", + }); server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { @@ -472,31 +470,26 @@ describe("page-owned screen synchronization", () => { { wrapper: createQueryWrapper().wrapper }, ); - await waitFor(() => - expect(result.current.accounts.meta?.sync.id).toBe("history"), - ); - await waitFor(() => expect(polls).toBeGreaterThan(0)); + await vi.waitFor(() => expect(modes).toEqual([null, "observe"])); + expect(polls).toBe(1); expect(result.current.sync.refreshing).toBe(true); 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(); - expect(modes.slice(1).every(mode => mode === "observe")).toBe(true); + isCompleted = 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(); else act(() => configureAuthClient()); - const stopped = polls; - await act(async () => { - await new Promise(resolve => setTimeout(resolve, 1100)); - }); - expect(polls).toBe(stopped); + + await act(() => vi.advanceTimersByTimeAsync(60_000)); + expect(polls).toBe(2); + expect(modes).toHaveLength(3); } finally { 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 () => { auth(); server.use( diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts index 0809222..56b9893 100644 --- a/src/features/tbank-connection/model/screen-sync.ts +++ b/src/features/tbank-connection/model/screen-sync.ts @@ -29,7 +29,7 @@ type ScreenResource = { refetch: () => Promise; }; -const STATUS_INTERVAL_MS = 1000; +const STATUS_INTERVAL_MS = 5000; const FIRST_SOURCE_RECOVERY_DELAY_MS = 5000; const SECOND_SOURCE_RECOVERY_DELAY_MS = 15_000;