mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-06-28 10:03:40 +02:00
5f5c87296e
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>
861 lines
36 KiB
Markdown
861 lines
36 KiB
Markdown
# 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
|