Files
_Assistant_Lead_Tech/knowledge/frontend/risques/state.md
MaksTinyWorkshop b3417ad77b capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)
Triage du 95_a_capitaliser.md (~75 propositions) :
- 60 entrées intégrées dans knowledge/ (backend, frontend, workflow)
- 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md,
  frontend/patterns/general.md, workflow/patterns/general.md
- 6 doublons rejetés
- Mise à jour des READMEs index pour refléter les nouvelles entrées
- 95_a_capitaliser.md restauré à sa structure initiale
- 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant
- 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI,
  prisma migrate diffs cosmétiques

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:12:44 +02:00

22 KiB

Frontend — Risques & vigilance : State

Extrait de la base de connaissance Lead_tech. Voir knowledge/frontend/risques/README.md pour l'index complet.


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

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

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)

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

} 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

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

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

É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

// ❌ 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


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

// ❌ 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

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

// ❌ 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


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

// ❌ 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


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

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

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

// ❌ 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

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

// ❌ 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

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

# 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
<!--  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

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" :

# 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


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

# 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

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