mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-06-28 01:53:40 +02:00
docs(knowledge): capitalisation frontend — intégration du triage local (mai-juin 2026)
Triage et intégration des propositions frontend du buffer 95_a_capitaliser.md (lot local RL799_V2/Vue3 + app-alexandrie/RN-Expo, mai-juin 2026). ~73 entrées intégrées sur knowledge/frontend/ + 1 nouveau fichier, dont : - patterns/state.md : race-token partagé latest-wins (fusion 3 props), capture sync anti-race, event bus timestamp, clé cache composite, état dérivé = computed - risques/state.md : 9 risques Zustand/store (fetchId reset, useRef remount, re-fetch infini sur [], flag optimiste écrasé, cache détail/liste stale, latch sans reset, :key index) - patterns/navigation.md : Expo Router (tab bar, useFocusEffect, Href typé, routing pur fusionné) - patterns/general.md : helpers temps purs, composants génériques + skeleton, fail-fast, touch target - risques/general.md : 24 risques (sweep statique, filtre client liste paginée, hooks avant return, a11y VoiceOver/disabled, redirection allowlist, RangeError toISOString, section i18n...) - design-tokens (cluster theming light/dark MD3), tests, performance, react-native, nextjs - NOUVEAU risques/responsive.md (gating par capacité d'input + checklist régressions mobile) - READMEs patterns/risques mis à jour Doublons inter-fichiers évités (vérifié : aucune ancre dupliquée introduite). Rejets (doublons 91/9/87), reciblages workflow (156/257) et bloc 32 (CLAUDE projet) non intégrés ici. Source 95_a_capitaliser.md non purgée (purge en fin de capitalisation complète). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -592,4 +592,269 @@ function dbStatusToUiStatus(s: DBStatus): UIStatus {
|
||||
|
||||
- Règle : chaque valeur de l'enum DB doit avoir son propre `if` dans le mapping. Éviter le `return` catch-all final qui absorbe les valeurs non prévues.
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-fetch-reset-early-return-isloading"></a>
|
||||
## Race `fetchEntity(reset=true)` avalé par un early-return `isLoading`
|
||||
|
||||
### Risques
|
||||
|
||||
- Une action `fetchEntity(reset)` qui pose `if (isLoading) return` avale le 2ᵉ appel lorsqu'un changement de filtre/critère survient pendant un fetch en vol
|
||||
- La promesse du 1er fetch (filtre A) résout après le changement et écrase le state avec les résultats de A, alors que l'UI affiche le filtre B
|
||||
|
||||
### Symptômes
|
||||
|
||||
- 100 % reproductible sur connexion lente : résultats incohérents après un changement rapide de filtre/recherche/onglet
|
||||
- L'écran affiche le bon filtre sélectionné mais les mauvaises données
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
Distinguer `reset` (changement de critère — toujours lancer + ignorer les fetchs périmés) de `loadMore` (pagination — early-return légitime), via un token incrémental.
|
||||
|
||||
```ts
|
||||
fetchEntity: async (token, reset = false) => {
|
||||
const { isLoading, fetchId } = get();
|
||||
if (!reset && isLoading) return; // garde seulement pour loadMore
|
||||
const myId = fetchId + 1;
|
||||
set({ isLoading: true, fetchId: myId });
|
||||
try {
|
||||
const result = await api.getEntity({ filter, ... });
|
||||
if (get().fetchId !== myId) return; // fetch périmé : drop
|
||||
set({ ..., isLoading: false });
|
||||
} catch (err) {
|
||||
if (get().fetchId !== myId) return;
|
||||
set({ error: ..., isLoading: false });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Test** : 1er fetch deferred (résolu à la main), changement de filtre + 2e fetch, vérifier que le résultat tardif du 1er ne pollue pas le store
|
||||
- Lien : variante côté pattern dans `patterns/state.md#pattern-race-token-partage-latest-wins`
|
||||
- Contexte technique : React Native / Zustand — app-alexandrie review IA-v2.7, 28-05-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-useref-undefined-hook-reactif-store"></a>
|
||||
## `useRef(undefined)` dans un hook réactif à un store persistant → refresh fantôme
|
||||
|
||||
### Risques
|
||||
|
||||
- Un hook qui suit la dernière valeur observée d'un store via `useRef<T | undefined>(undefined)` rejoue son effet à chaque remount si le store a été touché entre-temps
|
||||
- Le ref repart à `undefined` au remount, mais le store conserve sa valeur ≠ undefined → l'effet se redéclenche sans action utilisateur
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Refresh fantôme au retour sur un écran après navigation (flush UI local, requête réseau inutile, scroll-top non voulu)
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
```typescript
|
||||
// ❌ confond "1er mount d'une session" et "remount session déjà en cours"
|
||||
const lastSeenRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// ✅ initialiser à la valeur courante du store → ne déclenche que sur vraie transition
|
||||
const lastSeenRef = useRef<number | undefined>(refreshTimestamp);
|
||||
```
|
||||
|
||||
- **Règle** : un `useRef` qui mémorise la dernière valeur observée d'un store doit être initialisé à la valeur courante, pas à `undefined`
|
||||
- **Test** : mount → store fire → unmount → remount → vérifier que `onRefresh` n'est PAS rappelé tant que la valeur n'a pas changé
|
||||
- Lien : pattern associé `patterns/state.md#pattern-event-bus-zustand-timestamp`
|
||||
- Contexte technique : React Native / Zustand — app-alexandrie review IA-v2.8 (H2), 28-05-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-logique-metier-dispersee-callsites"></a>
|
||||
## Logique métier dispersée dans les callsites au lieu d'un sélecteur dérivé partagé
|
||||
|
||||
### Risques
|
||||
|
||||
- Une prop dérivable d'un store global est recalculée dans chaque callsite (`mode="subscribe"` hardcodé au lieu de `mode={trial.active ? 'trial-upgrade' : 'subscribe'}`)
|
||||
- N callsites sur M oublient la branche conditionnelle → wording/mode incohérent. Ni TypeScript ni les tests unitaires ne capturent ce manque si chaque callsite n'est pas testé
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Comportement correct sur certains écrans, incorrect sur d'autres rendant le même composant
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
```bash
|
||||
grep -rn '<PaywallModal mode="subscribe"' apps/mobile/src # 5 résultats hardcodés → devrait être 0
|
||||
```
|
||||
|
||||
1. Définir un sélecteur pur dans le store : `selectPaywallMode(state): 'trial-upgrade' | 'subscribe'`
|
||||
2. Le tester isolément (fonction pure, trivial)
|
||||
3. Migrer tous les callsites vers `useStore(selectPaywallMode)`
|
||||
|
||||
- **Règle** : toute prop dérivable du store doit l'être via un sélecteur exporté, pas recalculée par callsite (le typage TS ne protège pas → la revue humaine reste indispensable)
|
||||
- Contexte technique : React Native / Zustand — app-alexandrie review IA-v2.5 (H3), 27-05-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-useeffect-data-length-zero-refetch-infini"></a>
|
||||
## `useEffect` avec condition `data.length === 0` → re-fetch infini si l'API retourne `[]`
|
||||
|
||||
### Risques
|
||||
|
||||
- Un `useEffect` qui déclenche un fetch tant que `data.length === 0` boucle indéfiniment quand l'API retourne légitimement un tableau vide
|
||||
- Insidieux : passe les tests mockés (qui renvoient des données), ne se manifeste qu'avec un serveur réel renvoyant `[]`
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Fetch en boucle à chaque re-render/remount sur un endpoint vide
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
```ts
|
||||
// ❌ data.length reste 0 si l'API renvoie [] → fetch en boucle
|
||||
useEffect(() => {
|
||||
if (data.length === 0) void fetchData();
|
||||
}, [data.length, fetchData]);
|
||||
|
||||
// ✅ flag single-shot (ref local ou flag `attempted` dérivé du store)
|
||||
const hasFetchedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (data.length === 0 && !hasFetchedRef.current) {
|
||||
hasFetchedRef.current = true;
|
||||
void fetchData();
|
||||
}
|
||||
}, [data.length, fetchData]);
|
||||
```
|
||||
|
||||
- **Détection** : grep `length === 0` dans des conditions `useEffect`
|
||||
- Contexte technique : React Native — app-alexandrie code review ia-v2-6 (`topics.tsx`), 28-05-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-flag-optimiste-ecrase-par-hydratation"></a>
|
||||
## Flag local optimiste + endpoint d'hydratation = écrasement silencieux si le POST a échoué
|
||||
|
||||
### Risques
|
||||
|
||||
- Un flag basculé en optimiste (`markFlagLocal()`) puis persisté en fire-and-forget (`void persistToBackend()`) est ramené à la valeur backend au prochain endpoint d'hydratation si le POST a échoué (offline, 500)
|
||||
- L'utilisateur perd l'optimistic update sans notification (ex : revoit l'onboarding qu'il avait "passé")
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Action one-shot (skip onboarding, marquage lu) qui "revient" après un reboot offline
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
1. `await` le POST avant `router.replace` (recommandé pour les actions one-shot)
|
||||
2. ou stocker un pending-flag local (AsyncStorage) à rejouer au prochain online
|
||||
3. ou ne basculer le flag local qu'au succès du POST (perd la réactivité)
|
||||
|
||||
- **Règle** : tout flag local optimiste ré-hydraté depuis le backend doit avoir une stratégie de réconciliation explicite, pas un `void promise.catch(() => undefined)`
|
||||
- Contexte technique : React Native / Zustand — app-alexandrie ia-v2-6, 28-05-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-mutation-detail-non-propagee-liste"></a>
|
||||
## Mutation d'un détail non propagée à la liste (cache stale)
|
||||
|
||||
### Risques
|
||||
|
||||
- Un store maintient `items: T[]` (liste) ET `currentDetail: T | null` (détail) ; une action mute `currentDetail` mais oublie de propager à `items[]` et aux listes secondaires
|
||||
- L'invariant "source de vérité unique" est respecté côté backend (les endpoints lisent la même table) mais bafoué côté store (champs dénormalisés)
|
||||
- Invalider un compteur global (`progression: null`) ne suffit pas : `items[]` est un cache local qui survit au retour à la liste
|
||||
|
||||
### Symptômes
|
||||
|
||||
- L'utilisateur agit sur un détail puis revient à la liste et voit l'ancien état
|
||||
- Bug souvent asymétrique (sens A→liste propagé, B→liste oublié) → invisible jusqu'au 1er QA device
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
```typescript
|
||||
// ✅ Propager le même fait à TOUTES les surfaces qui le dénormalisent
|
||||
async resetDetailConsumption(token) {
|
||||
await api.reset(currentDetail.id);
|
||||
const resetId = currentDetail.id;
|
||||
set((state) => ({
|
||||
progression: null,
|
||||
currentDetail: { ...state.currentDetail, consumptionState: 'NOT_STARTED', completedAt: null },
|
||||
items: state.items.map((i) =>
|
||||
i.id === resetId ? { ...i, consumptionState: 'NOT_STARTED', completedAt: null } : i),
|
||||
packContents: state.packContents.map((i) =>
|
||||
i.id === resetId ? { ...i, consumptionState: 'NOT_STARTED', completedAt: null } : i),
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
1. Avant d'écrire l'action, **lister toutes les surfaces du state qui partagent un champ** avec l'entité mutée (liste principale, listes secondaires, caches paginés)
|
||||
2. Les mettre à jour dans le même `set()` — ou documenter pourquoi une surface n'est pas propagée (re-fetch systématique au mount)
|
||||
3. **Symétrie obligatoire** : si `markCompleted` propage, `resetConsumption` doit propager aussi
|
||||
4. Test : asserter que `items[i]` et `currentDetail` sont alignés après chaque mutation
|
||||
|
||||
- Note : rencontré 2× sur app-alexandrie (ux-cleanup-6 backend, puis ux-cleanup-7 store mobile) — la leçon a glissé d'une couche à l'autre
|
||||
- Contexte technique : React Native / Zustand — app-alexandrie ux-cleanup-7, 29-05-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-latch-sans-reset-changement-session"></a>
|
||||
## Latch de chargement sans `reset()` → données figées au changement de session
|
||||
|
||||
### Risques
|
||||
|
||||
- Un latch anti-boucle (`hasLoadedOnce`, posé `true` en succès ET en erreur pour stopper le refetch sur liste vide) survit au changement de compte
|
||||
- Après un logout→login à chaud, le `useEffect` gardé par le latch ne refetch jamais → l'écran reste figé sur les données du compte précédent
|
||||
- Divergence entre stores frères : l'un (messaging) a son reset, l'autre (notifications) ne l'a pas
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Données de l'ancien compte affichées après changement de session sans rechargement complet de l'app
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- **Règle « latch ⇒ reset »** : tout store introduisant un latch de chargement DOIT fournir `reset()` (via un `initialState` factorisé) ET le déclencher sur transition du token d'auth (ref `previousToken` au niveau écran) ou au logout
|
||||
- Vérifier la **symétrie entre stores frères** : si A reset, B doit reset
|
||||
- Contexte technique : React Native / Zustand — app-alexandrie review bo-4, 04-06-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-key-index-liste-editable-inputs"></a>
|
||||
## `:key` par index sur une liste d'inputs éditable au milieu → désalignement de l'état natif
|
||||
|
||||
### Risques
|
||||
|
||||
- Un `v-for`/`map` rendant des `<input>` dont l'utilisateur peut retirer un élément du milieu, keyé sur l'index, recycle le nœud DOM de l'ancien index pour la nouvelle donnée
|
||||
- La valeur contrôlée (`v-model`) se patche bien, mais tout l'état NON contrôlé suit l'ancien nœud : focus, position curseur, sélection, bulle de validation HTML5 `required`
|
||||
|
||||
### Symptômes
|
||||
|
||||
- On retire une ligne au-dessus d'un champ en cours d'édition → focus/validation restent sur le mauvais champ
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- Transformer la liste en tableau d'objets `{ id, value }` (id stable généré à l'ajout) et keyer sur `item.id` avec `v-model="item.value"`
|
||||
- Vrai pour toute liste réordonnable ou supprimable au milieu
|
||||
- Contexte technique : Vue 3 — RL799 (`InstructionForm.vue`), code review adversariale, 13-06-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-optimistic-update-slice-absente"></a>
|
||||
## Optimistic update sur slice indexée absente → message perdu visuellement
|
||||
|
||||
### Risques
|
||||
|
||||
- Dans un store structuré en `Record<id, slice>` (chaque slice = items + pagination + flags), un optimistic update qui `return` sans rien faire quand la slice n'existe pas encore perd visuellement l'item
|
||||
- Cas typique : "Nouveau message → premier envoi → navigation immédiate vers `/messages/:id`" — la slice n'est jamais créée avant la navigation, l'item disparaît jusqu'au `fetchFirstPage`
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Trou visuel (message envoyé absent) pendant la latence réseau, comblé seulement au fetch suivant
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
```typescript
|
||||
// ✅ initialiser la slice si absente lors de l'optimistic update
|
||||
const slice = state.messagesByConversationId[message.conversationId];
|
||||
const nextSlice = slice
|
||||
? { ...slice, items: [message, ...slice.items] }
|
||||
: { items: [message], nextCursor: null, hasMore: false, isReadOnly: false, isLoading: false, error: null };
|
||||
```
|
||||
|
||||
- **Règle** : tout store en `Record<id, slice>` DOIT initialiser la slice manquante lors d'un optimistic update (sinon le fetch suivant l'écrase, mais l'utilisateur voit un trou)
|
||||
- Lien : voisin de `risque-zustand-optimistic-update-sous-listes` (recherche d'item à travers les sous-listes)
|
||||
- Contexte technique : React Native / Zustand — app-alexandrie rétro Epic 10 (A2), 13-05-2026
|
||||
|
||||
- Contexte technique : frontend / mapping statut DB → UI — app-template-resto 25-06-2026
|
||||
|
||||
Reference in New Issue
Block a user