@buivietphi/skill-mobile-mt 2.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +96 -40
- package/README.md +77 -40
- package/SKILL.md +762 -54
- package/package.json +1 -1
- package/shared/bug-detection.md +411 -27
- package/shared/code-generation-templates.md +656 -0
- package/shared/code-review.md +899 -37
- package/shared/complex-ui-patterns.md +526 -0
- package/shared/data-flow-patterns.md +422 -0
- package/shared/debugging-intelligence.md +787 -0
- package/shared/error-handling.md +394 -0
- package/shared/i18n-localization.md +426 -0
- package/shared/intent-analysis.md +473 -0
- package/shared/navigation-patterns.md +375 -0
- package/shared/prompt-engineering.md +176 -20
- package/shared/spec-to-code.md +293 -0
- package/shared/storage-patterns.md +312 -0
- package/shared/testing-patterns.md +428 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# Data Flow Patterns — Fetching, Caching, Real-Time
|
|
2
|
+
|
|
3
|
+
> On-demand module. Loaded when implementing pagination, optimistic updates, cache invalidation, or real-time features.
|
|
4
|
+
> Contains production patterns that handle edge cases (race conditions, stale data, error recovery).
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Pagination — Infinite Scroll
|
|
9
|
+
|
|
10
|
+
### React Native (TanStack Query)
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
// hooks/useProductList.ts
|
|
14
|
+
import { useInfiniteQuery } from '@tanstack/react-query';
|
|
15
|
+
|
|
16
|
+
interface ProductPage {
|
|
17
|
+
items: Product[];
|
|
18
|
+
nextCursor: string | null; // null = no more pages
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useProductList(category?: string) {
|
|
22
|
+
return useInfiniteQuery({
|
|
23
|
+
queryKey: ['products', { category }],
|
|
24
|
+
queryFn: async ({ pageParam }) => {
|
|
25
|
+
const res = await productService.getProducts({
|
|
26
|
+
cursor: pageParam,
|
|
27
|
+
limit: 20,
|
|
28
|
+
category,
|
|
29
|
+
});
|
|
30
|
+
return res as ProductPage;
|
|
31
|
+
},
|
|
32
|
+
initialPageParam: undefined as string | undefined,
|
|
33
|
+
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
|
34
|
+
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ProductListScreen.tsx
|
|
39
|
+
function ProductListScreen() {
|
|
40
|
+
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error, refetch } = useProductList();
|
|
41
|
+
|
|
42
|
+
const products = data?.pages.flatMap(page => page.items) ?? [];
|
|
43
|
+
|
|
44
|
+
if (isLoading) return <ProductListSkeleton />;
|
|
45
|
+
if (error) return <ErrorView error={error} onRetry={refetch} />;
|
|
46
|
+
if (products.length === 0) return <EmptyView message="No products found" />;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<FlatList
|
|
50
|
+
data={products}
|
|
51
|
+
keyExtractor={(item) => item.id}
|
|
52
|
+
renderItem={({ item }) => <ProductCard product={item} />}
|
|
53
|
+
onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage(); }}
|
|
54
|
+
onEndReachedThreshold={0.5}
|
|
55
|
+
ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
|
|
56
|
+
refreshControl={<RefreshControl refreshing={false} onRefresh={refetch} />}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Offset-Based Pagination (alternative)
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// When API uses page numbers instead of cursors
|
|
66
|
+
export function useProductListPaged() {
|
|
67
|
+
return useInfiniteQuery({
|
|
68
|
+
queryKey: ['products'],
|
|
69
|
+
queryFn: async ({ pageParam = 1 }) => {
|
|
70
|
+
return productService.getProducts({ page: pageParam, limit: 20 });
|
|
71
|
+
},
|
|
72
|
+
initialPageParam: 1,
|
|
73
|
+
getNextPageParam: (lastPage, allPages) => {
|
|
74
|
+
// No more pages if last page returned fewer items than limit
|
|
75
|
+
return lastPage.items.length >= 20 ? allPages.length + 1 : undefined;
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Flutter (Riverpod)
|
|
82
|
+
|
|
83
|
+
```dart
|
|
84
|
+
// providers/product_list_provider.dart
|
|
85
|
+
@riverpod
|
|
86
|
+
class ProductList extends _$ProductList {
|
|
87
|
+
String? _nextCursor;
|
|
88
|
+
bool _hasMore = true;
|
|
89
|
+
|
|
90
|
+
@override
|
|
91
|
+
FutureOr<List<Product>> build() => _fetchPage(null);
|
|
92
|
+
|
|
93
|
+
Future<List<Product>> _fetchPage(String? cursor) async {
|
|
94
|
+
final page = await ref.read(productRepoProvider).getProducts(cursor: cursor);
|
|
95
|
+
_nextCursor = page.nextCursor;
|
|
96
|
+
_hasMore = page.nextCursor != null;
|
|
97
|
+
return page.items;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
Future<void> loadMore() async {
|
|
101
|
+
if (!_hasMore || state is AsyncLoading) return;
|
|
102
|
+
final currentItems = state.value ?? [];
|
|
103
|
+
final newItems = await _fetchPage(_nextCursor);
|
|
104
|
+
state = AsyncData([...currentItems, ...newItems]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
bool get hasMore => _hasMore;
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Optimistic Updates with Rollback
|
|
114
|
+
|
|
115
|
+
### React Native (TanStack Query)
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// hooks/useToggleFavorite.ts
|
|
119
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
120
|
+
|
|
121
|
+
export function useToggleFavorite() {
|
|
122
|
+
const queryClient = useQueryClient();
|
|
123
|
+
|
|
124
|
+
return useMutation({
|
|
125
|
+
mutationFn: (productId: ProductId) => productService.toggleFavorite(productId),
|
|
126
|
+
|
|
127
|
+
// Optimistic update: change UI before API responds
|
|
128
|
+
onMutate: async (productId) => {
|
|
129
|
+
// Cancel outgoing refetches (they would overwrite our optimistic update)
|
|
130
|
+
await queryClient.cancelQueries({ queryKey: ['product', productId] });
|
|
131
|
+
|
|
132
|
+
// Snapshot the previous value
|
|
133
|
+
const previous = queryClient.getQueryData<Product>(['product', productId]);
|
|
134
|
+
|
|
135
|
+
// Optimistically update the cache
|
|
136
|
+
queryClient.setQueryData<Product>(['product', productId], (old) =>
|
|
137
|
+
old ? { ...old, isFavorite: !old.isFavorite } : old
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Return snapshot for rollback
|
|
141
|
+
return { previous };
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
// On error: rollback to snapshot
|
|
145
|
+
onError: (_error, productId, context) => {
|
|
146
|
+
if (context?.previous) {
|
|
147
|
+
queryClient.setQueryData(['product', productId], context.previous);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
// On success or error: refetch to ensure server truth
|
|
152
|
+
onSettled: (_data, _error, productId) => {
|
|
153
|
+
queryClient.invalidateQueries({ queryKey: ['product', productId] });
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Usage: simple one-liner
|
|
159
|
+
// const toggleFavorite = useToggleFavorite();
|
|
160
|
+
// <HeartButton onPress={() => toggleFavorite.mutate(product.id)} />
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Zustand Optimistic Pattern
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
// stores/useCartStore.ts
|
|
167
|
+
addItem: async (product: Product, quantity: number) => {
|
|
168
|
+
// 1. Snapshot current state
|
|
169
|
+
const snapshot = get().items;
|
|
170
|
+
|
|
171
|
+
// 2. Optimistic update
|
|
172
|
+
set(state => {
|
|
173
|
+
state.items.push({ productId: product.id, quantity, price: product.price });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// 3. API call
|
|
177
|
+
try {
|
|
178
|
+
await cartService.addItem(product.id, quantity);
|
|
179
|
+
} catch {
|
|
180
|
+
// 4. Rollback on error
|
|
181
|
+
set(state => { state.items = snapshot; });
|
|
182
|
+
throw new Error('Failed to add to cart. Please try again.');
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Cache Invalidation Strategies
|
|
190
|
+
|
|
191
|
+
### When to Invalidate
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
AFTER MUTATION (user changes data):
|
|
195
|
+
→ Invalidate the entity that was changed
|
|
196
|
+
→ Invalidate lists that contain that entity
|
|
197
|
+
|
|
198
|
+
Example: User edits profile
|
|
199
|
+
queryClient.invalidateQueries({ queryKey: ['user', userId] });
|
|
200
|
+
queryClient.invalidateQueries({ queryKey: ['users'] }); // list
|
|
201
|
+
|
|
202
|
+
ON NAVIGATION BACK (may be stale):
|
|
203
|
+
→ Use refetchOnWindowFocus: true (TanStack Query default)
|
|
204
|
+
→ Or manual: useFocusEffect(() => { refetch(); });
|
|
205
|
+
|
|
206
|
+
PERIODIC POLLING (real-time-ish data):
|
|
207
|
+
useQuery({
|
|
208
|
+
queryKey: ['notifications'],
|
|
209
|
+
queryFn: fetchNotifications,
|
|
210
|
+
refetchInterval: 30_000, // every 30 seconds
|
|
211
|
+
refetchIntervalInBackground: false, // stop when app backgrounded
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
MANUAL REFRESH (pull-to-refresh):
|
|
215
|
+
<RefreshControl refreshing={isRefetching} onRefresh={refetch} />
|
|
216
|
+
|
|
217
|
+
SELECTIVE vs FULL CLEAR:
|
|
218
|
+
// Selective: only affected queries
|
|
219
|
+
queryClient.invalidateQueries({ queryKey: ['product', productId] });
|
|
220
|
+
|
|
221
|
+
// Full clear (logout):
|
|
222
|
+
queryClient.clear();
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### React Navigation Focus Refetch
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
// hooks/useRefetchOnFocus.ts
|
|
229
|
+
import { useFocusEffect } from '@react-navigation/native';
|
|
230
|
+
import { useCallback } from 'react';
|
|
231
|
+
|
|
232
|
+
export function useRefetchOnFocus(refetch: () => void) {
|
|
233
|
+
useFocusEffect(
|
|
234
|
+
useCallback(() => {
|
|
235
|
+
refetch();
|
|
236
|
+
}, [refetch])
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Usage:
|
|
241
|
+
// const { data, refetch } = useProductDetail(id);
|
|
242
|
+
// useRefetchOnFocus(refetch);
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## WebSocket Real-Time
|
|
248
|
+
|
|
249
|
+
### React Native — Socket Connection Manager
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
// services/socket.ts
|
|
253
|
+
import { io, Socket } from 'socket.io-client';
|
|
254
|
+
import { useAuthStore } from '@/stores/useAuthStore';
|
|
255
|
+
import { AppState } from 'react-native';
|
|
256
|
+
|
|
257
|
+
class SocketManager {
|
|
258
|
+
private socket: Socket | null = null;
|
|
259
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
260
|
+
private maxReconnectDelay = 30_000;
|
|
261
|
+
private reconnectAttempts = 0;
|
|
262
|
+
|
|
263
|
+
connect() {
|
|
264
|
+
const token = useAuthStore.getState().token;
|
|
265
|
+
if (!token || this.socket?.connected) return;
|
|
266
|
+
|
|
267
|
+
this.socket = io(process.env.EXPO_PUBLIC_WS_URL!, {
|
|
268
|
+
auth: { token },
|
|
269
|
+
transports: ['websocket'],
|
|
270
|
+
reconnection: false, // we handle reconnection manually
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
this.socket.on('connect', () => {
|
|
274
|
+
this.reconnectAttempts = 0;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
this.socket.on('disconnect', (reason) => {
|
|
278
|
+
if (reason !== 'io client disconnect') {
|
|
279
|
+
this.scheduleReconnect();
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
this.socket.on('connect_error', () => {
|
|
284
|
+
this.scheduleReconnect();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Reconnect when app comes to foreground
|
|
288
|
+
AppState.addEventListener('change', (state) => {
|
|
289
|
+
if (state === 'active' && !this.socket?.connected) {
|
|
290
|
+
this.connect();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private scheduleReconnect() {
|
|
296
|
+
if (this.reconnectTimer) return;
|
|
297
|
+
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts) + Math.random() * 500, this.maxReconnectDelay);
|
|
298
|
+
this.reconnectAttempts++;
|
|
299
|
+
this.reconnectTimer = setTimeout(() => {
|
|
300
|
+
this.reconnectTimer = null;
|
|
301
|
+
this.connect();
|
|
302
|
+
}, delay);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
on<T>(event: string, callback: (data: T) => void) {
|
|
306
|
+
this.socket?.on(event, callback);
|
|
307
|
+
return () => { this.socket?.off(event, callback); };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
emit(event: string, data?: unknown) {
|
|
311
|
+
this.socket?.emit(event, data);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
disconnect() {
|
|
315
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
316
|
+
this.socket?.disconnect();
|
|
317
|
+
this.socket = null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export const socketManager = new SocketManager();
|
|
322
|
+
|
|
323
|
+
// hooks/useSocket.ts — auto-subscribe/unsubscribe
|
|
324
|
+
export function useSocket<T>(event: string, callback: (data: T) => void) {
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
const unsubscribe = socketManager.on(event, callback);
|
|
327
|
+
return unsubscribe;
|
|
328
|
+
}, [event, callback]);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Usage:
|
|
332
|
+
// useSocket<Message>('new_message', (msg) => {
|
|
333
|
+
// queryClient.setQueryData(['messages', chatId], (old) => [...old, msg]);
|
|
334
|
+
// });
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## Request Queuing for Offline
|
|
340
|
+
|
|
341
|
+
### React Native — Mutation Queue
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
// services/offlineQueue.ts
|
|
345
|
+
import NetInfo from '@react-native-community/netinfo';
|
|
346
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
347
|
+
|
|
348
|
+
interface QueuedRequest {
|
|
349
|
+
id: string;
|
|
350
|
+
method: 'POST' | 'PUT' | 'DELETE';
|
|
351
|
+
url: string;
|
|
352
|
+
data: unknown;
|
|
353
|
+
timestamp: number;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const QUEUE_KEY = 'offline_request_queue';
|
|
357
|
+
|
|
358
|
+
export const offlineQueue = {
|
|
359
|
+
async enqueue(request: Omit<QueuedRequest, 'id' | 'timestamp'>) {
|
|
360
|
+
const queue = await this.getQueue();
|
|
361
|
+
queue.push({
|
|
362
|
+
...request,
|
|
363
|
+
id: Math.random().toString(36).slice(2),
|
|
364
|
+
timestamp: Date.now(),
|
|
365
|
+
});
|
|
366
|
+
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
async getQueue(): Promise<QueuedRequest[]> {
|
|
370
|
+
const raw = await AsyncStorage.getItem(QUEUE_KEY);
|
|
371
|
+
return raw ? JSON.parse(raw) : [];
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
async flush() {
|
|
375
|
+
const queue = await this.getQueue();
|
|
376
|
+
const remaining: QueuedRequest[] = [];
|
|
377
|
+
|
|
378
|
+
for (const req of queue) {
|
|
379
|
+
try {
|
|
380
|
+
await api({ method: req.method, url: req.url, data: req.data });
|
|
381
|
+
} catch {
|
|
382
|
+
remaining.push(req); // retry next time
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(remaining));
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Auto-flush when online
|
|
391
|
+
NetInfo.addEventListener(state => {
|
|
392
|
+
if (state.isConnected) offlineQueue.flush();
|
|
393
|
+
});
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## Data Prefetching
|
|
399
|
+
|
|
400
|
+
```typescript
|
|
401
|
+
// Prefetch next screen's data when user is likely to navigate
|
|
402
|
+
function ProductCard({ product }: { product: Product }) {
|
|
403
|
+
const queryClient = useQueryClient();
|
|
404
|
+
|
|
405
|
+
const prefetchDetail = () => {
|
|
406
|
+
queryClient.prefetchQuery({
|
|
407
|
+
queryKey: ['product', product.id],
|
|
408
|
+
queryFn: () => productService.getById(product.id),
|
|
409
|
+
staleTime: 5 * 60 * 1000,
|
|
410
|
+
});
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<TouchableOpacity
|
|
415
|
+
onPress={() => navigation.navigate('ProductDetail', { productId: product.id })}
|
|
416
|
+
onPressIn={prefetchDetail} // prefetch on touch start (before navigation)
|
|
417
|
+
>
|
|
418
|
+
{/* card content */}
|
|
419
|
+
</TouchableOpacity>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
```
|