From a93226bc5cb3b89c5e7e599b497badec9153763d Mon Sep 17 00:00:00 2001 From: chonilius Date: Tue, 25 Aug 2026 14:12:09 +0100 Subject: [PATCH 1/4] fix(hooks): stabilize useKeyboardShortcut's effect dependency on modifiers (closes #446) The keydown-listener effect depended on [key, modifiers], but callers that omit modifiers rely on the `= {}` default, which is a new object reference every render -- tearing down and re-registering the window listener on every render cycle instead of once. Depend on the individual modifier flags (ctrl/shift/alt/meta) rather than the modifiers object itself, so the effect's dependency array compares by value instead of reference. Adds a regression test spying on addEventListener/removeEventListener across re-renders, asserting the listener is registered once and never torn down. --- src/__tests__/useKeyboardShortcut.test.tsx | 25 ++++++++++++++++++++++ src/hooks/useKeyboardShortcut.ts | 19 +++++++++++----- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/__tests__/useKeyboardShortcut.test.tsx b/src/__tests__/useKeyboardShortcut.test.tsx index 14f82bf..4c742cf 100644 --- a/src/__tests__/useKeyboardShortcut.test.tsx +++ b/src/__tests__/useKeyboardShortcut.test.tsx @@ -63,6 +63,31 @@ describe('useKeyboardShortcut', () => { expect(secondHandler).toHaveBeenCalledTimes(1); }); + it('does not re-register the keydown listener on re-render when modifiers is omitted', () => { + // Regression test: `modifiers = {}` is a new object reference every + // render, so a naive [key, modifiers] dependency array tears down and + // re-registers the listener every render cycle. + const addSpy = vi.spyOn(window, 'addEventListener'); + const removeSpy = vi.spyOn(window, 'removeEventListener'); + const handler = vi.fn(); + + const hook = renderHook(() => useKeyboardShortcut('k', handler)); + const initialAddCalls = addSpy.mock.calls.filter((c) => c[0] === 'keydown').length; + + hook.rerender(); + hook.rerender(); + hook.rerender(); + + const addCallsAfterRerenders = addSpy.mock.calls.filter((c) => c[0] === 'keydown').length; + const removeCallsAfterRerenders = removeSpy.mock.calls.filter((c) => c[0] === 'keydown').length; + + expect(addCallsAfterRerenders).toBe(initialAddCalls); + expect(removeCallsAfterRerenders).toBe(0); + + addSpy.mockRestore(); + removeSpy.mockRestore(); + }); + it('removes the listener on unmount', () => { const handler = vi.fn(); const hook = renderHook(() => useKeyboardShortcut('k', handler)); diff --git a/src/hooks/useKeyboardShortcut.ts b/src/hooks/useKeyboardShortcut.ts index a03a2d5..5aff25f 100644 --- a/src/hooks/useKeyboardShortcut.ts +++ b/src/hooks/useKeyboardShortcut.ts @@ -13,18 +13,27 @@ export function useKeyboardShortcut( const handlerRef = useRef(handler); useEffect(() => { handlerRef.current = handler; }); + // Depend on the individual modifier flags rather than the `modifiers` + // object itself: callers that omit modifiers rely on the `= {}` default, + // which is a new object reference every render, so depending on the + // object would tear down and re-register the listener every render cycle. + const ctrl = modifiers.ctrl; + const shift = modifiers.shift; + const alt = modifiers.alt; + const meta = modifiers.meta; + useEffect(() => { function onKeyDown(e: KeyboardEvent) { if (e.key.toLowerCase() !== key.toLowerCase()) return; - if (modifiers.ctrl && !e.ctrlKey) return; - if (modifiers.shift && !e.shiftKey) return; - if (modifiers.alt && !e.altKey) return; - if (modifiers.meta && !e.metaKey) return; + if (ctrl && !e.ctrlKey) return; + if (shift && !e.shiftKey) return; + if (alt && !e.altKey) return; + if (meta && !e.metaKey) return; e.preventDefault(); e.stopPropagation(); handlerRef.current(); } window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [key, modifiers]); + }, [key, ctrl, shift, alt, meta]); } From b3b7ed9cbbfd4304d2dde038e9358f342546e839 Mon Sep 17 00:00:00 2001 From: chonilius Date: Tue, 25 Aug 2026 14:15:19 +0100 Subject: [PATCH 2/4] fix(pools): guard against null/undefined pool.apy (closes #447) (pool.apy * 100).toFixed(1) throws a TypeError if the API returns apy: null -- network responses may not conform to the PoolStats type. DepositModal.tsx already guards against this with the same pattern; the pools listing page didn't. --- src/app/pools/page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/pools/page.tsx b/src/app/pools/page.tsx index dfca12f..2a45c9c 100644 --- a/src/app/pools/page.tsx +++ b/src/app/pools/page.tsx @@ -100,7 +100,9 @@ export default function PoolsPage() {
APY
-
{(pool.apy * 100).toFixed(1)}%
+
+ {pool.apy != null ? `${(pool.apy * 100).toFixed(1)}%` : '—'} +
From b842fce6392f3e99b611a0b1894c9ce59f7a826c Mon Sep 17 00:00:00 2001 From: chonilius Date: Tue, 25 Aug 2026 14:21:38 +0100 Subject: [PATCH 3/4] feat(claims): add auto-refresh countdown and pause/resume (closes #438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claims page auto-refreshed every 15s (CLAIMS_REFRESH_INTERVAL_MS) with no visual indicator of when the next refresh would happen and no way to pause it while reviewing a specific claim. Extended useClaims to track lastRefreshedAt and a paused flag: the polling interval and the visibilitychange handler both skip firing while paused (checked via a ref so toggling pause doesn't need to tear down/re-register the interval). Derived secondsUntilRefresh and secondsSinceRefresh from lastRefreshedAt, ticked once a second by a small separate display-only interval. Wired a Pause/Resume button next to Refresh and a "Last refreshed Xs ago · next in Ys" line (or "auto-refresh paused" when paused) into the page. Adds tests: no poll fires while paused, polling resumes after toggling again, and secondsUntilRefresh counts down correctly. --- src/__tests__/useClaims.test.tsx | 84 ++++++++++++++++++++++++++++++++ src/app/claims/page.tsx | 27 +++++++++- src/hooks/useClaims.ts | 48 +++++++++++++++++- 3 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/useClaims.test.tsx diff --git a/src/__tests__/useClaims.test.tsx b/src/__tests__/useClaims.test.tsx new file mode 100644 index 0000000..aa99dc6 --- /dev/null +++ b/src/__tests__/useClaims.test.tsx @@ -0,0 +1,84 @@ +import { act } from 'react'; +import { useClaims } from '../hooks/useClaims'; +import { renderHook, flushMicrotasks } from './renderHook'; +import type { Claim } from '../types'; + +const { fetchUserClaims } = vi.hoisted(() => ({ + fetchUserClaims: vi.fn(), +})); + +vi.mock('@/lib/api', () => ({ fetchUserClaims })); + +function makeClaim(overrides: Partial = {}): Claim { + return { + id: 'claim-1', + policyId: 'policy-1', + claimant: 'GABCDEF1234567890', + triggerMet: true, + status: 'Pending', + submittedAt: 1_720_000_000, + processedAt: null, + ...overrides, + }; +} + +describe('useClaims', () => { + beforeEach(() => { + fetchUserClaims.mockReset(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not poll again while paused', async () => { + fetchUserClaims.mockResolvedValue([makeClaim()]); + + const hook = renderHook(() => useClaims('GWALLET')); + await flushMicrotasks(); + expect(fetchUserClaims).toHaveBeenCalledTimes(1); + + act(() => hook.current.togglePause()); + expect(hook.current.paused).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000); + }); + + // Still only the initial call -- no poll fired while paused. + expect(fetchUserClaims).toHaveBeenCalledTimes(1); + }); + + it('resumes polling after togglePause is called again', async () => { + fetchUserClaims.mockResolvedValue([makeClaim()]); + + const hook = renderHook(() => useClaims('GWALLET')); + await flushMicrotasks(); + + act(() => hook.current.togglePause()); + act(() => hook.current.togglePause()); + expect(hook.current.paused).toBe(false); + + await act(async () => { + await vi.advanceTimersByTimeAsync(15_000); + }); + + expect(fetchUserClaims).toHaveBeenCalledTimes(2); + }); + + it('counts down secondsUntilRefresh toward zero after a successful load', async () => { + fetchUserClaims.mockResolvedValue([makeClaim()]); + + const hook = renderHook(() => useClaims('GWALLET')); + await flushMicrotasks(); + + expect(hook.current.secondsUntilRefresh).toBe(15); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + + expect(hook.current.secondsUntilRefresh).toBe(10); + }); +}); diff --git a/src/app/claims/page.tsx b/src/app/claims/page.tsx index 7afcbc4..d20833a 100644 --- a/src/app/claims/page.tsx +++ b/src/app/claims/page.tsx @@ -12,7 +12,16 @@ import { downloadClaimsCSV, downloadClaimsJSON } from '@/lib/claimsExport'; export default function ClaimsPage() { const { address, connected } = useWallet(); - const { claims, loading, error, refetch } = useClaims(address); + const { + claims, + loading, + error, + refetch, + paused, + togglePause, + secondsUntilRefresh, + secondsSinceRefresh, + } = useClaims(address); const [refreshing, setRefreshing] = useState(false); const [exportOpen, setExportOpen] = useState(false); @@ -77,6 +86,13 @@ export default function ClaimsPage() { )} )} +