diff --git a/app/src/components/admin/ComplianceSection.test.tsx b/app/src/components/admin/ComplianceSection.test.tsx index 2782a94c..b4a9f7f6 100644 --- a/app/src/components/admin/ComplianceSection.test.tsx +++ b/app/src/components/admin/ComplianceSection.test.tsx @@ -132,7 +132,6 @@ describe("ComplianceSection", () => { verifier: "CVERIFIERADDRESS", target: TARGET, destination: undefined, - active: true, }) const overview = screen.getByRole("region", { name: "Target" }) expect(within(overview).getByText("Allowlisted")).toBeTruthy() @@ -300,8 +299,5 @@ describe("ComplianceSection", () => { />, ) expect(screen.queryAllByRole("dialog", { hidden: true })).toHaveLength(0) - expect(useComplianceStateMock).toHaveBeenLastCalledWith( - expect.objectContaining({ active: false }), - ) }) }) diff --git a/app/src/components/admin/ComplianceSection.tsx b/app/src/components/admin/ComplianceSection.tsx index 85481696..cee12714 100644 --- a/app/src/components/admin/ComplianceSection.tsx +++ b/app/src/components/admin/ComplianceSection.tsx @@ -112,7 +112,6 @@ const ComplianceSection: React.FC<{ verifier, target, destination: destinationOf(inputs), - active, }) const flows = useComplianceFlows() diff --git a/app/src/components/admin/ConfigurationSection.test.tsx b/app/src/components/admin/ConfigurationSection.test.tsx index 8c9e839b..b001b6fe 100644 --- a/app/src/components/admin/ConfigurationSection.test.tsx +++ b/app/src/components/admin/ConfigurationSection.test.tsx @@ -196,22 +196,4 @@ describe("ConfigurationSection", () => { ) expect(screen.queryAllByRole("dialog", { hidden: true })).toHaveLength(0) }) - - it("reads the parameters with polling only while active", () => { - const { rerender } = render( - , - ) - expect(useConfigurationStateMock).toHaveBeenLastCalledWith({ - active: false, - }) - - rerender( - , - ) - expect(useConfigurationStateMock).toHaveBeenLastCalledWith({ active: true }) - }) }) diff --git a/app/src/components/admin/ConfigurationSection.tsx b/app/src/components/admin/ConfigurationSection.tsx index 7f3846ef..433f32d2 100644 --- a/app/src/components/admin/ConfigurationSection.tsx +++ b/app/src/components/admin/ConfigurationSection.tsx @@ -53,7 +53,7 @@ const ConfigurationSection: React.FC<{ contracts?: React.ReactNode active?: boolean }> = ({ grants, wallet, contracts, active = true }) => { - const { configuration } = useConfigurationState({ active }) + const { configuration } = useConfigurationState() const depositCap = usePendingTransaction(useSetDepositCap()) const custodian = usePendingTransaction(useSetCustodian()) const notice = usePendingTransaction(useSetNotice()) diff --git a/app/src/components/admin/CycleSection.test.tsx b/app/src/components/admin/CycleSection.test.tsx index 6064be2e..519bfc14 100644 --- a/app/src/components/admin/CycleSection.test.tsx +++ b/app/src/components/admin/CycleSection.test.tsx @@ -363,16 +363,6 @@ describe("CycleSection", () => { expect(screen.getAllByRole("dialog", { hidden: true })).toHaveLength(1) }) - it("pauses the cycle polling while the surface is inactive", () => { - const { rerender } = render( - , - ) - expect(useCycleStateMock).toHaveBeenLastCalledWith({ active: false }) - - rerender() - expect(useCycleStateMock).toHaveBeenLastCalledWith({ active: true }) - }) - it("labels the deployment modal with the exact amount that is signed", () => { const { rerender } = render( , diff --git a/app/src/components/admin/CycleSection.tsx b/app/src/components/admin/CycleSection.tsx index 287e4e86..05114960 100644 --- a/app/src/components/admin/CycleSection.tsx +++ b/app/src/components/admin/CycleSection.tsx @@ -41,7 +41,7 @@ const CycleSection: React.FC<{ contracts?: React.ReactNode active?: boolean }> = ({ grants, wallet, contracts, active = true }) => { - const { cycle } = useCycleState({ active }) + const { cycle } = useCycleState() const { history } = useEpochHistory() const { cycleEvents } = useCycleEvents() const { balance } = useDepositBalance() diff --git a/app/src/components/admin/EmergencySection.test.tsx b/app/src/components/admin/EmergencySection.test.tsx index 31e142e0..7af8efe0 100644 --- a/app/src/components/admin/EmergencySection.test.tsx +++ b/app/src/components/admin/EmergencySection.test.tsx @@ -221,15 +221,10 @@ describe("EmergencySection", () => { expect(pauseVaultMock.reset).not.toHaveBeenCalled() }) - it("polls the state only while the surface is active", () => { - const { rerender } = render( + it("polls the vault pause state", () => { + render( , ) - expect(useVaultPausedMock).toHaveBeenLastCalledWith({ poll: false }) - expect(useTokenPausedMock).toHaveBeenLastCalledWith({ poll: false }) - expect(useOracleStateMock).toHaveBeenLastCalledWith({ poll: false }) - - rerender() expect(useVaultPausedMock).toHaveBeenLastCalledWith({ poll: true }) }) }) diff --git a/app/src/components/admin/EmergencySection.tsx b/app/src/components/admin/EmergencySection.tsx index 01622fcb..ba755d02 100644 --- a/app/src/components/admin/EmergencySection.tsx +++ b/app/src/components/admin/EmergencySection.tsx @@ -38,9 +38,9 @@ const EmergencySection: React.FC<{ contracts?: React.ReactNode active?: boolean }> = ({ grants, wallet, contracts, active = true }) => { - const { pause: vault } = useVaultPaused({ poll: active }) - const { pause: token } = useTokenPaused({ poll: active }) - const { oracle } = useOracleState({ poll: active }) + const { pause: vault } = useVaultPaused({ poll: true }) + const { pause: token } = useTokenPaused() + const { oracle } = useOracleState() const pauseVault = usePendingTransaction(usePauseVault()) const unpauseVault = usePendingTransaction(useUnpauseVault()) const raiseRipcord = usePendingTransaction(useRaiseRipcord()) diff --git a/app/src/components/admin/GovernanceSection.test.tsx b/app/src/components/admin/GovernanceSection.test.tsx index a27cc6d4..b6cecb3b 100644 --- a/app/src/components/admin/GovernanceSection.test.tsx +++ b/app/src/components/admin/GovernanceSection.test.tsx @@ -376,7 +376,6 @@ describe("GovernanceSection", () => { ) expect(useGovernanceStateMock).toHaveBeenCalledWith({ contracts: { vault: VAULT, oracle: "CORACLE", token: "CTOKEN" }, - active: true, }) }) diff --git a/app/src/components/admin/GovernanceSection.tsx b/app/src/components/admin/GovernanceSection.tsx index e51a640f..2ea12a05 100644 --- a/app/src/components/admin/GovernanceSection.tsx +++ b/app/src/components/admin/GovernanceSection.tsx @@ -147,7 +147,6 @@ const GovernanceSection: React.FC<{ oracle: addresses.oracle, token: addresses.shareToken, }, - active, }) const flows = useGovernanceFlows() const { inputs, setInputs, onInput } = useRailInputs() diff --git a/app/src/hooks/useComplianceState.ts b/app/src/hooks/useComplianceState.ts index 27fd6921..9dbc5c49 100644 --- a/app/src/hooks/useComplianceState.ts +++ b/app/src/hooks/useComplianceState.ts @@ -54,12 +54,10 @@ export function useComplianceState({ verifier, target, destination, - active = true, }: { verifier: string | null target: string | undefined destination: string | undefined - active?: boolean }): { target: TargetState; destination: Allowance } { const { data } = useQuery({ queryKey: [...complianceReadsKey, verifier, target], @@ -68,7 +66,7 @@ export function useComplianceState({ ? skipToken : () => fetchTargetReads(verifier, target), staleTime: 30_000, - refetchInterval: active ? REFRESH_MS : false, + refetchInterval: REFRESH_MS, }) const { data: destinationAllowance } = useQuery( allowanceQuery(verifier, destination), diff --git a/app/src/hooks/useConfigurationState.test.ts b/app/src/hooks/useConfigurationState.test.ts index 68b96d57..01a3ec0e 100644 --- a/app/src/hooks/useConfigurationState.test.ts +++ b/app/src/hooks/useConfigurationState.test.ts @@ -99,10 +99,4 @@ describe("useConfigurationState", () => { }), ) }) - - it("polls only while active", () => { - renderWithWallet(() => useConfigurationState({ active: false })) - - expect(useCycleReadsMock).toHaveBeenCalledWith(false) - }) }) diff --git a/app/src/hooks/useConfigurationState.ts b/app/src/hooks/useConfigurationState.ts index 2286d35b..67fa52a6 100644 --- a/app/src/hooks/useConfigurationState.ts +++ b/app/src/hooks/useConfigurationState.ts @@ -43,16 +43,16 @@ async function fetchConfigurationReads() { } } -export function useConfigurationState({ - active = true, -}: { active?: boolean } = {}): { configuration: ConfigurationState } { - const cycle = useCycleReads(active) +export function useConfigurationState(): { + configuration: ConfigurationState +} { + const cycle = useCycleReads() const { authorities, isPending } = useVaultAuthorities() const { data: reads } = useQuery({ queryKey: configurationReadsKey, queryFn: fetchConfigurationReads, staleTime: STALE_MS, - refetchInterval: active ? REFRESH_MS : false, + refetchInterval: REFRESH_MS, }) if (cycle === undefined || reads === undefined || isPending) diff --git a/app/src/hooks/useCycleState.test.ts b/app/src/hooks/useCycleState.test.ts index b8591801..ee5f77f5 100644 --- a/app/src/hooks/useCycleState.test.ts +++ b/app/src/hooks/useCycleState.test.ts @@ -363,28 +363,6 @@ describe("useCycleState", () => { } }) - it("stops refetching the cycle reads while inactive and resumes when active again", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }) - try { - let active = false - const { result, rerender } = renderWithWallet(() => - useCycleState({ active }), - ) - await waitFor(() => expect(result.current.cycle.status).toBe("ready")) - const before = oracleMock.state.mock.calls.length - - await vi.advanceTimersByTimeAsync(31_000) - expect(oracleMock.state.mock.calls).toHaveLength(before) - - active = true - rerender() - await vi.advanceTimersByTimeAsync(16_000) - expect(oracleMock.state.mock.calls.length).toBeGreaterThan(before) - } finally { - vi.useRealTimers() - } - }) - it("reads no balance when no custodian is set", async () => { vaultMock.custodian.mockResolvedValue({ result: null }) diff --git a/app/src/hooks/useCycleState.ts b/app/src/hooks/useCycleState.ts index 1b807a3f..ed3e02e5 100644 --- a/app/src/hooks/useCycleState.ts +++ b/app/src/hooks/useCycleState.ts @@ -262,24 +262,24 @@ async function fetchCustodianBalance( return toAmount(balance) } -export function useCycleReads(active: boolean): CycleReads | undefined { +export function useCycleReads(): CycleReads | undefined { const { data } = useQuery({ queryKey: cycleReadsKey, queryFn: fetchCycleReads, staleTime: STALE_MS, - refetchInterval: active ? REFRESH_MS : false, + refetchInterval: REFRESH_MS, }) return data } -export function useCycleState({ active = true }: { active?: boolean } = {}): { +export function useCycleState(): { cycle: CycleState } { const { history } = useEpochHistory() const { figures, isPending: figuresPending } = useVaultFigures() const { pause } = useVaultPaused() const { authorities, isPending: authoritiesPending } = useVaultAuthorities() - const reads = useCycleReads(active) + const reads = useCycleReads() const custodian = authorities?.custodian ?? { kind: "unreadable" } const custodianAddress = custodian.kind === "set" ? custodian.address : null const { data: custodianBalance, isPending: balancePending } = useQuery({ diff --git a/app/src/hooks/useGovernanceState.ts b/app/src/hooks/useGovernanceState.ts index 8b673c24..815c4835 100644 --- a/app/src/hooks/useGovernanceState.ts +++ b/app/src/hooks/useGovernanceState.ts @@ -58,15 +58,15 @@ async function fetchUpgradeProposal(): Promise { } } -function useUpgrades(active: boolean): Upgrades { - const cycle = useCycleReads(active) - const { configuration } = useConfigurationState({ active }) - const { pause } = useVaultPaused({ poll: active }) +function useUpgrades(): Upgrades { + const cycle = useCycleReads() + const { configuration } = useConfigurationState() + const { pause } = useVaultPaused({ poll: true }) const { data } = useQuery({ queryKey: upgradeProposalKey, queryFn: fetchUpgradeProposal, staleTime: STALE_MS, - refetchInterval: active ? REFRESH_MS : false, + refetchInterval: REFRESH_MS, }) if ( cycle === undefined || @@ -104,16 +104,14 @@ function toHandovers( export function useGovernanceState({ contracts, - active = true, }: { contracts: Record | undefined - active?: boolean }): { handovers: Handovers; upgrades: Upgrades } { const { data, isError } = useQuery({ ...pendingAdminsQuery(contracts), - refetchInterval: active ? REFRESH_MS : false, + refetchInterval: REFRESH_MS, }) - const upgrades = useUpgrades(active) + const upgrades = useUpgrades() let handovers: Handovers = { status: "checking" } if (data !== undefined && contracts !== undefined) handovers = toHandovers(data, contracts) diff --git a/app/src/hooks/useOracleState.ts b/app/src/hooks/useOracleState.ts index df046442..183f39e9 100644 --- a/app/src/hooks/useOracleState.ts +++ b/app/src/hooks/useOracleState.ts @@ -34,14 +34,14 @@ async function fetchOracleState(): Promise { return classifyOracle(read) } -export function useOracleState({ poll = false }: { poll?: boolean } = {}): { +export function useOracleState(): { oracle: OracleStatus } { const { data } = useQuery({ queryKey: oracleStateKey, queryFn: fetchOracleState, staleTime: 30_000, - refetchInterval: poll ? PAUSE_REFRESH_MS : false, + refetchInterval: PAUSE_REFRESH_MS, }) return { oracle: data ?? "checking" } diff --git a/app/src/hooks/useTokenPaused.ts b/app/src/hooks/useTokenPaused.ts index 2a5d42b7..c2bc7835 100644 --- a/app/src/hooks/useTokenPaused.ts +++ b/app/src/hooks/useTokenPaused.ts @@ -14,14 +14,14 @@ async function fetchTokenPause(): Promise { return classifyPause(read) } -export function useTokenPaused({ poll = false }: { poll?: boolean } = {}): { +export function useTokenPaused(): { pause: PauseState } { const { data } = useQuery({ queryKey: tokenPausedKey, queryFn: fetchTokenPause, staleTime: 30_000, - refetchInterval: poll ? PAUSE_REFRESH_MS : false, + refetchInterval: PAUSE_REFRESH_MS, }) return { pause: data ?? "checking" } diff --git a/app/src/hooks/useVaultPaused.test.ts b/app/src/hooks/useVaultPaused.test.ts index f45ab008..6b72e97f 100644 --- a/app/src/hooks/useVaultPaused.test.ts +++ b/app/src/hooks/useVaultPaused.test.ts @@ -1,13 +1,30 @@ import { type ContractRead } from "@stellar-scaffold/app-lib" -import { describe, expect, it, vi } from "vitest" -import { classifyPause } from "./useVaultPaused" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { act, renderHook, waitFor } from "@testing-library/react" +import { createElement, type ReactNode } from "react" +import { afterEach, describe, expect, it, vi } from "vitest" +import { + classifyPause, + PAUSE_REFRESH_MS, + useVaultPaused, +} from "./useVaultPaused" + +const { pausedMock } = vi.hoisted(() => ({ + pausedMock: vi.fn(async () => ({ result: false })), +})) vi.mock("../config/clients", () => ({ - asyncVault: async () => ({ - paused: async () => ({}), - }), + asyncVault: async () => ({ paused: pausedMock }), })) +const setPageVisibility = (state: DocumentVisibilityState) => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => state, + }) + document.dispatchEvent(new Event("visibilitychange")) +} + const paused: ContractRead = { kind: "value", value: true } const open: ContractRead = { kind: "value", value: false } const contractError: ContractRead = { @@ -26,3 +43,31 @@ describe("classifyPause", () => { expect(classifyPause(read)).toBe(expected) }) }) + +describe("useVaultPaused", () => { + afterEach(() => { + setPageVisibility("visible") + vi.useRealTimers() + }) + + it("stops polling while the browser tab is hidden and resumes once shown", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const client = new QueryClient({ + defaultOptions: { + queries: { refetchOnWindowFocus: false, retry: false }, + }, + }) + const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children) + renderHook(() => useVaultPaused({ poll: true }), { wrapper }) + await waitFor(() => expect(pausedMock).toHaveBeenCalledTimes(1)) + + act(() => setPageVisibility("hidden")) + await act(() => vi.advanceTimersByTimeAsync(PAUSE_REFRESH_MS * 3)) + expect(pausedMock).toHaveBeenCalledTimes(1) + + act(() => setPageVisibility("visible")) + await act(() => vi.advanceTimersByTimeAsync(PAUSE_REFRESH_MS)) + expect(pausedMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/app/src/pages/AdminPanel.test.tsx b/app/src/pages/AdminPanel.test.tsx index 6b95d45f..e8906dce 100644 --- a/app/src/pages/AdminPanel.test.tsx +++ b/app/src/pages/AdminPanel.test.tsx @@ -1,4 +1,10 @@ import { + QueryClient, + QueryClientProvider, + useQuery, +} from "@tanstack/react-query" +import { + act, cleanup, fireEvent, render, @@ -7,7 +13,7 @@ import { } from "@testing-library/react" import type React from "react" import { MemoryRouter, Route, Routes } from "react-router-dom" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import type * as UseAdminAuthority from "../hooks/useAdminAuthority" import { type AdminAuthority, @@ -16,10 +22,22 @@ import { } from "../hooks/useAdminAuthority" import AdminPanel from "./AdminPanel" -const { useAdminAuthorityMock, useWalletMock } = vi.hoisted(() => ({ - useAdminAuthorityMock: vi.fn(), - useWalletMock: vi.fn(), -})) +const { useAdminAuthorityMock, useWalletMock, readSurfaceMock } = vi.hoisted( + () => ({ + useAdminAuthorityMock: vi.fn(), + useWalletMock: vi.fn(), + readSurfaceMock: vi.fn<(surface: string) => Promise>(), + }), +) + +const SurfaceRead: React.FC<{ surface: string }> = ({ surface }) => { + const { data } = useQuery({ + queryKey: ["surface", surface], + queryFn: () => readSurfaceMock(surface), + refetchInterval: 15_000, + }) + return

{data ?? "Reading…"}

+} vi.mock("../hooks/useAdminAuthority", async (importOriginal) => { const actual = await importOriginal() @@ -40,6 +58,7 @@ vi.mock("../components/admin/CycleSection", () => ({ }) => (

Cycle surface for {grants.map((grant) => grant.role).join(", ")}

+ {contracts}
@@ -66,6 +85,7 @@ vi.mock("../components/admin/ComplianceSection", () => ({

Compliance surface for {grants.map((grant) => grant.role).join(", ")}

+ {contracts} ), @@ -179,17 +199,41 @@ const authority = (overrides: Partial): AdminAuthority => ({ ...overrides, }) +let queryClient = new QueryClient() + const routedPanel = (path: string) => ( - - - } /> - } /> - - + + + + } /> + } /> + + + ) const renderAt = (path: string) => render(routedPanel(path)) +const holdCycleAndCompliance = () => { + const manager = holderOf("vault manager") + const compliance = holderOf("share-token compliance") + useAdminAuthorityMock.mockReturnValue( + authority({ + status: "ready", + surfaces: new Set(["cycle", "compliance"]), + grantedBy: { + ...emptyGrantedBy, + cycle: [manager], + compliance: [compliance], + }, + grants: [manager, compliance], + }), + ) +} + +const readSurfaces = () => + readSurfaceMock.mock.calls.map(([surface]) => surface) + const currentTab = () => screen .getAllByRole("link") @@ -198,6 +242,15 @@ const currentTab = () => describe("AdminPanel", () => { beforeEach(() => { useWalletMock.mockReturnValue({ address: connectedAddress }) + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + readSurfaceMock.mockReset() + readSurfaceMock.mockImplementation(async (surface) => `${surface} read`) + }) + + afterEach(() => { + vi.useRealTimers() }) it("tells a disconnected wallet to connect, with no tabs and no identity line", () => { @@ -770,4 +823,53 @@ describe("AdminPanel", () => { "250", ) }) + + it("reads only the open tab's data on a cold load", async () => { + holdCycleAndCompliance() + renderAt("/admin/cycle") + + expect(await screen.findByText("cycle read")).toBeTruthy() + expect(readSurfaces()).toEqual(["cycle"]) + }) + + it("loads a tab's data when it is opened, showing it as reading until it arrives", async () => { + holdCycleAndCompliance() + let arrive: (value: string) => void = () => undefined + readSurfaceMock.mockImplementation(async (surface) => + surface === "compliance" + ? new Promise((resolve) => { + arrive = resolve + }) + : `${surface} read`, + ) + renderAt("/admin/cycle") + await screen.findByText("cycle read") + expect(readSurfaces()).not.toContain("compliance") + + fireEvent.click(screen.getByRole("link", { name: "Compliance" })) + + const compliance = screen.getByRole("region", { name: "Compliance" }) + expect(within(compliance).getByText("Reading…")).toBeTruthy() + expect(readSurfaces()).toEqual(["cycle", "compliance"]) + + act(() => arrive("compliance read")) + + expect(await within(compliance).findByText("compliance read")).toBeTruthy() + }) + + it("stops reading a tab while another tab is shown", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + holdCycleAndCompliance() + renderAt("/admin/cycle") + await screen.findByText("cycle read") + + fireEvent.click(screen.getByRole("link", { name: "Compliance" })) + await screen.findByText("compliance read") + readSurfaceMock.mockClear() + + await act(() => vi.advanceTimersByTimeAsync(31_000)) + + expect(readSurfaces()).not.toContain("cycle") + expect(readSurfaces()).toContain("compliance") + }) }) diff --git a/app/src/pages/AdminPanel.tsx b/app/src/pages/AdminPanel.tsx index 4f71fcd7..296ebea4 100644 --- a/app/src/pages/AdminPanel.tsx +++ b/app/src/pages/AdminPanel.tsx @@ -1,5 +1,5 @@ import { shortAddress } from "@stellar-scaffold/app-lib" -import React, { useEffect, useRef } from "react" +import React, { Activity, useEffect, useRef } from "react" import { Navigate, useParams } from "react-router-dom" import ComplianceSection from "../components/admin/ComplianceSection" import ConfigurationSection from "../components/admin/ConfigurationSection" @@ -180,15 +180,17 @@ const HeldSurfaces: React.FC<{ > {SURFACE_LABELS[surface]} - + + + ))}