Skip to content

Commit cd2c769

Browse files
committed
Add code review agents.
1 parent dee8bbf commit cd2c769

13 files changed

+2606
-0
lines changed

.cursor/agents/review-async.md

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
---
2+
name: review-async
3+
description: Reviews code for async patterns including timers, data loading, and background services. Use when reviewing files with setInterval, async functions, or service components.
4+
---
5+
6+
Review the branch/pull request in context for async pattern conventions.
7+
8+
## Context Expected
9+
10+
You will receive:
11+
- Repository name
12+
- Branch name
13+
- List of changed files to review
14+
15+
## How to Review
16+
17+
1. Read the changed files provided in context
18+
2. Look for setInterval, async data loading, background work
19+
3. Verify async patterns follow conventions
20+
4. Report findings with specific file:line references
21+
22+
---
23+
24+
## Never Use setInterval
25+
26+
Always use `makePeriodicTask` instead, especially when async work is involved:
27+
28+
```typescript
29+
// Incorrect
30+
setInterval(() => fetchData(), 5000)
31+
32+
// Correct
33+
const task = makePeriodicTask(async () => {
34+
await fetchData()
35+
}, 5000)
36+
task.start()
37+
// ... later
38+
task.stop()
39+
```
40+
41+
---
42+
43+
## Use TanStack Query or useAsyncValue
44+
45+
For async data loading in components, use TanStack Query:
46+
47+
```typescript
48+
import { useQuery } from '@tanstack/react-query'
49+
50+
const { data, isLoading } = useQuery({
51+
queryKey: ['myData'],
52+
queryFn: fetchMyData
53+
})
54+
```
55+
56+
Or use existing `useAsyncValue` hook. Don't re-implement async loading patterns.
57+
58+
---
59+
60+
## Background Services
61+
62+
Background services go in `components/services/` directory:
63+
64+
> "If we did want a persistent background service, it would go in components/services. Making it a component means it gets mounted/unmounted cleanly when we log in/out, and we can also see all the background stuff in one place."
65+
66+
Avoid too much background work. Consider:
67+
- Triggering only when the user has pending items
68+
- Running only when on the relevant scene
69+
70+
---
71+
72+
## Prevent Concurrent Execution
73+
74+
Use a helper to prevent duplicate parallel calls when a function can be triggered multiple times (e.g., button presses, retries):
75+
76+
```typescript
77+
// Helper to ensure only one execution at a time
78+
function runOnce<T>(fn: () => Promise<T>): () => Promise<T | undefined> {
79+
let running = false
80+
return async () => {
81+
if (running) return
82+
running = true
83+
try {
84+
return await fn()
85+
} finally {
86+
running = false
87+
}
88+
}
89+
}
90+
91+
// Usage
92+
const handleSubmit = runOnce(async () => {
93+
await submitForm()
94+
})
95+
```
96+
97+
This prevents race conditions from multiple simultaneous calls to the same async function.
98+
99+
---
100+
101+
## Polling Race Conditions
102+
103+
When implementing polling that can be cancelled, ensure the cancel flag is checked after each async operation:
104+
105+
```typescript
106+
// Incorrect - race condition if cancelled during poll interval
107+
const pollForStatus = async (cancel: { cancelled: boolean }) => {
108+
while (!cancel.cancelled) {
109+
const status = await checkStatus()
110+
if (status === 'complete') return status
111+
await sleep(2000)
112+
// BUG: If cancelled during sleep, still continues
113+
}
114+
}
115+
116+
// Correct - check cancel after every await
117+
const pollForStatus = async (cancel: { cancelled: boolean }) => {
118+
while (!cancel.cancelled) {
119+
const status = await checkStatus()
120+
if (cancel.cancelled) return
121+
if (status === 'complete') return status
122+
await sleep(2000)
123+
}
124+
}
125+
```
126+
127+
---
128+
129+
## Refresh State in Async Callbacks
130+
131+
When a timeout or async callback needs current state, read it fresh inside the callback rather than capturing it in a closure:
132+
133+
```typescript
134+
// Incorrect - captured state becomes stale
135+
const { wallets } = props.state // Captured at setup time
136+
setTimeout(() => {
137+
for (const walletId of walletIds) {
138+
const wallet = wallets[walletId] // Stale - may not reflect changes
139+
wallet.doSomething()
140+
}
141+
}, 15000)
142+
143+
// Correct - read fresh state in callback
144+
setTimeout(() => {
145+
const { wallets } = props.state // Fresh read
146+
for (const walletId of walletIds) {
147+
const wallet = wallets[walletId]
148+
if (wallet == null) continue // Guard against removed wallets
149+
wallet.doSomething()
150+
}
151+
}, 15000)
152+
```
153+
154+
State captured in closures doesn't update when the source changes. This is especially important for timeouts and interval callbacks that run much later.
155+
156+
---
157+
158+
## Clean Up Timeouts on Shutdown
159+
160+
When using `setTimeout` in services or engines that can be stopped, track pending timeouts and clear them on shutdown:
161+
162+
```typescript
163+
// Incorrect - timeouts persist after engine stops
164+
class MyEngine {
165+
async processItem(id: string) {
166+
try {
167+
await this.fetch(id)
168+
} catch (error) {
169+
// BUG: This timeout runs even after killEngine() is called
170+
setTimeout(() => this.processItem(id), 5000)
171+
}
172+
}
173+
174+
async killEngine() {
175+
this.cache.clear() // Timeouts still fire after this!
176+
}
177+
}
178+
179+
// Correct - track and clear pending timeouts
180+
class MyEngine {
181+
private pendingTimeouts: Set<ReturnType<typeof setTimeout>> = new Set()
182+
183+
async processItem(id: string) {
184+
try {
185+
await this.fetch(id)
186+
} catch (error) {
187+
const timeoutId = setTimeout(() => {
188+
this.pendingTimeouts.delete(timeoutId)
189+
this.processItem(id)
190+
}, 5000)
191+
this.pendingTimeouts.add(timeoutId)
192+
}
193+
}
194+
195+
async killEngine() {
196+
// Clear all pending timeouts before cleanup
197+
for (const timeoutId of this.pendingTimeouts) {
198+
clearTimeout(timeoutId)
199+
}
200+
this.pendingTimeouts.clear()
201+
this.cache.clear()
202+
}
203+
}
204+
```
205+
206+
**Rule:** Any `setTimeout` in a service that has a shutdown/cleanup method must be tracked and cleared on shutdown to prevent callbacks from executing on stale/cleared state.

