Skip to content

Commit ab2661b

Browse files
authored
Merge pull request #263 from Phantomcall/feat/react-query-config-issue-226
feat: React Query configuration — stale times, retry policy, backgrou…
2 parents 983c0e1 + f4eb80f commit ab2661b

8 files changed

Lines changed: 381 additions & 10 deletions

File tree

frontend/README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,48 @@ On failure, the Playwright job uploads traces and screenshots to the
9090
npx playwright show-report path/to/downloaded/playwright-report
9191
```
9292

93+
## React Query configuration
94+
95+
All React Query settings are centralized in `src/lib/query/queryClientConfig.ts`. Per-component overrides are discouraged — add a new named constant to `STALE_TIMES` instead.
96+
97+
### Stale times
98+
99+
| Query type | Stale time | Rationale |
100+
|---|---|---|
101+
| `policies` | 30 s | Changes only on user-initiated transactions |
102+
| `claims` | 10 s | Any holder can file; moderate freshness needed |
103+
| `votes` | 5 s | Active voting windows are time-sensitive |
104+
| `ledger` | 5 s | New ledger every ~5 s |
105+
| `default` | 15 s | Catch-all for uncategorized queries |
106+
107+
### Retry policy
108+
109+
- Max **3 retries** for transient errors (network failures, 5xx, 429).
110+
- **No retry** for 4xx client errors (except 429 with Retry-After).
111+
- Exponential backoff: 1 s → 2 s → 4 s, capped at 30 s.
112+
113+
### Background refetch
114+
115+
- `refetchOnWindowFocus: false` globally. Enable per-query only for time-sensitive queries (e.g. active votes) by passing `refetchOnWindowFocus: true`.
116+
- `refetchOnReconnect: true` — always resync after coming back online.
117+
- `refetchIntervalInBackground: false` — respects the Page Visibility API; no polling on hidden tabs.
118+
119+
### Offline support
120+
121+
Use `useNetworkAwareQuery` (`src/lib/query/useNetworkAwareQuery.ts`) instead of `useQuery` for any query that uses `refetchInterval`. It automatically pauses interval-based refetch when the browser is offline or the tab is hidden, preventing battery drain on mobile.
122+
123+
```ts
124+
import { useNetworkAwareQuery, STALE_TIMES } from '@/lib/query';
125+
126+
const { data } = useNetworkAwareQuery({
127+
queryKey: ['votes', claimId],
128+
queryFn: () => fetchVoteTallies(claimId),
129+
staleTime: STALE_TIMES.votes,
130+
refetchInterval: 5_000,
131+
refetchOnWindowFocus: true, // enabled for time-sensitive vote data
132+
});
133+
```
134+
93135
## Analytics
94136

95137
Analytics uses [Plausible](https://plausible.io) (cookieless, no PII).

frontend/package.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,20 @@
1414
"check-docs": "node scripts/check-docs-links.js"
1515
},
1616
"dependencies": {
17-
"@creit.tech/stellar-wallets-kit": "^2.0.1",
17+
"@creit.tech/stellar-wallets-kit": "^1.3.0",
1818
"@hookform/resolvers": "^5.2.2",
1919
"@radix-ui/react-dialog": "^1.1.15",
2020
"@radix-ui/react-label": "^2.1.8",
2121
"@radix-ui/react-select": "^2.2.6",
2222
"@radix-ui/react-slot": "^1.2.4",
2323
"@radix-ui/react-toast": "^1.2.15",
24+
"@tanstack/react-query": "^5.80.7",
2425
"class-variance-authority": "^0.7.1",
2526
"clsx": "^2.1.1",
2627
"lucide-react": "^1.7.0",
2728
"next": "^15.5.14",
2829
"next-intl": "^3.26.5",
29-
"next-mdx-remote": "^5.0.0",
30+
"next-mdx-remote": "^6.0.0",
3031
"react": "^19.0.0",
3132
"react-dom": "^19.0.0",
3233
"react-hook-form": "^7.72.0",
@@ -35,9 +36,11 @@
3536
"zod": "^4.3.6"
3637
},
3738
"devDependencies": {
39+
"@axe-core/playwright": "^4.10.1",
3840
"@next/bundle-analyzer": "^15.2.3",
3941
"@playwright/test": "^1.49.0",
4042
"@tailwindcss/postcss": "^4.2.2",
43+
"@tanstack/react-query-devtools": "^5.80.7",
4144
"@testing-library/jest-dom": "^6.9.1",
4245
"@testing-library/react": "^16.3.2",
4346
"@testing-library/user-event": "^14.6.1",
@@ -55,8 +58,6 @@
5558
"postcss": "^8.5.8",
5659
"tailwindcss": "^4.2.2",
5760
"ts-jest": "^29.4.6",
58-
"typescript": "^5",
59-
"@axe-core/playwright": "^4.10.1",
60-
"@playwright/test": "^1.49.0"
61+
"typescript": "^5"
6162
}
6263
}

frontend/src/app/layout.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import "./globals.css";
55
import { ThemeProvider } from "@/components/theme-provider";
66
import { Toaster } from "@/components/ui/toaster";
77
import { inter, ibmPlexMono } from "@/lib/fonts";
8+
import { QueryProvider } from "@/lib/query";
89

910
export const viewport: Viewport = {
1011
width: "device-width",
@@ -81,11 +82,13 @@ export default async function RootLayout({ children }: { children: React.ReactNo
8182
</head>
8283
<body className="font-sans antialiased">
8384
<ThemeProvider defaultTheme="system" storageKey="niffyinsur-theme">
84-
<WalletProvider>
85-
{children}
86-
<NetworkMismatchModal />
87-
<Toaster />
88-
</WalletProvider>
85+
<QueryProvider>
86+
<WalletProvider>
87+
{children}
88+
<NetworkMismatchModal />
89+
<Toaster />
90+
</WalletProvider>
91+
</QueryProvider>
8992
</ThemeProvider>
9093
</body>
9194
</html>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { QueryClientProvider } from '@tanstack/react-query';
5+
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
6+
import { createQueryClient } from './queryClientConfig';
7+
8+
interface QueryProviderProps {
9+
children: React.ReactNode;
10+
}
11+
12+
/**
13+
* Wraps the app with a QueryClientProvider using the centralized config.
14+
* The QueryClient is created once per component mount (useState initializer)
15+
* so it is stable across re-renders but not shared across requests in SSR.
16+
*
17+
* DevTools are included only in development builds (tree-shaken in production).
18+
*/
19+
export function QueryProvider({ children }: QueryProviderProps) {
20+
const [queryClient] = useState(() => createQueryClient());
21+
22+
return (
23+
<QueryClientProvider client={queryClient}>
24+
{children}
25+
{process.env.NODE_ENV === 'development' && (
26+
<ReactQueryDevtools initialIsOpen={false} />
27+
)}
28+
</QueryClientProvider>
29+
);
30+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/// <reference types="jest" />
2+
import { createQueryClient, STALE_TIMES } from '../queryClientConfig';
3+
4+
describe('createQueryClient', () => {
5+
it('returns a QueryClient instance', () => {
6+
const client = createQueryClient();
7+
expect(client).toBeDefined();
8+
expect(typeof client.getQueryCache).toBe('function');
9+
});
10+
11+
it('default staleTime matches STALE_TIMES.default', () => {
12+
const client = createQueryClient();
13+
const defaults = client.getDefaultOptions();
14+
expect(defaults.queries?.staleTime).toBe(STALE_TIMES.default);
15+
});
16+
17+
it('does not retry on 4xx errors (except 429)', () => {
18+
const client = createQueryClient();
19+
const retry = client.getDefaultOptions().queries?.retry;
20+
if (typeof retry !== 'function') throw new Error('retry should be a function');
21+
22+
const err400 = { status: 400 };
23+
const err401 = { status: 401 };
24+
const err404 = { status: 404 };
25+
const err429 = { status: 429 };
26+
const err500 = { status: 500 };
27+
const networkErr = new Error('Failed to fetch');
28+
29+
expect(retry(0, err400)).toBe(false);
30+
expect(retry(0, err401)).toBe(false);
31+
expect(retry(0, err404)).toBe(false);
32+
// 429 is retryable
33+
expect(retry(0, err429)).toBe(true);
34+
// 5xx is retryable up to 3 attempts
35+
expect(retry(0, err500)).toBe(true);
36+
expect(retry(2, err500)).toBe(true);
37+
expect(retry(3, err500)).toBe(false);
38+
// Network errors are retryable
39+
expect(retry(0, networkErr)).toBe(true);
40+
});
41+
42+
it('retryDelay uses exponential backoff capped at 30s', () => {
43+
const client = createQueryClient();
44+
const retryDelay = client.getDefaultOptions().queries?.retryDelay;
45+
if (typeof retryDelay !== 'function') throw new Error('retryDelay should be a function');
46+
47+
expect(retryDelay(0, new Error())).toBe(1_000);
48+
expect(retryDelay(1, new Error())).toBe(2_000);
49+
expect(retryDelay(2, new Error())).toBe(4_000);
50+
// Capped at 30s
51+
expect(retryDelay(10, new Error())).toBe(30_000);
52+
});
53+
54+
it('refetchOnWindowFocus is false by default', () => {
55+
const client = createQueryClient();
56+
expect(client.getDefaultOptions().queries?.refetchOnWindowFocus).toBe(false);
57+
});
58+
59+
it('refetchOnReconnect is true', () => {
60+
const client = createQueryClient();
61+
expect(client.getDefaultOptions().queries?.refetchOnReconnect).toBe(true);
62+
});
63+
64+
it('refetchIntervalInBackground is false', () => {
65+
const client = createQueryClient();
66+
expect(client.getDefaultOptions().queries?.refetchIntervalInBackground).toBe(false);
67+
});
68+
});
69+
70+
describe('STALE_TIMES', () => {
71+
it('votes stale time is shortest (most time-sensitive)', () => {
72+
expect(STALE_TIMES.votes).toBeLessThan(STALE_TIMES.claims);
73+
expect(STALE_TIMES.claims).toBeLessThan(STALE_TIMES.policies);
74+
});
75+
76+
it('all values are positive numbers', () => {
77+
Object.values(STALE_TIMES).forEach((v) => {
78+
expect(v).toBeGreaterThan(0);
79+
});
80+
});
81+
});

frontend/src/lib/query/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { createQueryClient, STALE_TIMES } from './queryClientConfig';
2+
export { QueryProvider } from './QueryProvider';
3+
export { useNetworkAwareQuery } from './useNetworkAwareQuery';
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Centralized React Query configuration for NiffyInsur.
3+
*
4+
* Rationale
5+
* ---------
6+
* Default React Query settings (staleTime: 0, retry: 3 with fixed backoff)
7+
* are poorly suited to a blockchain-backed app where:
8+
* - On-chain data changes infrequently (new ledger every ~5 s, indexer lags ~15 s).
9+
* - RPC/indexer errors are transient — aggressive retries waste bandwidth.
10+
* - 4xx errors (bad request, unauthorized) are never transient and must not retry.
11+
* - Background refetch on a hidden tab drains mobile battery.
12+
*
13+
* Stale times (per query type)
14+
* ----------------------------
15+
* policies 30 s — policy state changes only on user action (renew/terminate)
16+
* claims 10 s — claims can be filed by any holder; moderate freshness needed
17+
* votes 5 s — active voting windows are time-sensitive
18+
* ledger 5 s — latest ledger advances every ~5 s
19+
* default 15 s — catch-all for uncategorized queries
20+
*
21+
* Retry policy
22+
* ------------
23+
* - Max 3 attempts for transient errors (network, 5xx, 429).
24+
* - No retry for 4xx client errors (except 429 Retry-After).
25+
* - Exponential backoff: 1 s → 2 s → 4 s (capped at 30 s).
26+
*
27+
* Background refetch
28+
* ------------------
29+
* - refetchOnWindowFocus: false globally; enabled per-query only for votes.
30+
* - refetchOnReconnect: true — always resync after coming back online.
31+
* - refetchIntervalInBackground: false — respects Page Visibility API.
32+
*/
33+
34+
import { QueryClient } from '@tanstack/react-query';
35+
36+
// ---------------------------------------------------------------------------
37+
// Stale time constants — import these in useQuery calls for consistency.
38+
// ---------------------------------------------------------------------------
39+
40+
export const STALE_TIMES = {
41+
/** Policy list / detail — changes only on user-initiated transactions. */
42+
policies: 30_000,
43+
/** Claims list — any holder can file; moderate freshness. */
44+
claims: 10_000,
45+
/** Vote tallies — time-sensitive during open voting windows. */
46+
votes: 5_000,
47+
/** Latest ledger sequence — advances every ~5 s. */
48+
ledger: 5_000,
49+
/** Default catch-all. */
50+
default: 15_000,
51+
} as const;
52+
53+
// ---------------------------------------------------------------------------
54+
// Retry predicate — never retry 4xx (except 429).
55+
// ---------------------------------------------------------------------------
56+
57+
interface MaybeHttpError {
58+
status?: number;
59+
}
60+
61+
function isNonRetryable(error: unknown): boolean {
62+
const status = (error as MaybeHttpError)?.status;
63+
if (typeof status !== 'number') return false;
64+
// 4xx except 429 (rate limit) are client errors — retrying won't help.
65+
return status >= 400 && status < 500 && status !== 429;
66+
}
67+
68+
function retryDelay(attempt: number): number {
69+
// Exponential backoff: 1s, 2s, 4s — capped at 30s.
70+
return Math.min(1_000 * Math.pow(2, attempt), 30_000);
71+
}
72+
73+
// ---------------------------------------------------------------------------
74+
// QueryClient factory — call once at app root.
75+
// ---------------------------------------------------------------------------
76+
77+
export function createQueryClient(): QueryClient {
78+
return new QueryClient({
79+
defaultOptions: {
80+
queries: {
81+
staleTime: STALE_TIMES.default,
82+
// 3 retries for transient errors; skip for 4xx.
83+
retry: (failureCount: number, error: unknown) => {
84+
if (isNonRetryable(error)) return false;
85+
return failureCount < 3;
86+
},
87+
retryDelay: (attempt: number) => retryDelay(attempt),
88+
// Disable window-focus refetch globally; enable per-query for votes.
89+
refetchOnWindowFocus: false,
90+
// Always resync after reconnect.
91+
refetchOnReconnect: true,
92+
// Never refetch in a background tab — respects Page Visibility API.
93+
refetchIntervalInBackground: false,
94+
},
95+
mutations: {
96+
retry: (failureCount: number, error: unknown) => {
97+
if (isNonRetryable(error)) return false;
98+
return failureCount < 2;
99+
},
100+
retryDelay: (attempt: number) => retryDelay(attempt),
101+
},
102+
},
103+
});
104+
}

0 commit comments

Comments
 (0)