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:
MaksTinyWorkshop
2026-06-25 15:31:53 +02:00
parent f1b783407a
commit 5f5c87296e
15 changed files with 2439 additions and 12 deletions
+265
View File
@@ -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