Files
_Assistant_Lead_Tech/knowledge/frontend/risques/state.md
T
MaksTinyWorkshop 5f5c87296e 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>
2026-06-25 15:31:53 +02:00

861 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Frontend — Risques & vigilance : State
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/frontend/risques/README.md` pour l'index complet.
---
<a id="risque-erreurs-silencieuses"></a>
## Erreurs silencieuses / écrans blancs
### Risques
- Exceptions non gérées → app inutilisable
- États async mal gérés → UI incohérente (loading infini, vide incompris)
### Symptômes
- Écran blanc après une action
- Toast générique "Une erreur est survenue" sans corrélation
- Pas de moyen de reproduire / diagnostiquer
### Bonnes pratiques / mitigations
- Pattern "états UI explicites" (loading/empty/error)
- Boundary d'erreur UI + fallback
- Logging minimal côté client avec requestId/traceId quand possible
---
<a id="risque-melange-server-client-state"></a>
## Mélange server state / client state
### Risques
- Cache pollué par des états UI (onglets, filtres)
- UI qui reflète une donnée périmée sans le savoir
- Re-renders et bugs de synchronisation
### Symptômes
- "Ça revient tout seul" après refresh
- Données affichées ≠ données du backend
- Debug très long car état implicite
### Bonnes pratiques / mitigations
- Séparer explicitement server state vs client state
- Invalidation/reload explicite du server state
- État UI local réinitialisable
---
<a id="risque-api-state-local-ecran"></a>
## Appels API gérés en state local d'écran (refactor coûteux)
### Risques
- Server state non partageable entre écrans (liste/detail, wizard, tabs) → duplication et incohérences
- Pas de cache / invalidation standard → bugs subtils et re-fetchs inutiles
- Refactor tardif quand l'epic s'étend (mutations, cache, offline, pagination)
### Symptômes
- Même appel API recopié dans plusieurs écrans
- Un écran "A" modifie une ressource mais l'écran "B" n'est jamais rafraîchi
- Code review qui force un refactor vers un store/cache au milieu d'un epic
### Bonnes pratiques / mitigations
- Par défaut : créer un store de domaine (ex : Zustand) ou un cache de server state pour tout domaine susceptible d'être réutilisé
- Centraliser `isLoading`/`error`/`data` et la stratégie de refresh/invalidation
- Exception acceptable : état purement UI, local et jetable (ex : input de recherche, filtres temporaires non persistés)
---
<a id="risque-catch-silencieux"></a>
## Catch silencieux — erreur inconnue sans feedback utilisateur
### Risques
- Un `catch` qui ne traite que les cas connus laisse l'utilisateur face à un spinner qui disparaît sans message
- L'état d'erreur reste implicite → impossible de diagnostiquer ou de reproduire
### Symptômes
- Bouton spinner qui s'arrête, rien ne se passe
- Pas de toast / message d'erreur affiché
- Erreur "avalée" silencieusement dans les logs
### Bonnes pratiques / mitigations
```typescript
} catch (err: unknown) {
const code = (err as { code?: string }).code;
if (code === 'SUBSCRIPTION_REQUIRED') {
setSubscriptionRequired(true);
} else {
setError('Une erreur est survenue. Veuillez réessayer.'); // toujours un fallback
}
}
```
- **Règle** : tout `catch` doit avoir une branche `else` (ou `default`) qui affiche un feedback utilisateur explicite.
- Contexte technique : React Native / Expo — 09-03-2026
---
<a id="risque-auto-reset-etat-degrade"></a>
## Auto-reset d'un état dégradé sur toute réponse 2xx
### Risques
- Le client sort trop tôt d'un mode dégradé alors que la cause serveur est toujours présente
- Le bandeau ou l'état read-only clignote puis disparaît à tort
- Les utilisateurs retentent une action d'écriture qui va encore échouer
### Symptômes
- Un GET réussi réinitialise `isReadOnly` ou `isDegraded`
- L'UI redevient "normale" alors que Redis ou un service critique est toujours indisponible
- Les erreurs reviennent immédiatement à la prochaine mutation
### Bonnes pratiques / mitigations
- Ne réinitialiser l'état dégradé qu'après une requête d'écriture réussie
- Exclure `GET` et `HEAD` de la logique de reset
- Conserver le mode dégradé tant qu'aucune mutation n'a prouvé le retour à la normale
- Contexte technique : React Native / Expo — 10-03-2026
---
<a id="risque-refresh-store-fire-and-forget"></a>
## Refresh store en fire-and-forget après mutation
### Risques
- L'UI affiche un succès alors que la resynchronisation a échoué
- État local incohérent avec l'état serveur
- Erreurs silencieuses impossibles à diagnostiquer
### Symptômes
- Mutation réussie puis store jamais rafraîchi
- Spinner coupé avant que l'écran soit réellement à jour
- Données anciennes qui persistent jusqu'au prochain reload
### Bonnes pratiques / mitigations
- `await` explicite du refresh si l'UI dépend du résultat
- Gestion d'erreur dédiée sur la phase de resynchronisation
- N'utiliser le fire-and-forget que pour un effet secondaire réellement non bloquant
- Contexte technique : React Native / Expo — 10-03-2026
---
<a id="risque-boolean-ui-hardcode-store"></a>
## État booléen UI dérivé hardcodé au lieu d'être calculé depuis le store
### Risques
- Un état toggle (`isBookmarked`, `isLiked`, `isFollowed`) initialisé à `false` en dur ne reflète jamais l'état réel
- Le bouton est toujours en mode "ajouter" sans jamais passer en mode "supprimer"
### Symptômes
- `const isBookmarked = false; // état local géré ci-dessous via state`
- Bouton bookmark/like toujours dans le même état visuel peu importe l'état réel
### Bonnes pratiques / mitigations
```typescript
// ❌ Anti-pattern — état hardcodé
const isBookmarked = false;
// ✅ Pattern correct — dérivé du store au rendu
const { bookmarks } = useCommunityStore();
const isBookmarked = bookmarks.some((b) => b.thread.id === threadId);
```
- Règle : si le store contient la liste (bookmarks, likes, follows), l'état booléen se dérive avec `.some()` ou `.has()`
- Contexte technique : React Native / Zustand — app-alexandrie story 4.4, 20-03-2026
---
<a id="risque-flag-isloading-unique-nature-differente"></a>
## Flag `isLoading` unique pour des opérations de natures différentes
### Risques
- Un même flag (ex: `isBookmarking`) utilisé à la fois pour les mutations (add/remove) et le chargement de la liste provoque des bugs visuels — spinner manquant au premier chargement si une mutation est en cours en parallèle
### Symptômes
- Spinner absent au premier chargement de la liste bookmarks
- Bouton "ajouter" désactivé alors qu'aucune mutation n'est en cours
### Bonnes pratiques / mitigations
```typescript
// ❌ Anti-pattern — un seul flag pour tout
isBookmarking: boolean;
// ✅ Pattern correct — séparation claire
isBookmarking: boolean; // mutations add/remove
isLoadingBookmarks: boolean; // chargement de la liste (GET)
```
- Contexte technique : React Native / Zustand — app-alexandrie story 4.4, 20-03-2026
---
<a id="risque-zustand-optimistic-update-sous-listes"></a>
## Zustand : optimistic update sur item absent de la liste principale
### Risques
- Une action admin qui cherche l'item uniquement dans `state.threads` (liste paginée principale) manque les items présents exclusivement dans `state.pinnedThreads` ou `state.showcasedThreads`
- L'optimistic update ne se reflète pas visuellement même si l'appel API a réussi
### Symptômes
- L'item mis à jour par une action admin n'apparaît pas dans la nouvelle sous-liste après l'action
- Bug reproductible uniquement quand l'item est épinglé / en vitrine mais pas dans la page courante du flux principal
### Bonnes pratiques / mitigations
```typescript
// ❌ Anti-pattern : cherche uniquement dans la liste principale paginée
const target = state.threads.find((t) => t.id === threadId);
// → manque les items présents uniquement dans pinnedThreads / showcasedThreads
// ✅ Pattern correct : fallback sur toutes les sous-listes du store
const target =
state.threads.find((t) => t.id === threadId) ??
state.pinnedThreads.find((t) => t.id === threadId) ??
state.showcasedThreads.find((t) => t.id === threadId);
```
- **Règle** : toute action qui opère sur un item pouvant être présent dans plusieurs sous-listes doit chercher dans l'ensemble de ces listes
- Règle complémentaire : ne pas mettre à jour une sous-liste (ex: `pinnedThreads`) lors d'une action qui n'y a pas de rapport (ex: mise en vitrine ne touche pas `pinnedThreads`)
- Contexte technique : React Native / Zustand — app-alexandrie 23-03-2026
---
<a id="risque-zustand-erreur-sans-rethrow"></a>
## Store Zustand : méthodes `update*` qui avalent les erreurs sans rethrow
### Risques
- Une méthode store qui catch une erreur sans la relancer (`throw`) avale silencieusement les erreurs métier (ex: `UNSAFE_LINK`)
- L'écran appelant ne reçoit jamais l'erreur → impossible d'afficher un feedback à l'utilisateur
### Symptômes
- L'action semble réussir côté UI mais la donnée n'a pas changé en base
- Erreurs métier (ex: lien interdit) invisibles pour l'utilisateur final
### Bonnes pratiques / mitigations
```typescript
// ❌ MAUVAIS — l'erreur est avalée, l'écran ne sait pas que ça a échoué
async updateThread(forumSlug, threadId, body) {
await communityService.updateThread(accessToken, forumSlug, threadId, body);
},
// ✅ BON — l'erreur est propagée pour que l'écran puisse réagir
async updateThread(forumSlug, threadId, body) {
try {
await communityService.updateThread(accessToken, forumSlug, threadId, body);
} catch (e) {
const err = e as Error & { code?: string };
throw err; // Le code d'erreur (ex: UNSAFE_LINK) est préservé sur l'objet
}
},
```
- **Règle** : toute méthode store qui appelle le service réseau doit soit (1) relancer l'erreur enrichie avec `throw err`, soit (2) la stocker dans le state (`set({ error: err.message })`). Jamais les deux à la fois sans rethrow si l'écran doit réagir au catch.
- Contexte technique : React Native / Zustand — app-alexandrie 24-03-2026
---
<a id="risque-catch-objet-throw-vs-error"></a>
## Catch Zustand : objet structuré throwé vs instance `Error`
### Risques
- Quand `apiRequest` propage une erreur HTTP en throwant le JSON brut `{ error: { code, message } }`, un catch limité à `err instanceof Error` laisse le message inaccessible → fallback générique ou message vide affiché
### Symptômes
- Toast "Erreur de connexion au serveur" affiché même quand le serveur retourne un message explicite
- `err.message` undefined alors que `(err as ApiError).error.message` contient la cause réelle
### Bonnes pratiques / mitigations
```typescript
catch (err: unknown) {
let message = 'Erreur de connexion au serveur.';
if (err instanceof Error) {
message = err.message;
} else if (
typeof err === 'object' && err !== null &&
'error' in err &&
typeof (err as { error: { message?: string } }).error?.message === 'string'
) {
message = (err as { error: { message: string } }).error.message;
}
set({ error: message, isLoading: false });
}
```
- Règle : tout store qui appelle `apiRequest` directement doit inspecter les deux cas — `Error` natif et objet structuré `{ error: { message } }`
- Contexte technique : React Native / Zustand — app-alexandrie review 5.2, 27-03-2026
---
<a id="risque-flag-global-actions-paralleles"></a>
## Flag booléen global pour des actions par entité — préférer `Set<string>`
### Risques
- Un flag `isLoading: boolean` global pour une action par item (follow, like, bookmark sur une liste) désactive tous les boutons dès qu'une action est en cours sur un seul item
### Symptômes
- Cliquer "Suivre" sur une carte désactive tous les autres boutons "Suivre" de la liste
- Impossible de déclencher deux actions en parallèle sur des entités différentes
### Bonnes pratiques / mitigations
```typescript
// ❌ Anti-pattern — un seul flag pour toutes les instances
followIsLoading: boolean;
// ✅ Pattern correct — un Set des IDs en cours
followingInProgress: Set<string>;
// Selector
isFollowInProgress: (userId: string) => get().followingInProgress.has(userId),
// Mutation
set((state) => {
const next = new Set(state.followingInProgress);
next.add(targetUserId);
return { followingInProgress: next };
});
// Après succès/erreur
next.delete(targetUserId);
```
- Règle : toute action async sur des items d'une liste (like, bookmark, follow, reaction) doit utiliser `Set<EntityId>` au lieu d'un `boolean` global
- Contexte technique : React Native / Zustand — app-alexandrie review 5.3, 28-03-2026
---
<a id="risque-erreur-partagee-action-liste"></a>
## Clé d'erreur partagée entre action et liste dans un store Zustand
### Risques
- Une clé d'erreur partagée entre une mutation (follow/unfollow) et un fetch de liste (followers, followings) affiche une erreur d'action périmée comme erreur de chargement
- Ex : `followError: 'ALREADY_FOLLOWING'` stocké depuis une action précédente s'affiche dans l'écran "Abonnés" comme "Erreur de chargement"
### Symptômes
- Écran de liste affiche une erreur après une action précédente sans rapport direct
- Bug intermittent difficile à reproduire, corrélé à l'ordre des actions utilisateur
### Bonnes pratiques / mitigations
```typescript
// ❌ Anti-pattern — clé partagée
followError: string | null;
// ✅ Pattern correct — clés séparées par nature
followError: string | null; // erreur de followUser/unfollowUser
followersError: string | null; // erreur de fetchFollowers
followingsError: string | null; // erreur de fetchFollowings
```
- Règle : dans un store qui gère à la fois des mutations et des listes paginées, chaque opération doit avoir sa propre clé d'erreur
- Contexte technique : React Native / Zustand — app-alexandrie review 5.3, 28-03-2026
---
<a id="risque-emit-vue-mutation-serveur-sans-listener"></a>
## `emit` Vue annonçant une mutation serveur sans listener parent → caches stale
### Risques
- Un composant enfant émet un événement (`emit('approved')`) après une mutation côté serveur, mais aucun parent n'écoute
- L'enfant met bien à jour son état local, mais les caches parents qui dérivent du même statut (badges accordion, verrous cascade, prop `previousAggregate` consommée par d'autres enfants) restent stale **indéfiniment**, jusqu'au prochain changement d'écran/route
- Bug invisible dans les tests structurels (`content.includes`) et passe inaperçu en revue parce que le code enfant est "correct"
### Symptômes
- Tout autre affichage du même statut dans le parent ou dans des sous-composants frères reste "Publiée" alors que la planche est "Approuvée" en DB
- L'UI ne se rafraîchit qu'au prochain reload manuel ou navigation
### Bonnes pratiques / mitigations
```bash
# Repérer les emits qui annoncent une mutation serveur
grep -rn "defineEmits" apps/frontend/src/components
# Pour chaque emit trouvé, chercher s'il a au moins un listener parent
grep -rn "@<eventName>=" apps/frontend/src/pages apps/frontend/src/components
```
Zéro listener = bug latent (sauf si l'emit est purement informatif — analytics, debug).
**Règle** : tout `emit` qui annonce une **mutation persistée serveur** (création, suppression, changement de statut, validation) doit avoir au moins un listener parent qui :
1. Invalide les caches locaux dérivés de la même donnée (Map de statuts, computed, props transitives)
2. Recharge la slice agrégée si le parent passe une prop construite à partir d'un autre fetch (`previousAggregate`, dashboard data)
3. **Ne se contente PAS de l'optimistic update** de l'enfant — la source de vérité reste serveur, le cache parent doit refléter l'état serveur post-mutation
```vue
<!-- Enfant émet, parent ignore. Le badge de l'accordion reste stale -->
<PlancheTraceeCard :tenue-id="planche.tenueId" mode="previous" />
<!-- Listener explicite qui invalide les caches dérivés -->
<PlancheTraceeCard
:tenue-id="planche.tenueId"
mode="previous"
@approved="onPlancheApproved(planche.tenueId)"
/>
```
**Couverture** : test de mount complet via `@vue/test-utils` qui simule l'événement et vérifie que le rendu parent change. Les tests `readFileSync + content.includes('emit')` valident que le code enfant émet, pas que le parent écoute.
- Contexte technique : Vue 3 — RL799_V2 29-04-2026
---
<a id="risque-templates-vue-references-orphelines"></a>
## Templates Vue — références orphelines invisibles à `tsc --noEmit`
### Risques
- Une variable supprimée du `<script setup>` mais encore référencée dans le `<template>` ne génère pas d'erreur compile avec `tsc --noEmit` seul (Volar moins strict que `tsc` pur sur les expressions template)
- Le composant crashe **uniquement au runtime** : `Cannot read properties of undefined` ou `[Vue warn] Property "X" was accessed during render but is not defined`
### Symptômes
- Refactor où on extrait un composable et oublie de destructurer une variable, ou on retire un import devenu (apparemment) inutilisé
- Typecheck passe, tests structurels passent, page charge mais une section ne s'affiche pas / un bouton ne fait rien
- `[Vue warn]` dans la console au mount
### Bonnes pratiques / mitigations
**Recommandation outillage** : migrer le `typecheck` du projet de `tsc --noEmit` vers `vue-tsc -p tsconfig.typecheck.json --noEmit`. Avec `vue-tsc`, les expressions template sont strictement typées contre le `<script setup>` exposé. Une ref orpheline → erreur de compile, pas warning runtime.
**Checklist étendue avant de marquer une extraction "done"** :
```bash
# Pour chaque symbole supprimé/non destructuré, grep dans le template
for symbol in normalizedQuery directoryOfficeLabel; do
echo "=== $symbol ==="
# Patterns template critiques
grep -nE "v-(if|else-if|show)=\"[^\"]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
grep -nE "\\{\\{[^}]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
grep -nE "(:|@)[a-z-]+=\"[^\"]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
done
```
**QA visuel obligatoire post-refactor** : pour tout refactor qui touche une page importante, ouvrir la page en browser dev avant de pousser :
- naviguer aux états critiques (search, modals, toggle accordion)
- vérifier la console : zéro `[Vue warn]` ou `ReferenceError`
- tester au moins un workflow complet par section refactorée
- Contexte technique : Vue 3 / Volar / `vue-tsc` — RL799_V2 29-04-2026
---
<a id="risque-symboles-orphelins-suppression-bloc"></a>
## Symboles orphelins après suppression d'un bloc — checklist grep
### Risques
- Refactor où on supprime un bloc cohérent (state + computeds + handlers) en utilisant un Edit ciblé. Tout compile, tous les tests passent, mais au runtime : `ReferenceError: <symbole> is not defined` au mount
- Le symbole supprimé était encore référencé dans un **lifecycle hook** (`onMounted`, `onUnmounted`), un **watcher**, ou un **handler asynchrone** non inclus dans le bloc supprimé
### Symptômes
- Page qui ne mount plus après refactor, alors que typecheck OK + tests verts
- Erreur visible uniquement à `cmd+R` sur la page
### Bonnes pratiques / mitigations
```bash
# Pour chaque symbole déclaré dans le bloc à supprimer
for symbol in updatePointerType pointerMql closeInsertMenuOnOutsideClick; do
echo "=== $symbol ==="
grep -n "\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
done
```
Doit retourner **uniquement les lignes du bloc à supprimer**. Si une référence apparaît hors du bloc → ne pas supprimer sans traiter explicitement (déplacer, adapter, ou laisser).
**Lieux à vérifier en priorité** :
| Lieu | Fréquence du piège |
|------|--------------------|
| `onMounted(() => { ... })` | ⚠️⚠️⚠️ très fréquent |
| `onUnmounted(() => { ... })` | ⚠️⚠️⚠️ très fréquent (cleanup) |
| `watch(() => x, () => { fn() })` | ⚠️⚠️ fréquent |
| Handler async (`.then(() => fn())`) | ⚠️ rare mais existe |
| Computed dans une autre section du fichier | ⚠️ rare |
| Template (`@click`, `:disabled`) | typecheck attrape via SFC plugin |
**Garde-fou complémentaire** : QA visuel obligatoire post-refactor (cf. `risque-templates-vue-references-orphelines`).
- Contexte technique : Vue 3 — RL799_V2 29-04-2026
---
<a id="risque-extraction-vue-ts-bug-typage-latent"></a>
## Extraction `.vue` → composable `.ts` révèle des bugs de typage latents
### Risques
- Vue 3 + Volar compile les blocs `<script setup>` avec une stratégie d'inférence moins agressive que `tsc` strict pur. Un handler peut compiler dans le `.vue` si le runtime n'utilise pas le champ manquant
- Le composable extrait est compilé par `tsc -p tsconfig.typecheck.json` **hors contexte Vue** → toutes les règles strictes s'appliquent → la divergence de type devient une erreur bloquante
- C'est un effet de bord **positif** mais qui peut bloquer l'extraction tant qu'on n'a pas diagnostiqué la divergence
### Symptômes
```
Type 'X' is not assignable to type 'Y'.
The types of '<champ>.<sous-champ>' are incompatible…
Type 'A' is missing the following properties from type 'B': …
```
Cas typique : un emit Vue annonçait `Detail` (type pour la liste) alors que la fonction service renvoyait `Data` (type pour la mutation). Silencieux dans le `.vue` d'origine, devenu visible dans le `.ts` extrait.
### Bonnes pratiques / mitigations
Trois cas typiques quand l'erreur apparaît après extraction :
1. **Le type annoncé ne matche pas le type réellement émis** : aligner sur le type réellement émis plutôt que sur le type annoncé dans `defineEmits`. Si possible, corriger aussi la signature de l'émetteur — mais c'est un autre scope
2. **Le `.vue` exploitait un cast implicite** : `v-if="x.foo"` réduit le union type. Dans un `.ts` extrait, narrow explicitement avec un `if` ou un type guard
3. **Volar n'analysait pas un chemin de type complexe** : type récursif, génériques imbriqués, `Pick<...>` dans une union → extraire un alias intermédiaire propre dans `@<module>/types.ts`
**Quoi faire face à l'erreur** :
1. **NE PAS** mettre `as Foo` pour faire taire le compilateur — c'est probablement masquer le même bug sous un autre nom
2. Identifier lequel des deux types est correct (généralement celui que la fonction service / l'API renvoie réellement)
3. Aligner la signature du handler/composable sur ce type-là
4. Documenter dans un commentaire au-dessus du handler que le type émis diverge du type annoncé dans `defineEmits` (si on ne corrige pas l'émetteur dans le même refactor)
5. Ouvrir un TODO si la correction de l'émetteur est hors scope
**Recommandation outillage** : `vue-tsc` plutôt que `tsc` pur en typecheck (cf. `risque-templates-vue-references-orphelines`). Ce genre de divergence aurait été détecté **avant** le refactor.
- Contexte technique : Vue 3 / Volar / `vue-tsc` — RL799_V2 29-04-2026
---
<a id="risque-fallback-catch-all-mapping-statut-db-ui"></a>
## Fallback catch-all dans le mapping statut DB → UI
### Risques
- Un mapping statut DB → statut UI avec un `return` catch-all final accepte implicitement **toute** nouvelle valeur d'enum DB sans alerte
- Une nouvelle valeur (ex : `pending`) atterrit alors dans la branche par défaut (ex : rendue comme `processing`) avec un libellé incorrect, violant silencieusement le contrat d'affichage
### Symptômes
- Une valeur d'enum DB non explicitement gérée affiche le mauvais état UI (ex : "En cours…" au lieu de "En attente")
- L'ajout d'une valeur d'enum côté DB ne déclenche aucune erreur de compile ni de test ; le mauvais libellé passe inaperçu
### Bonnes pratiques / mitigations
```ts
// ❌ Le catch-all masque silencieusement "pending" sous "processing"
function dbStatusToUiStatus(s: DBStatus): UIStatus {
if (s === "ready") return "ready";
if (s === "failed") return "failed";
return "processing"; // "pending" atterrit ici sans libellé correct
}
// ✅ Mapping exhaustif : un if par valeur d'enum
function dbStatusToUiStatus(s: DBStatus): UIStatus {
if (s === "ready") return "ready";
if (s === "failed") return "failed";
if (s === "pending") return "pending";
return "processing";
}
```
- 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