.cursor/agents/review-cleaners.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
---
2+
name: review-cleaners
3+
description: Reviews code for cleaner library usage and data validation patterns. Use when reviewing files that handle external data (API responses, disk reads).
4+
---
5+
6+
Review the branch/pull request in context for cleaner usage conventions.
7+
8+
## Context Expected
9+
10+
You will receive:
11+
- Repository name
12+
- Branch name
13+
- List of changed files to review
14+
15+
## How to Review
16+
17+
1. Read the changed files provided in context
18+
2. Look for `fetch`, API calls, file reads, or external data sources
19+
3. Verify cleaners are applied before data is used
20+
4. Check for type/cleaner duplication
21+
5. Report findings with specific file:line references
22+
23+
---
24+
25+
## Clean All External Data
26+
27+
All network requests and data reads from disk must be cleaned with the cleaners library. Code must access the cleaned values, not the original raw/untyped data object.
28+
29+
---
30+
31+
## Let Cleaners Handle Parsing
32+
33+
Use cleaners for JSON parsing instead of double-parsing:
34+
35+
```typescript
36+
// Incorrect
37+
const data = JSON.parse(response)
38+
const cleaned = asMyType(data)
39+
40+
// Correct - let cleaners handle it
41+
const asMyResponse = asJSON(asObject({
42+
field: asString
43+
}))
44+
const cleaned = asMyResponse(response)
45+
```
46+
47+
---
48+
49+
## No Duplicate Types
50+
51+
Types should be derived from cleaners using `ReturnType` syntax. Do not duplicate type definitions:
52+
53+
```typescript
54+
// Incorrect - duplicated type
55+
interface MyData {
56+
name: string
57+
value: number
58+
}
59+
const asMyData = asObject({
60+
name: asString,
61+
value: asNumber
62+
})
63+
64+
// Correct - derive type from cleaner
65+
const asMyData = asObject({
66+
name: asString,
67+
value: asNumber
68+
})
69+
type MyData = ReturnType<typeof asMyData>
70+
```
71+
72+
---
73+
74+
## Remove Unused Fields
75+
76+
Remove or comment out any fields in a cleaner that are unused in the codebase.
77+
78+
---
79+
80+
## asOptional Handles Both Undefined and Null
81+
82+
Despite the name, `asOptional` accepts both `undefined` AND `null` values. This is non-intuitive but useful:
83+
84+
```typescript
85+
// asOptional accepts: missing field, undefined, or null
86+
const asResponse = asObject({
87+
data: asOptional(asString) // Works for { data: null }
88+
})
89+
90+
// These all pass:
91+
asResponse({}) // data is undefined
92+
asResponse({ data: undefined }) // data is undefined
93+
asResponse({ data: null }) // data is null (converted to undefined by default)
94+
asResponse({ data: 'hello' }) // data is 'hello'
95+
```
96+
97+
When you need to preserve the distinction between `null` and `undefined`:
98+
99+
```typescript
100+
// To preserve explicit null values from API
101+
const asResponse = asObject({
102+
data: asOptional(asEither(asNull, asString), null)
103+
// Second arg is default: null if missing/undefined, otherwise the actual value
104+
})
105+
```
106+
107+
---
108+
109+
## New Fields Must Be Optional for Backward Compatibility
110+
111+
When adding new fields to cleaners that validate persisted data (files saved to disk, transaction metadata, etc.), always use `asOptional` to handle existing data that doesn't have the new field:
112+
113+
```typescript
114+
// Incorrect - breaks loading existing transaction files
115+
const asEdgeTxSwap = asObject({
116+
orderId: asOptional(asString),
117+
payoutTokenId: asEdgeTokenId // BUG: Old files don't have this field
118+
})
119+
120+
// Correct - handles old data gracefully
121+
const asEdgeTxSwap = asObject({
122+
orderId: asOptional(asString),
123+
payoutTokenId: asOptional(asEdgeTokenId) // Old files load successfully
124+
})
125+
```
126+
127+
If the field is truly required, first add it as optional and include a migration path for existing data. Types can reflect the optional nature:
128+
129+
```typescript
130+
// In cleaners (backward compatible)
131+
const asEdgeTxSwap = asObject({
132+
payoutTokenId: asOptional(asEdgeTokenId)
133+
})
134+
135+
// Type reflects optional nature - GUI handles missing values
136+
type EdgeTxSwap = ReturnType<typeof asEdgeTxSwap>
137+
// payoutTokenId?: EdgeTokenId
138+
```
139+
140+
**Rule:** Any field added to a cleaner for persisted data MUST be wrapped with `asOptional` unless migration code is included.

0 commit comments

Comments
 (0)