docs(knowledge): capitalisation frontend — intégration du triage local (mai-juin 2026)

Triage et intégration des propositions frontend du buffer 95_a_capitaliser.md
(lot local RL799_V2/Vue3 + app-alexandrie/RN-Expo, mai-juin 2026).

~73 entrées intégrées sur knowledge/frontend/ + 1 nouveau fichier, dont :
- patterns/state.md : race-token partagé latest-wins (fusion 3 props), capture sync anti-race,
  event bus timestamp, clé cache composite, état dérivé = computed
- risques/state.md : 9 risques Zustand/store (fetchId reset, useRef remount, re-fetch infini
  sur [], flag optimiste écrasé, cache détail/liste stale, latch sans reset, :key index)
- patterns/navigation.md : Expo Router (tab bar, useFocusEffect, Href typé, routing pur fusionné)
- patterns/general.md : helpers temps purs, composants génériques + skeleton, fail-fast, touch target
- risques/general.md : 24 risques (sweep statique, filtre client liste paginée, hooks avant return,
  a11y VoiceOver/disabled, redirection allowlist, RangeError toISOString, section i18n...)
- design-tokens (cluster theming light/dark MD3), tests, performance, react-native, nextjs
- NOUVEAU risques/responsive.md (gating par capacité d'input + checklist régressions mobile)
- READMEs patterns/risques mis à jour

Doublons inter-fichiers évités (vérifié : aucune ancre dupliquée introduite).
Rejets (doublons 91/9/87), reciblages workflow (156/257) et bloc 32 (CLAUDE projet) non intégrés ici.
Source 95_a_capitaliser.md non purgée (purge en fin de capitalisation complète).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MaksTinyWorkshop
2026-06-25 15:31:53 +02:00
parent f1b783407a
commit 5f5c87296e
15 changed files with 2439 additions and 12 deletions
+4 -4
View File
@@ -8,10 +8,10 @@ Avant toute proposition frontend, identifie le fichier dont le nom et la descrip
| Fichier | Domaine | Entrées clés |
|---------|---------|--------------|
| `state.md` | State management, UI states, Zustand, listes paginées, refactor monolithe Vue | États UI loading/empty/error, séparation server/client state, refresh idempotent, UI admin légère, refactor monolithe Vue sous-lots Go/No-Go, convention `pages/<module>/`, `styles.css` partagé non-scoped, annuaire client-side TTL |
| `state.md` | State management, UI states, Zustand, listes paginées, refactor monolithe Vue | États UI loading/empty/error, séparation server/client state, refresh idempotent, UI admin légère, refactor monolithe Vue sous-lots Go/No-Go, convention `pages/<module>/`, `styles.css` partagé non-scoped, annuaire client-side TTL, race-token partagé (latest-wins), capture synchrone before async, event bus via timestamp, clé de cache composite, loadings séparés initial/pagination, flags d'état séparés par préoccupation, dérivé = `computed`, une source deux vues lecture inerte, noyau visuel générique + variants |
| `forms.md` | Formulaires, validation, Server Actions, optimistic UI | Formulaire robuste, toggle optimiste rollback, Server Action retourne entité, AppInput Outlined Material thème dark, fusion DRY composants jumeaux par prop discriminante |
| `navigation.md` | Navigation, routing, Expo Router, intégrations tierces | Navigation réactive post-action async, link-out page locale canonique, factorisation page mode dynamique via `meta.mode` typé |
| `design-tokens.md` | Design tokens, typographie, spacing, Tailwind, RN StyleSheet | Tokens TypeScript Expo/RN, typography sémantique, export styles composant, grilles 2 colonnes |
| `navigation.md` | Navigation, routing, Expo Router, intégrations tierces | Navigation réactive post-action async, link-out page locale canonique, factorisation page mode dynamique via `meta.mode` typé, tab bar native cachée (wiring routing pur), état UI éphémère via `useFocusEffect`, routes typées `Href`, stack indépendant par tab, routing décorrélé du rendu (builder pur) |
| `design-tokens.md` | Design tokens, typographie, spacing, Tailwind, RN StyleSheet | Tokens TypeScript Expo/RN, typography sémantique, export styles composant, grilles 2 colonnes, palette light/dark MD3 + `useThemedColors` + dual export, map sémantique slug → token, migration tokens typo formalisés |
| `nextjs.md` | Next.js App Router, embeds, ESLint | Click-to-load embeds tiers, ESLint flat config Next.js |
| `tests.md` | Tests styles React Native, smoke checks, mount + mock composable | Tests de styles sans renderer JSX, smoke checks `readFileSync`, classe CSS modifier vs texte, cleanup E2E best-effort, helpers SW purs, mount + mock composable, assertions React Email, garde-fous de non-activation feature parking Later |
| `general.md` | Focus visible, inputs date HTML5, journaux/audit logs, pages admin | Focus visible interne pour overflow clip, restyle global `<input type="date">`, UI patterns journaux d'audit, structuration pages admin (eyebrows + grille filtres + variante danger) |
| `general.md` | Focus visible, inputs date HTML5, journaux/audit logs, pages admin, composants génériques | Focus visible interne pour overflow clip, restyle global `<input type="date">`, UI patterns journaux d'audit, structuration pages admin, reset défensif pointer events, modale dédiée vs partagée, fail-fast branche unreachable `__DEV__`, extension rétrocompatible vs sibling, CTA toggle async préserver l'état, helpers fail-safe data backend, prop de raccourci, compositions sémantiques utilisées, touch target 44 minWidth+minHeight, helpers temps/date purs, avatar initiale pastel, composants génériques + états de chargement (EmptyState/Skeleton) |
@@ -184,3 +184,106 @@ it('variante primary utilise colors.primary', () => {
1. `.spec.ts` (node) : tokens, valeurs, logique pure
2. `.spec.tsx` (config séparée avec renderer) : rendu visuel, interactions
---
<a id="pattern-palette-light-dark-md3-usethemedcolors"></a>
## Pattern : Palette light/dark MD3 + hook `useThemedColors` + dual export
### Synthèse
- **Objectif** : poser un theming light/dark/system complet sur une codebase qui n'avait qu'une palette unique, sans casser les imports historiques.
- **Contexte** : React Native / Expo avec préférence utilisateur light/dark/system.
- **Quand l'utiliser** : introduction d'un 2ᵉ thème sur une base existante.
- **Quand l'éviter** : thème unique sans besoin de bascule (le hook serait du sucre sans bénéfice).
### Analyse
- **Avantages** :
- compat ascendante : `export const colors = dark` garde les imports `import { colors }` compilants
- le hook `useThemedColors` rend le reste mécanique (le composant ne code jamais le scheme en dur)
- dual export pour les styles legacy : `export const fooStyles = makeStyles(colors)` (snapshot dark) reste synchronisé avec la fonction dynamique
- **Limites / vigilance** :
- anti-pattern : palette plate sans typage par mode → force à coder le scheme en dur ou à dupliquer les styleSheets
- tokens "fixed" partagés light/dark (`primaryContainer`, `primaryFixedDim`) pour la continuité du branding ; `inverse-primary` d'un mode = `primary` de l'autre
- shadow : `#000000` OK en dark, mais en light un tinted neutre (ex. `outline` à 8 %), jamais noir pur
- validation WCAG : README documentant chaque token + ratio mesuré (texte ≥ 4.5:1, UI ≥ 3:1)
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native / Expo — app-alexandrie (ux-cleanup-8, ~95 fichiers migrés)
### Implémentation
```ts
// theme/colors.ts
const dark = { background: '#131316', primary: '#cdbdff' } as const;
const light = { background: '#fbf8fe', primary: '#4c00c9' } as const;
export const colors = dark; // compat ascendante (imports historiques)
export const colorsLight = light;
export const colorsDark = dark;
// theme/use-themed-colors.ts
export function useThemedColors() {
return useEffectiveColorScheme() === 'light' ? colorsLight : colorsDark;
}
// composant
const themed = useThemedColors();
const styles = useMemo(() => makeStyles(themed), [themed]);
function makeStyles(c: ReturnType<typeof useThemedColors>) {
return StyleSheet.create({ container: { backgroundColor: c.background } });
}
// styles exportés (compat tests) — source unique
export const fooStyles = makeStyles(colors); // snapshot dark
```
> Les pièges de migration associés (composants tiers, `<Text>` sans `color`, scheme effectif, ThemeProvider) sont documentés dans `risques/design-tokens.md#risque-theming-light-dark-pieges-caches`.
---
<a id="pattern-map-semantique-slug-token-icone"></a>
## Pattern : Map sémantique slug → token + icône
### Synthèse
- **Objectif** : différencier visuellement des entités d'un même type (forums, packs, badges) via une map slug → token résolue au runtime, sans hex en dur.
- **Contexte** : entités multiples d'un même type nécessitant un code couleur/icône cohérent light/dark.
- **Quand l'utiliser** : ≥ 2 entités à différencier visuellement.
- **Quand l'éviter** : entité unique ou différenciation purement textuelle.
### Analyse
- **Structure** : `getEntityBadgeTokens(slug) → { bgColorKey, fgColorKey, icon }`, le caller résout via `useThemedColors` (suit le thème).
- **Règles** : (1) toujours des `ColorKey` de `useThemedColors`, jamais de hex ; (2) toujours un fallback neutre (un slug futur ne casse pas l'UI) ; (3) icônes outline légères ; (4) tests env node sur map + fallback + distinctness.
- **Avantages** : vs if/else inline → 0 duplication ; vs hex map → suit le thème ; vs composant → reste un helper pur testable env node.
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native — app-alexandrie (`forum-badge.ts`, ux-cleanup-15)
---
<a id="pattern-migration-tokens-typo-formalises"></a>
## Pattern : Migration tokens typo formalisés
### Synthèse
- **Objectif** : formaliser et migrer les valeurs typographiques en dur (`fontSize`, `fontWeight`, `fontFamily`) vers des tokens, comme le sweep tokens couleur.
- **Contexte** : codebase avec ~100 occurrences de valeurs typo en dur sur des dizaines de fichiers.
- **Quand l'utiliser** : volumétrie significative de valeurs typo dispersées.
- **Quand l'éviter** : poignée d'usages localisés.
### Analyse
- **Méthodologie en 4 étapes** : (1) audit grep + rapport stats ; (2) étendre `typography.ts` SANS renommer les tokens existants (rétro-compat) — ajouter des aliases sémantiques ; (3) migration mécanique (perl/sed) + imports, typecheck après chaque batch ; (4) doc README (table sémantique + anti-patterns).
- **Cas dégénérés à laisser en dur** : `fontSize: 48/64` sur un emoji/hero = taille d'icône, pas de la typo → ne pas créer un token `iconLarge` (documenter l'exception).
- **Anti-patterns** : codemod auto sans review visuel (un `15 → 14` casse un layout) ; renommer un token existant (`fontSize.tab` utilisé partout → casse 50+ fichiers) ; créer `useThemedTypography()` si la typo ne varie pas light/dark (sucre sans bénéfice, garder l'import statique).
### Validation
- Validé le : 30-05-2026
- Contexte technique : React Native — app-alexandrie (ux-cleanup-12, 110 occurrences / 33 fichiers, 0 régression)
+368
View File
@@ -375,3 +375,371 @@ Sur un écran qui mélange actions constructives et destructives (ex : saisie in
- Hint général qui répète ce que l'empty state dit déjà → 1 message, 1 niveau d'info
- Confondre "titre de page" (l'onglet actif suffit souvent) et "structure de section" (eyebrows)
- Action destructive en variant primary or → danger explicite
---
<a id="pattern-reset-defensif-pointer-events"></a>
## Pattern : Reset défensif du state pointer events au `pointerdown`
### Synthèse
- **Objectif** : éviter qu'un composant swipe/drag reste figé dans un état corrompu (`dragging = true`) quand un `pointerup` est "volé" par une modale ou une navigation post-action.
- **Contexte** : composant gérant `@pointerdown / @pointermove / @pointerup` qui émet une action ouvrant une modale au `pointerup` (la modale prend le focus → le pointer quitte la card → `pointerup` ne remonte jamais).
- **Quand l'utiliser** : tout composant à geste pointer émettant une action qui change de surface (modale, nav).
- **Quand l'éviter** : geste pur sans effet de bord de navigation.
### Analyse
- **Avantages** : robuste face aux modales/navigations/focus switches ; un seul point d'entrée à protéger ; pas de logique conditionnelle complexe.
- **Limites / vigilance** : ne couvre pas le démontage du composant pendant le drag (géré par le cycle de vie Vue). Pattern complémentaire : flag anti-click synthétique pour ne pas déclencher un `@click` natif après le geste.
### Validation
- Validé le : 05-05-2026
- Contexte technique : Vue 3 / pointer events — RL799 (`ProfaneListCard`)
### Implémentation
```typescript
const handlePointerDown = (event: PointerEvent) => {
if (event.pointerType === 'mouse' && event.button !== 0) return;
resetSwipe(); // défense en profondeur : un pointerup précédent a pu être "volé"
activePointerId.value = event.pointerId;
// …
};
// flag anti-click synthétique : posé en pointerup, consommé dans @click
const handleClick = (): void => {
if (swipeJustHandled.value) { swipeJustHandled.value = false; return; }
emit('select');
};
```
---
<a id="pattern-modale-dediee-vs-partagee"></a>
## Pattern : Modale dédiée vs partagée — décider à la 2ᵉ contrainte
### Synthèse
- **Objectif** : décider en amont s'il faut étendre une modale partagée (`v-if`/`forcedType`/`mode`) ou créer une modale dédiée quand un type métier se décline en variantes aux contraintes d'édition différentes.
- **Contexte** : type métier (Document) décliné en variantes (planche, helper) avec champs obligatoires/validations/types autorisés divergents.
- **Quand l'utiliser** : dès qu'une variante diverge d'un cas standard.
- **Quand l'éviter** : variantes ne différant que par le wording (préférer alors la factorisation par mode).
### Analyse
- **Critère de décision** : modale **dédiée dès que ≥ 2 contraintes divergent** (type figé vs éditable, champ exclusif, champs masqués, validation conditionnelle). 1 seule contrainte → prop conditionnelle sur la modale partagée.
- **Avantages dédiée** : impossible de corrompre la catégorie (type figé en TS, jamais dans le patch) ; pas d'explosion combinatoire de `v-if`.
- **Limites / vigilance** :
- une modale partagée avec select à fallback peut **silencieusement** basculer une ressource d'une catégorie à l'autre (cas vécu : `TYPE_OPTIONS` sans `'helpers'` → fallback `'planches'` au save)
- duplication ~80 % (a11y, focus trap, layout) : extraire un composable partagé pour le focus trap si identique, classes BEM dédiées sinon
- **Détection en revue** : `grep "forcedType\|forcedMode\|isVariantX"` dans une modale partagée — si > 3 occurrences, refactorer en modale dédiée.
### Validation
- Validé le : 06-05-2026
- Contexte technique : Vue 3 — RL799 (`DocumentEditModal``HelperEditModal`/`HelperUploadModal`)
---
<a id="pattern-fail-fast-branche-unreachable-dev"></a>
## Pattern : Fail-fast sur branche unreachable en `__DEV__`
### Synthèse
- **Objectif** : éviter qu'un `return;` no-op silencieux sur une branche unreachable (garantie par un early-return en amont) ne devienne un bug fantôme si un refactor casse l'invariant.
- **Contexte** : `switch`/`match` dont un case est inatteignable par construction (ex. CTA paywall jamais atteint car `SUBSCRIPTION_REQUIRED` fait un early-return avant le rendu).
- **Quand l'utiliser** : toute branche unreachable par invariant.
- **Quand l'éviter** : branche réellement atteignable (gérer le cas normalement).
### Analyse
- **Avantages** : tout refactor cassant l'invariant fait crasher l'app en dev (feedback immédiat) ; la prod reste safe (`return;` no-op) ; le commentaire documente l'invariant et la story.
- **Limites / vigilance** : anti-pattern `// no-op pour l'instant (cas marginal)` sans throw — le cas marginal devient un bug silencieux le jour où il se réalise.
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native — app-alexandrie (ux-cleanup-7, CTA "Débloquer ce pack")
### Implémentation
```typescript
switch (model.action) {
case 'paywall':
if (__DEV__) throw new Error('[story-X] CTA paywall atteint — invariant cassé ?');
return; // prod : no-op safe
// …
}
```
---
<a id="pattern-extension-retrocompatible-composant"></a>
## Pattern : Extension rétrocompatible d'un composant vs création d'un sibling
### Synthèse
- **Objectif** : étendre un composant existant via une prop optionnelle plutôt que créer un sibling quasi-dupliqué, quand le composant convient à ~80 % d'un nouveau use-case.
- **Contexte** : composant UI (ex. FAB circulaire icon-only) à étendre pour un nouveau cas (porter un label).
- **Quand l'utiliser** : quand 4 critères tiennent (voir Analyse).
- **Quand l'éviter** : logique métier divergente, ou props mutuellement exclusives qui s'accumulent (signal de 2 composants déguisés).
### Analyse
- **Critères pour étendre** : (1) la partie variante (label, icon, taille) ≠ la partie invariante (positionnement, ombre, animation, a11y) ; (2) l'invariant DOIT rester partagé (un seul fichier à toucher quand la nav change) ; (3) la prop d'extension est optionnelle avec un défaut préservant le comportement actuel ; (4) pas de logique métier divergente.
- **Avantages** : 1 fichier, 1 batch de tests, call-sites existants intacts (0 régression).
- **Limites / vigilance** : si le rendu fait `if (mode === 'X') … else …` plus de 2-3 fois → deux composants déguisés, séparer. Anti-pattern : `Fab.tsx` + `FabExtended.tsx` avec ~80 % de styles dupliqués (un bug de positionnement se corrige dans un seul fichier et ressurgit sur l'autre écran).
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native — app-alexandrie (FAB partagé Messages/Contenu/Forum)
### Implémentation
```typescript
type FabProps = {
icon: MaterialIconName;
onPress: () => void;
accessibilityLabel: string;
label?: string; // optionnel → bascule en mode extended si présent
bottomOffset?: number;
};
const isExtended = typeof label === 'string' && label.length > 0;
// call-site existant inchangé : <Fab icon="add" onPress={...} accessibilityLabel="Nouveau message" />
```
---
<a id="pattern-cta-toggle-async-preserver-etat"></a>
## Pattern : CTA toggle async — préserver l'état pendant le loading
### Synthèse
- **Objectif** : garder visible l'indicateur de l'état pré-toggle (ex. icône check "Suivi") pendant le chargement async, pour ne pas perdre le repère visuel.
- **Contexte** : bouton toggle d'état (Suivre/Suivi, like/unlike) avec action async.
- **Quand l'utiliser** : tout CTA toggle dont l'action est asynchrone.
- **Quand l'éviter** : action synchrone instantanée.
### Analyse
- **Règle** : ne pas remplacer la **totalité** de l'état visible par un spinner — garder au moins un indicateur de l'état pré-toggle pour la durée du loading.
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native — app-alexandrie (`directory-follow-button.tsx`, ux-cleanup-9)
### Implémentation
```tsx
// ✅ l'icône check reste visible, hors du ternaire isLoading
<View style={containerStyle}>
{isFollowing && <Icon name="check" />}
{isLoading ? <Spinner /> : <Label>{label}</Label>}
</View>
```
---
<a id="pattern-helpers-fail-safe-data-backend"></a>
## Pattern : Helpers fail-safe face aux bugs data backend
### Synthèse
- **Objectif** : qu'un helper de transformation donnée → affichage traite les cas dégénérés (date future, 0, NaN, format invalide) avec un comportement neutre, sans amplifier le bug visuellement.
- **Contexte** : helpers de formatage/dérivation consommant une donnée backend potentiellement bugguée.
- **Quand l'utiliser** : tout helper qui mappe une donnée externe vers de l'UI.
- **Quand l'éviter** : donnée 100 % maîtrisée localement.
### Analyse
- **Anti-patterns** : `createdAt` futur → "tout récent" → tous les contenus affichent "Nouveau" (cascade) ; `duration === 0` → "moins d'1 min" trompeur.
- **Règle** : retour neutre (`false` / `''` / `'—'`) + `console.warn` en `__DEV__` pour remonter la régression API sans polluer la prod.
### Validation
- Validé le : 30-05-2026
- Contexte technique : React Native — app-alexandrie (ux-cleanup-15, `isContentRecent`/`formatDuration`)
### Implémentation
```ts
export function isContentRecent(createdAt, now = new Date()) {
if (!createdAt) return false;
const created = parse(createdAt);
if (Number.isNaN(created.getTime())) return false;
const ageMs = now.getTime() - created.getTime();
if (ageMs < 0) {
if (__DEV__) console.warn('[isContentRecent] createdAt futur:', createdAt);
return false; // neutre, n'amplifie pas le bug
}
return ageMs < THRESHOLD;
}
```
---
<a id="pattern-props-raccourci-composant-generique"></a>
## Pattern : Prop de raccourci pour composant générique
### Synthèse
- **Objectif** : éviter qu'un cas d'usage dominant ne force chaque callsite à un wrapper répétitif, en ajoutant une prop de raccourci.
- **Contexte** : composant générique (`<EmptyState>`) appelé partout avec le même wrapper (`icon={<ThemedText style={{fontSize:48}}>📭</ThemedText>}`).
- **Quand l'utiliser** : wrapper identique répété sur plusieurs callsites.
- **Quand l'éviter** : variété réelle de wrappers (garder la prop générique seule).
### Analyse
- **Règle** : la prop générique (`icon?: ReactNode`) reste prioritaire pour les cas custom ; la prop raccourci (`emoji?: string`) injecte un wrapper standard.
- **Garde-fou** : 1-2 props de raccourci max. Si le cas devient complexe, exposer un sous-composant (`<EmptyState.Emoji>`) ou refactorer.
### Validation
- Validé le : 30-05-2026
- Contexte technique : React Native — app-alexandrie (`EmptyState`, ux-cleanup-15)
---
<a id="pattern-compositions-semantiques-utilisees"></a>
## Pattern : Compositions sémantiques — la migration doit les utiliser
### Synthèse
- **Objectif** : éviter de définir des compositions sémantiques (`typography.body`, `colors.actionPrimary`) que la migration n'utilise pas, laissant les tokens atomiques s'imposer et le sémantique devenir du code mort.
- **Contexte** : migration vers un design system avec compositions + tokens atomiques.
- **Quand l'utiliser** : toute migration introduisant des compositions sémantiques.
### Analyse
- **Règle** : la migration trie — composition existante pour le besoin → l'utiliser ; sinon token atomique + signaler en review qu'une composition pourrait être créée.
- **Ratio cible** : > 50 % d'usages de compositions. Si < 30 %, soit elles sont mal nommées, soit à supprimer (sur-engineering).
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native — app-alexandrie (ux-cleanup-12/13)
---
<a id="pattern-touch-target-44-minwidth-minheight"></a>
## Pattern : Touch target 44pt — imposer `minWidth` ET `minHeight`
### Synthèse
- **Objectif** : garantir une zone tap 44×44 (HIG iOS), pas 44×N — `minHeight: 44` seul laisse la largeur < 44 sur un label court.
- **Contexte** : composants génériques (`<EmptyState ctaButton>`, `<Button>`) où le caller fournit le label.
- **Quand l'utiliser** : tout CTA dont le label peut être court ("OK", "Annuler", chip).
- **Quand l'éviter** : zone tap déjà large par construction.
### Analyse
- **Cas d'oubli typique** : CTA court → label étroit + padding insuffisant → zone < 44 en width malgré `minHeight: 44` (miss-tap fréquent sur iPhone SE / gants).
- **Trade-off** : `minWidth: 44` est un floor invisible, rarement contraignant pour des labels longs.
### Validation
- Validé le : 31-05-2026
- Contexte technique : React Native — app-alexandrie (ux-cleanup-14)
### Implémentation
```tsx
ctaButton: { minHeight: 44, minWidth: 44, justifyContent: 'center', alignItems: 'center' }
```
---
<a id="pattern-helpers-temps-date-purs"></a>
## Pattern : Helpers temps/date purs centralisés (formatage relatif, groupement)
### Synthèse
- **Objectif** : centraliser le formatage temporel (date relative, groupement par blocs) dans des helpers purs testables env node, pour éviter la divergence de wording entre écrans et entre projets.
- **Contexte** : écrans multiples affichant des dates (Notifications, Messages, Feed) ou un timeline de messages séquencés (chat, commentaires).
- **Quand l'utiliser** : dès que > 1 écran formate des dates relatives, ou qu'une liste temporelle sature de timestamps.
- **Quand l'éviter** : un seul affichage de date trivial.
### Analyse
- **Avantages** : helper pur, `now` injectable → tests déterministes ; 0 dépendance lib (Intl natif, -50 Ko vs date-fns/dayjs) ; le 1er dev d'une story partagée écrit le helper, les suivants consomment.
- **Décisions structurantes** :
- bornes "hier"/jours semaine = **jours calendaires** (`startOfDay(now) startOfDay(date)`), pas deltas en ms (sinon 24h pile ≠ perception humaine)
- pas de "dans N min" pour une date future (clock skew) → retomber sur "à l'instant"
- tolérance gracieuse : `string` ISO invalide → fallback, jamais de crash
- groupement chat : séparateur centré quand delta > 5 min OU changement de jour ; 1er message toujours précédé d'un séparateur ; la fonction attend l'ordre **croissant**, le caller reverse si liste `inverted` (ne pas coupler la logique à l'index FlatList)
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native — app-alexandrie (`relative-time.ts`, `timestamp-grouping.ts`, ux-cleanup-10/11)
### API
```ts
formatRelativeTime(input: Date | string, now: Date = new Date()): string
// → "à l'instant" / "il y a N min" / "il y a N h" / "hier" / "lun" / "12 mars" / "12 mars 2025"
groupMessagesByTime<T>(messages: T[], now?: Date): GroupedItem<T>[]
// GroupedItem = { kind: 'timestamp' | 'message'; ... }
```
---
<a id="pattern-avatar-initiale-pastel-deterministe"></a>
## Pattern : Avatar circulaire avec initiale pastel déterministe
### Synthèse
- **Objectif** : différencier visuellement des utilisateurs sans photo (early adopters, B2B interne) via une initiale colorée déterministe.
- **Contexte** : listes d'utilisateurs où les photos de profil sont rares.
- **Quand l'utiliser** : ~quelques centaines d'utilisateurs, photos optionnelles.
- **Quand l'éviter** : photos systématiques, ou besoin d'unicité forte (collisions visuelles assumées ici).
### Analyse
- **Implémentation** : palette ~7 couleurs mappées sur tokens design system + 1 fallback grisé ; hash trivial (somme charCodes du nom trimmé modulo `palette.length - 1`) ; 1ère lettre majuscule (préserver accents) ; `null`/vide → fallback "?" grisé.
- **Avantages** : 0 dépendance, testable env node, suit light/dark, déterministe (un nom = toujours la même couleur).
- **Anti-patterns** : couleur aléatoire au mount (casse la reconnaissance) ; hash sur UUID (opaque, change au renommage — hasher le nom affiché) ; lib crypto pour le hash (surdimensionné).
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native — app-alexandrie (`avatar-initial.tsx`, ux-cleanup-11)
### API
```ts
<AvatarInitial name={"charlie" | null} size={40} />
```
---
<a id="pattern-composants-generiques-etats-chargement"></a>
## Pattern : Composants UI génériques + états de chargement (EmptyState, Skeleton)
### Synthèse
- **Objectif** : factoriser le layout des écrans liste (vide / chargement) dans des composants génériques tout en laissant le wording au caller pour respecter la voix produit.
- **Contexte** : N écrans liste partageant un layout d'état vide et un skeleton de premier chargement.
- **Quand l'utiliser** : ≥ 2 écrans liste avec états vide/chargement similaires.
- **Quand l'éviter** : écran unique sans réutilisation.
### Analyse
- **`<EmptyState>`** : API `{ icon?, emoji?, title, description?, cta? }`. Le caller fournit le wording (calme, bienveillant, non alarmiste) et un CTA **contextuel uniquement quand utile** (pas de "Voir tout" sur une liste vide qui ne pointe nulle part).
- **`<SkeletonScreen>`** : API `{ variant: 'card'|'list-item'|'text-line'|'header', count }`, shimmer animé (une seule shared value pour synchroniser les sous-blocs).
- **Règle CRITIQUE des 3 états de chargement** :
- **premier chargement** (`isLoading && items.length === 0`) → `<SkeletonScreen>`
- **refresh** (`isLoading && items.length > 0`) → `RefreshControl` natif
- **pagination** (`isLoadingMore`) → `ListFooterComponent` (ActivityIndicator)
- **Anti-patterns** : skeleton/spinner plein écran pendant un refresh (l'utilisateur perd ses items, clignotement) ; skeleton statique sans animation (signal "ça travaille" perdu) ; skeleton avec wording "Chargement…" (double signal redondant) ; wording technique ("Liste de 0 éléments").
- **Tests env node** : `react-native-reanimated` doit être mocké (hooks no-op, `Animated.View` → string).
### Validation
- Validé le : 31-05-2026
- Contexte technique : React Native — app-alexandrie (`EmptyState.tsx`, `SkeletonScreen.tsx`, ux-cleanup-13/15)
+215
View File
@@ -277,3 +277,218 @@ const handleSubmit = async () => {
- Détecter via query string (pollue URL, manipulable)
---
<a id="pattern-tab-bar-native-cachee-minimum-vital"></a>
## Pattern : Tab bar native cachée → ne garder que le wiring routing
### Synthèse
- **Objectif** : quand la tab bar native d'Expo Router est désactivée (`tabBarStyle.display: 'none'`) au profit d'une nav persistante custom, ne garder dans `(tabs)/_layout.tsx` que le minimum vital de déclaration de routes.
- **Contexte** : Expo Router avec `<PersistentTabBar />` custom remplaçant la tab bar native.
- **Quand l'utiliser** : dès que le visuel des tabs vit dans un composant custom et non dans `<Tabs>`.
- **Quand l'éviter** : tab bar native conservée (le `label`/icônes restent utiles).
### Analyse
- **Avantages** :
- les `<Tabs.Screen>` ne servent plus qu'à déclarer les routes physiques du dossier `(tabs)/` (sinon 404 sur certaines plateformes)
- suppression du dead-code visuel (`label`, `iconActive`, `tabBarBadge`…) et de ses dépendances inutiles (icons, stores de badges)
- l'intention "wiring routing pur, pas du visuel" devient explicite (`TAB_ROUTE_NAMES`)
- **Limites / vigilance** :
- un `TAB_CONFIG` riche "au cas où on réactiverait la tab bar native" masque l'intention et crée des incohérences silencieuses (ordre du config ≠ ordre visuel réel)
- **règle complémentaire** : tout hook/effet de boot alimentant un état consommé par la nav persistante (ex. `refreshUnreadCount` pour la cloche TopBar) appartient au `_layout.tsx` **racine**, pas au layout `(tabs)/` — sinon il ne s'exécute que sur les routes `(tabs)/*` et le badge ne s'hydrate pas ailleurs
### Validation
- Validé le : 27-05-2026
- Contexte technique : React Native / Expo Router — app-alexandrie (IA-v2.1)
### Implémentation
```tsx
// Les <Tabs.Screen> déclarent uniquement les routes physiques.
// La nav visuelle vit dans <PersistentTabBar />.
const TAB_ROUTE_NAMES = ['explore', 'community', 'library', 'profile',
'notifications', 'achievements', 'settings'] as const;
return (
<Tabs screenOptions={{ headerShown: false, tabBarStyle: { display: 'none' } }}>
{TAB_ROUTE_NAMES.map((name) => <Tabs.Screen key={name} name={name} />)}
</Tabs>
);
```
---
<a id="pattern-etat-ui-ephemere-usefocuseffect"></a>
## Pattern : État UI éphémère lié au focus écran via `useFocusEffect` (Expo Router)
### Synthèse
- **Objectif** : nettoyer un état UI éphémère qu'un écran pose dans un composant global persistant (TopBar, BottomBar, FAB contextuel) au **blur**, pas au démontage.
- **Contexte** : Expo Router, où un écran enfant n'est pas toujours démonté à la navigation arrière (gardé en arrière-plan, surtout en web).
- **Quand l'utiliser** : dès qu'un écran pose un état consommé par un composant global persistant.
- **Quand l'éviter** : état strictement local à l'écran, sans surface globale.
### Analyse
- **Avantages** : le cleanup se déclenche à la perte de focus, garanti même si l'écran reste monté.
- **Limites / vigilance** :
- `useEffect` + cleanup au démontage laisse un titre/état fantôme sur le parent (ex. titre "Bob" qui reste après retour sur la liste Messages)
- bug souvent invisible sur natif (démontage plus systématique), visible en web → smoke multi-plateforme indispensable
### Validation
- Validé le : 27-05-2026
- Contexte technique : React Native / Expo Router — app-alexandrie (IA-v2.2, `useTopBarStore`)
### Implémentation
```ts
// ❌ titre fantôme : cleanup au unmount, pas garanti au blur
useEffect(() => {
if (!peerHandle) return;
useTopBarStore.getState().setTitle(peerHandle);
return () => useTopBarStore.getState().setTitle(null);
}, [peerHandle]);
// ✅ cleanup au blur
import { useFocusEffect } from 'expo-router';
useFocusEffect(useCallback(() => {
useTopBarStore.getState().setTitle(peerHandle);
return () => useTopBarStore.getState().setTitle(null);
}, [peerHandle]));
```
---
<a id="pattern-routes-expo-router-typees-href"></a>
## Pattern : Routes Expo Router typées en `Href` au lieu de `as never`
### Synthèse
- **Objectif** : supprimer le cast `router.push('/route' as never)` répété à chaque callsite au profit de constantes `Href` centralisées.
- **Contexte** : Expo Router sans typed routes générés, qui pousse à caster en `never`.
- **Quand l'utiliser** : dès qu'une route est poussée depuis plusieurs callsites.
- **Quand l'éviter** : typed routes activés (le type généré suffit).
### Analyse
- **Avantages** :
- 1 seul cast par route au lieu d'un par callsite
- migration future vers `typedRoutes` triviale (remplacer `as Href` par le type généré sans toucher aux callsites)
- auto-complétion sur les constantes plutôt que sur des string literals
- **Limites / vigilance** : ne pas confondre avec la navigation réactive — les routes restent poussées dans des handlers, pas dans un `useEffect` (voir `risques/navigation.md`).
### Validation
- Validé le : 27-05-2026
- Contexte technique : React Native / Expo Router — app-alexandrie (IA-v2.5 L4)
### Implémentation
```typescript
import { useRouter, type Href } from 'expo-router';
const CHECKOUT_HREF = '/subscription/checkout' as Href;
const PACKS_HREF = '/packs' as Href;
// callsites :
onPress={() => router.push(CHECKOUT_HREF)}
onPress={() => router.push(PACKS_HREF)}
```
---
<a id="pattern-stack-independant-par-tab-derniere-route"></a>
## Pattern : Stack indépendant par tab via mémorisation de la dernière route
### Synthèse
- **Objectif** : qu'un tap sur un onglet re-atterrisse sur sa dernière route visitée (pas sur la racine) quand la `Tabs` native est désactivée + BottomBar custom.
- **Contexte** : Expo Router, `Tabs` native off, `<PersistentTabBar />` custom.
- **Quand l'utiliser** : quick win avant un refactor archi `(app)/` complet (option a).
- **Quand l'éviter** : besoin d'un back-stack complet par tab (préférer alors un `Stack` natif par tab).
### Analyse
- **Avantages** : low-risk, sans refactor archi ; mémorise la dernière route par tab dans un store.
- **Limites / vigilance** :
- **garde-fou clé** : exclure les routes auth/onboarding du tracking, sinon le tap sur un onglet ramène vers un onboarding terminé → écran vide/crash
- limites assumées en v2 : seulement la **dernière** route (pas de back-stack), pas de persistance disque (perdu au cold start)
### Validation
- Validé le : 27-05-2026
- Contexte technique : React Native / Expo Router — app-alexandrie (IA-v2.8 AC5)
### Implémentation
```typescript
type RememberableTab = 'explore' | 'community' | 'messages' | 'library';
const EXCLUDED = ['/(auth)', '/login', '/onboarding'];
export const useTabHistoryStore = create<{
lastRoutes: Partial<Record<RememberableTab, string>>;
setLastRoute: (tab: RememberableTab, route: string) => void;
}>((set) => ({
lastRoutes: {},
setLastRoute: (tab, route) => {
if (EXCLUDED.some((p) => route.startsWith(p))) return; // garde-fou
set((s) => ({ lastRoutes: { ...s.lastRoutes, [tab]: route } }));
},
}));
// _layout.tsx racine : observe pathname → pose la dernière route par tab
// BottomBar : au tap d'un tab non-actif, router.push(lastRoutes[tab] ?? tab.href)
```
---
<a id="pattern-routing-decorrele-rendu-fn-pure"></a>
## Pattern : Routing décorrélé du rendu (builder pur testable)
### Synthèse
- **Objectif** : extraire le calcul de la route cible (`(payload) => Href | null`) du composant qui navigue, pour le rendre pur et testable en env node.
- **Contexte** : navigation conditionnelle (route dépendant du payload, du type d'événement, de l'état utilisateur) ; Expo Router et autres routers.
- **Quand l'utiliser** : dès qu'une route est templatée inline ou qu'un `switch` de routing vit dans un composant.
- **Quand l'éviter** : navigation triviale vers une route fixe.
### Analyse
- **Avantages** :
- testable env node : 0 mock RN, 0 `NavigationContainer`, juste `expect(fn(...)).toBe('/path/x')`
- cas edge centralisés : un seul `switch`, un seul fallback `null`, un seul format d'URL
- type-safe si typed routes activés (le `pathname` est vérifié contre l'arbre réel)
- **pas de slug hardcodé** : si la source ne connaît pas le slug, on omet le param plutôt que de mentir — l'écran cible retombe sur sa résolution serveur
- **Limites / vigilance** :
- anti-pattern : `router.push(\`/community/thread/${id}?forumSlug=general\`)` quand `'general'` n'a aucune chance d'être le bon slug → fonctionne par accident (fallback serveur), illisible
- quand le caller a l'objet complet, exposer une **2ᵉ** fonction qui prend l'objet et délègue à la primitive (évite de dupliquer le `switch`)
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native / Expo Router — app-alexandrie (ux-cleanup-4 / ux-cleanup-9 / ux-cleanup-10 ; `feed-navigation.ts`, `notifications-navigation.ts`)
### Implémentation
```ts
// builder pur — forme objet { pathname, params }
export function buildThreadRoute(item: { id: string; forum?: { slug: string } | null }) {
const slug = item.forum?.slug;
return {
pathname: '/community/thread/[threadId]',
params: slug ? { threadId: item.id, forumSlug: slug } : { threadId: item.id },
} as const;
}
// primitive (bouts un par un) + dérivée (objet complet) coexistent
export function resolveNotificationRoute(type, targetId, commentId): string | null { /* switch */ }
export function routeForNotification(n: NotificationItem): string | null {
return resolveNotificationRoute(n.targetType, n.targetId, n.commentId);
}
// composant : délègue, ne décide pas
const route = resolveNotificationRoute(notif.targetType, notif.targetId, notif.commentId);
if (route) router.push(route);
```
---
+395
View File
@@ -475,3 +475,398 @@ const useEntityDirectory = (options: { ttlMs?: number } = {}) => {
return { directory, lastFetchedAt, loadDirectory, refresh };
};
```
---
<a id="pattern-race-token-partage-latest-wins"></a>
## Pattern : Race-token partagé (latest-wins) dans un store paginé
### Synthèse
- **Objectif** : garantir qu'un store paginé applique toujours le résultat de la dernière intention utilisateur, même si les réponses réseau reviennent dans le désordre.
- **Contexte** : store de domaine (Zustand, Pinia…) où plusieurs actions mutent la même liste (`fetchFeed`, `refresh`, `loadMore`) et peuvent être déclenchées en parallèle (changement de filtre pendant un load, spam de chips).
- **Quand l'utiliser** : dès qu'un changement de filtre/onglet peut survenir pendant un chargement déjà en vol.
- **Quand l'éviter** : action unique sans concurrence possible, ou actions coûteuses où il vaut mieux annuler les requêtes stale (voir vigilance).
### Analyse
- **Avantages** :
- latest-wins : la dernière intention gagne quel que soit l'ordre de résolution réseau
- un seul compteur partagé protège toutes les actions de la liste, pas seulement `fetchFeed`
- propre pour tests + HMR si le compteur vit dans la closure du `create` (par-instance)
- **Limites / vigilance** :
- un guard `if (get().isLoading) return;` sur `fetchFeed` est un **anti-pattern** : il drop silencieusement un changement de filtre survenu pendant le load initial
- race-token appliqué **uniquement** à `fetchFeed` est insuffisant : un `loadMore` in-flight peut résoudre après un `fetchFeed` plus récent et concaténer des items de l'ancien filtre
- N taps → N requêtes réseau (acceptable pour chips/toggles ; pour actions coûteuses, préférer `AbortController` côté http-client)
- `reset()` doit remettre le compteur à 0 (sinon pollution entre tests / après HMR)
### Validation
- Validé le : 20-05-2026
- Contexte technique : React Native / Expo / Zustand — app-alexandrie (stories 11.2 / 11.3)
### Implémentation
```typescript
export const useFeedStore = create((set, get) => {
let lastFetchId = 0; // partagé entre toutes les actions, vit dans la closure
return {
async fetchFeed(token, opts) {
const myId = ++lastFetchId;
set({ isLoading: true });
try {
const data = await service.getFeed(token, opts);
if (myId !== lastFetchId) return; // stale → drop
set({ items: data.items, isLoading: false });
} catch (err) {
if (myId !== lastFetchId) return;
set({ error: String(err), isLoading: false });
}
},
async refresh(token, opts) {
if (get().isRefreshing) return;
const myId = ++lastFetchId; // même compteur
// … idem, applique seulement si myId === lastFetchId
},
async loadMore(token, opts) {
const { hasMore, nextCursor, isLoadingMore } = get();
if (!hasMore || !nextCursor || isLoadingMore) return;
const myId = ++lastFetchId; // même compteur
// … append uniquement si myId === lastFetchId (sinon stale → drop)
},
reset: () => { lastFetchId = 0; set({ ...initialState }); },
};
});
```
### Documenter le design par un test (pas par un commentaire)
L'absence de guard `isLoading` ressemble à un bug en review. Le test EST la spec : il faut un test qui démontre le latest-wins, sinon la prochaine revue interprétera (à tort) la suppression du guard comme une régression.
```typescript
it('3 fetchFeed concurrents résolus dans le désordre : seul le dernier applique', async () => {
// lancer p1, p2, p3 avant qu'aucun ne résolve
// résoudre dans l'ordre p2 → p1 → p3
// assert : items reflètent uniquement p3
});
```
### Checklist
- [ ] Compteur `lastFetchId` partagé entre `fetchFeed` / `refresh` / `loadMore`
- [ ] Chaque action drop son résultat si `myId !== lastFetchId`
- [ ] `reset()` remet le compteur à 0
- [ ] Test "réponses dans le désordre → dernier gagne"
- [ ] Test "loadMore in-flight pendant fetchFeed → loadMore drop"
---
<a id="pattern-capture-synchrone-before-async-zustand"></a>
## Pattern : Capture synchrone "before" dans une action async (anti race latch)
### Synthèse
- **Objectif** : pour une transition monotone (latch) calculée à partir de l'état précédent, figer l'état observé **avant** le `await` pour éviter qu'un appel concurrent ne corrompe le calcul.
- **Contexte** : action async d'un store qui dérive un nouvel état de l'ancien (`previousIsActive`, latch d'abonnement…) et qui peut être appelée en parallèle.
- **Quand l'utiliser** : transitions monotones / latch lus depuis `get()` puis recombinés après `await`.
- **Quand l'éviter** : action sans dépendance à l'état précédent, ou action non-monotone (préférer alors le race-token latest-wins).
### Analyse
- **Avantages** :
- le calcul observe un snapshot cohérent, immunisé contre une mutation concurrente
- complémentaire du race-token (qui résout les actions non-monotones)
- **Limites / vigilance** :
- lire `get()` **après** le `await` expose à un état déjà muté par un second appel
### Validation
- Validé le : 27-05-2026
- Contexte technique : React Native / Zustand — app-alexandrie (`entitlements.store.ts`, fix H1 IA-v2.5)
### Implémentation
```typescript
// ❌ MAUVAIS — état lu APRÈS await, peut être muté par un appel concurrent
fetchEntitlements: async (token) => {
set({ isLoading: true });
const data = await service.getMe(token);
const wasActive = get().subscription?.isActive ?? false; // lu trop tard
set({ subscription: data.subscription, previousIsActive: wasActive ? true : null });
}
// ✅ BON — capture synchrone "before" avant l'await
fetchEntitlements: async (token) => {
const before = get();
const wasActive = before.subscription?.isActive ?? false;
const wasLatched = before.previousIsActive === true;
set({ isLoading: true });
const data = await service.getMe(token);
const isNowActive = data.subscription.isActive;
const previousIsActive = wasLatched || wasActive || isNowActive ? true : null;
set({ subscription: data.subscription, previousIsActive });
}
```
**Test associé** : `Promise.all([store.action(), store.action()])` doit produire le même état final que deux appels séquentiels.
---
<a id="pattern-event-bus-zustand-timestamp"></a>
## Pattern : Event bus via timestamp pour signaux UI inter-composants
### Synthèse
- **Objectif** : envoyer un signal d'un composant à un autre non-parent (ex. BottomBar → écran de l'onglet actif "rafraîchis-toi") sans prop drilling, sans EventEmitter à nettoyer, sans Context qui re-render tout le sous-arbre.
- **Contexte** : store minimal posant un `timestamp` par cible ; un hook consommateur déclenche un callback au changement de timestamp.
- **Quand l'utiliser** : signal fire-and-forget ponctuel entre composants découplés.
- **Quand l'éviter** : flux de données continu (préférer un state dérivé) ou parent-enfant direct (props/events suffisent).
### Analyse
- **Avantages** :
- le timestamp seul déclenche le `useEffect` (pas un state data → pas de boucle)
- le ref vivant sur le callback dispense l'appelant de `useCallback`
- testable comme un store classique (`setState`, assertions sur la map)
- **Limites / vigilance** :
- ❌ booléen + reset : race entre consommateurs, reset dur à placer
- ❌ EventEmitter Node-style : pas de garantie de re-render, cleanup à gérer
- ❌ Context React : re-render tout le sous-arbre à chaque tap
### Validation
- Validé le : 27-05-2026
- Contexte technique : React Native / Zustand — app-alexandrie (IA-v2.8 AC1)
### Implémentation
```typescript
type RefreshableTab = 'explore' | 'community' | 'messages' | 'library';
export const useTabActionStore = create<{
refreshTimestamps: Partial<Record<RefreshableTab, number>>;
requestRefresh: (tab: RefreshableTab) => void;
}>((set) => ({
refreshTimestamps: {},
requestRefresh: (tab) =>
set((s) => ({ refreshTimestamps: { ...s.refreshTimestamps, [tab]: Date.now() } })),
}));
// émetteur — fire & forget
useTabActionStore.getState().requestRefresh('explore');
// consommateur — hook réutilisable
export function useTabRefresh(tab: RefreshableTab, onRefresh: () => void) {
const timestamp = useTabActionStore((s) => s.refreshTimestamps[tab]);
const lastSeenRef = useRef<number | undefined>(undefined);
const onRefreshRef = useRef(onRefresh);
onRefreshRef.current = onRefresh; // ref vivant, pas de boucle deps
useEffect(() => {
if (timestamp === undefined || timestamp === lastSeenRef.current) return;
lastSeenRef.current = timestamp;
onRefreshRef.current();
}, [timestamp]);
}
```
---
<a id="pattern-cache-zustand-cle-composite"></a>
## Pattern : Clé de cache composite sur action async paramétrée
### Synthèse
- **Objectif** : éviter de servir un cache stale quand une action async indexe son résultat sur un seul paramètre alors que d'autres paramètres modifient le résultat.
- **Contexte** : store qui mémorise un résultat par paramètre métier (`slug`, `id`) et qui court-circuite le refetch via une égalité de clé.
- **Quand l'utiliser** : dès qu'une action de fetch prend plus d'un paramètre influençant le résultat.
- **Quand l'éviter** : action mono-paramètre, ou cache géré par une lib (React Query) qui clé déjà sur l'ensemble des args.
### Analyse
- **Avantages** :
- le cache reflète exactement les paramètres qui produisent le résultat
- pas de dépendance fragile à un `clearXxx()` externe
- **Limites / vigilance** :
- clé partielle (`packSlug` seul) → navigation A→B du même pack sert B avec l'exclusion de A toujours active
### Validation
- Validé le : 29-05-2026
- Contexte technique : Zustand — app-alexandrie (code review ux-cleanup-7)
### Implémentation
```typescript
// ❌ Cache stale si excludeId change mais pas packSlug
async fetchPackContents(token, packSlug, excludeId) {
if (get().packContentsSlug === packSlug) return;
// …
}
// ✅ Clé composite : un champ d'état par paramètre métier
async fetchPackContents(token, packSlug, excludeId) {
const normalizedExcludeId = excludeId ?? null;
const sameKey =
get().packContentsSlug === packSlug &&
get().packContentsExcludeId === normalizedExcludeId;
if (sameKey && (get().isLoading || get().items.length > 0)) return;
// …
set({ packContentsSlug: packSlug, packContentsExcludeId: normalizedExcludeId });
}
```
**Règle** : N paramètres métier influençant le résultat → N champs de clé dans l'état.
---
<a id="pattern-loadings-separes-initial-pagination"></a>
## Pattern : Loadings séparés (fetch initial vs pagination)
### Synthèse
- **Objectif** : éviter que le pull-to-refresh tourne pendant l'infinite scroll en distinguant deux flags de chargement de natures différentes.
- **Contexte** : store qui supporte à la fois fetch initial/refresh ET pagination (`fetchNextPage`).
- **Quand l'utiliser** : toute liste avec `RefreshControl` + chargement de pages.
- **Quand l'éviter** : liste sans pagination, ou sans pull-to-refresh.
### Analyse
- **Avantages** : le `RefreshControl` ne s'anime que pour le refresh ; le spinner de bas de liste pour la pagination.
- **Limites / vigilance** : un seul `isLoading` partagé → l'écran clignote, le refresh tourne pendant le scroll. Côté anti-pattern, voir aussi `risques/state.md#risque-flag-isloading-unique-nature-differente`.
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native / Zustand — app-alexandrie (ux-cleanup-10 M4, `notifications.store.ts`)
### Implémentation
```typescript
type State = {
items: T[];
isLoading: boolean; // fetch initial OU pull-to-refresh
isLoadingMore: boolean; // pagination (fetchNextPage)
};
```
```tsx
<FlatList
refreshing={isLoading} // pull-to-refresh seulement
ListFooterComponent={isLoadingMore ? <Spinner/> : null}
onEndReached={() => store.fetchNextPage(...)}
/>
```
---
<a id="pattern-flags-etat-separes-par-preoccupation"></a>
## Pattern : Flags d'état séparés par préoccupation (liste / création / mutation-par-item)
### Synthèse
- **Objectif** : éviter qu'un `isSubmitting`/`error` unique partagé entre `create`, `update(id)` et `remove(id)` ne fasse passer tous les boutons d'une liste en `loading` quand on agit sur une seule ligne.
- **Contexte** : store qui gère liste + création + mutation-par-item (agnostique Pinia/Vuex/Redux/Zustand).
- **Quand l'utiliser** : dès qu'une liste a des actions par item ET un formulaire de création.
- **Quand l'éviter** : store mono-action sans liste interactive.
### Analyse
- **Avantages** : chaque préoccupation a son flag → boutons ciblés, actions enchaînables, erreurs au bon endroit.
- **Limites / vigilance** : un `isSubmitting` unique provoque (1) tous les boutons de toutes les lignes en `loading`, (2) actions non-enchaînables, (3) erreur de mutation affichée dans un formulaire de création sans rapport. Pour le variant booléen-vs-`Set`, voir `risques/state.md#risque-flag-global-actions-paralleles`.
### Validation
- Validé le : 13-06-2026
- Contexte technique : Vue 3 / Pinia — RL799_V2 (`instructionsStore.ts`, code review)
### Implémentation
```typescript
// ❌ partagé : :loading="store.isSubmitting" sur chaque ligne
// ✅ séparé par préoccupation
isLoading / loadError // chargement de la liste
isCreating / createError // formulaire de création
submittingId: string | null // mutation par item
mutationError // erreur de mutation
// UI ligne : :loading="store.submittingId === item.id"
```
---
<a id="pattern-derive-computed-pas-ref-resync"></a>
## Pattern : État dérivé = `computed`, jamais un `ref` resynchronisé à la main
### Synthèse
- **Objectif** : supprimer les désyncs silencieuses d'un compteur/dérivé recalculé manuellement à chaque chemin de mutation.
- **Contexte** : composable Vue 3 exposant une valeur toujours dérivée d'un autre état réactif (`size`, `length`, total, booléen `isEmpty`).
- **Quand l'utiliser** : toute valeur jamais assignée indépendamment, toujours recalculée depuis une source.
- **Quand l'éviter** : valeur réellement indépendante (saisie utilisateur, état piloté en propre).
### Analyse
- **Avantages** : recalcul automatique à chaque changement de la source, zéro synchro manuelle, impossible d'oublier un chemin.
- **Limites / vigilance** :
- anti-pattern : `const count = ref(set.value.size)` + `syncCount()` rappelé dans chaque mutation → un oubli produit un compteur faux **sans erreur**
- exposer le dérivé en `ComputedRef<T>` (pas `Ref<T>`) pour signaler le read-only
- réactivité `Set`/`Map` : la mutation in-place (`set.add()`) ne déclenche rien — réassigner (`checked.value = new Set(checked.value)`) ; un `computed(() => checked.value.size)` se met alors à jour (il dépend de l'identité du Set)
- **Garde-fou de revue** : dans un composable, tout `ref` toujours recalculé depuis un autre `ref` est un `computed` déguisé.
### Validation
- Validé le : 18-06-2026
- Contexte technique : Vue 3 / Composition API — RL799_V2 (`useMcChecklist`, code review v2-2-2)
---
<a id="pattern-une-source-deux-vues-lecture-inerte"></a>
## Pattern : Une source, deux vues — la vue lecture inerte par construction
### Synthèse
- **Objectif** : présenter la même donnée en mode édition ET en mode lecture/présentation sans dupliquer la donnée ni la logique, en prouvant l'inertie du mode lecture **par construction** (aucun mutateur câblé) plutôt que par un garde runtime.
- **Contexte** : écran consultation/édition partageant un même fetch (préparation vs pédagogique, édition vs consultation).
- **Quand l'utiliser** : deux rendus d'une même donnée pilotés par un `viewMode` local.
- **Quand l'éviter** : modes aux données réellement disjointes.
### Analyse
- **Avantages** :
- un seul fetch, deux rendus via `v-if/v-else` sur un `ref<'edit'|'read'>`
- le mode lecture ne **référence** aucun mutateur → inertie garantie, pas besoin de `if (mode === 'read') return` dans chaque handler
- un composable d'état qui **lit** au mount reste inerte tant que ses mutateurs ne sont pas appelés (la lecture n'est pas un effet de bord) → il peut être instancié pour les deux modes
- **Limites / vigilance** :
- `viewMode` est un état d'affichage **local non persisté** (pas de la donnée métier)
- préférer un toggle in-place (un `viewMode` + `v-if` dans la page) à l'extraction d'un composant partagé tant qu'une autre story touche le même rendu — surface de merge minimale, factoriser à la 3ᵉ vraie divergence
- **Prouver l'inertie par test** : monter en mode lecture, spy sur `localStorage.setItem`/`fetch`, cliquer les éléments lecture, asserter aucun appel + clé localStorage restée nulle.
### Validation
- Validé le : 18-06-2026
- Contexte technique : Vue 3 — RL799_V2 (vue pédagogique MC, segmented control préparation↔pédagogique, code review v2-2-3)
---
<a id="pattern-noyau-visuel-partage-variants"></a>
## Pattern : Noyau visuel générique + comportements variants greffés
### Synthèse
- **Objectif** : faire servir un même artefact visuel positionnel (plan, diagramme, grille de placement) à deux finalités différentes sans dupliquer le rendu ni coupler les intentions via un `variant`.
- **Contexte** : un visuel (SVG, carte, plateau) doit afficher un état ET servir de sélecteur de navigation.
- **Quand l'utiliser** : à la **2ᵉ** utilisation de la même géométrie (pas avant — règle « factoriser à la Nᵉ utilisation »).
- **Quand l'éviter** : usage unique, ou intentions trop divergentes pour partager un contrat de props minimal.
### Analyse
- **Avantages** :
- le noyau ne connaît QUE l'invariant : géométrie, accessibilité clavier, contrat `nodes: {id, state}`, un seul événement neutre `select(id)`
- les consommateurs sont des **adaptateurs minces** : ils mappent leurs données métier vers `nodes` et retraduisent `select(id)`
- le 1er consommateur garde une signature publique inchangée (zéro régression, prouvée par typecheck + test de mount), le 2ᵉ usage est purement additif
- **Limites / vigilance** :
- mettre l'état « inerte » (plateau non cliquable) dans le **noyau** (garde de non-émission au clic ET au clavier), pas seulement en CSS ni dans chaque adaptateur
- dériver `nodes` d'une source unique exhaustive (ex. `RITUAL_OFFICER_ROLES`) plutôt qu'une liste en dur → un ajout futur apparaît automatiquement, pas de nœud manquant silencieux
### Validation
- Validé le : 13-06-2026
- Contexte technique : Vue 3 / SVG — RL799_V2 (`LodgeFloorPlanBase` consommé par `LodgeFloorPlan` collège + `ModuleFloorPlan` navigation, chantier surveillant-mobile-floorplan)
+9 -8
View File
@@ -9,11 +9,12 @@ Avant toute proposition frontend, identifie le fichier dont le nom et la descrip
| Fichier | Domaine | Entrées clés |
|---------|---------|--------------|
| `auth.md` | Auth, guards de rôle, entitlements, OAuth | Auth côté client, loading infini écran gated, bouton OAuth vide, guard rôle flash UX |
| `state.md` | Zustand, state management, erreurs async, optimistic UI | Erreurs silencieuses, catch sans feedback, auto-reset état dégradé, fire-and-forget refresh, boolean UI hardcodé, flag isLoading unique, erreur sans rethrow, optimistic update sous-listes, fallback catch-all mapping statut DB → UI |
| `navigation.md` | Expo Router, Vue Router, deep link, useEffect fetch, contexte store | Store vide deep link/reload, guard incomplet états terminaux, collection sans clé contexte, double route racine, router-link disabled, état local query param, fichiers non-route sous `src/app` Expo Router |
| `design-tokens.md` | Design tokens, spacing, Tailwind, StyleSheet RN | Double système espacement, dimensions via spacing, inline styles dashboard, classes Tailwind invalides |
| `nextjs.md` | Next.js App Router, SSR, Server Actions, sécurité | useSearchParams sans Suspense, type ViewData dupliqué, composant React .ts, double validation segment, consent state ambigu, script inline XSS, window.location.reload, useTransition snapshot, window.confirm, import type server, img natif, useTransition global liste, formulaire defaultValue sans key |
| `tests.md` | Jest, ts-jest, tests React Native, Vue | Config node bloque .tsx, faux test négatif, helpers copiés, test écran indirect, test façade flux réel, tests présence textuelle |
| `performance.md` | Re-renders, memoization, useCallback, fetch | Sur-renders bundle non maîtrisé, useCallback inutile inline, fetch sans timeout |
| `react-native.md` | React Native, fetch, ScrollView, TextInput | Focus ring TextInput, contentInset iOS-only, fetch sans response.ok |
| `general.md` | Accessibilité, regex, patterns transversaux, monorepo | Accessibilité oubliée a11y, regex globale singleton lastIndex, Alert.prompt iOS-only, primitive UI couplée, migration partielle classes legacy, ARIA roles sans clavier, duplication logique métier monorepo, event listeners globaux modales, boutons imbriqués, fire-and-forget sans feedback, type client non MAJ extension payload backend |
| `state.md` | Zustand, state management, erreurs async, optimistic UI | Erreurs silencieuses, catch sans feedback, auto-reset état dégradé, fire-and-forget refresh, boolean UI hardcodé, flag isLoading unique, erreur sans rethrow, optimistic update sous-listes, fallback catch-all mapping statut DB → UI, race `fetchEntity(reset)` early-return, `useRef(undefined)` hook réactif store, logique métier dispersée vs sélecteur dérivé, `useEffect` data.length===0 re-fetch infini, flag optimiste écrasé par hydratation, mutation détail non propagée à la liste, latch sans reset au changement de session, `:key` par index sur liste d'inputs, optimistic update slice absente |
| `navigation.md` | Expo Router, Vue Router, deep link, useEffect fetch, contexte store | Store vide deep link/reload, guard incomplet états terminaux, collection sans clé contexte, double route racine, router-link disabled, état local query param, fichiers non-route sous `src/app` Expo Router, icône découplée de la destination au renommage, activation nav path vs query |
| `design-tokens.md` | Design tokens, spacing, Tailwind, StyleSheet RN, theming light/dark | Double système espacement, dimensions via spacing, inline styles dashboard, classes Tailwind invalides, token inexistant sans fallback (border-color → currentColor), theming light/dark pièges cachés, nouveau site `colors.X` pendant migration `useThemedColors`, hook themed sans consommateur |
| `nextjs.md` | Next.js App Router, SSR, Server Actions, sécurité | useSearchParams sans Suspense, type ViewData dupliqué, composant React .ts, double validation segment, consent state ambigu, script inline XSS, window.location.reload, useTransition snapshot, window.confirm, import type server, img natif, useTransition global liste, formulaire defaultValue sans key, corrélation multi-onglets localStorage vs sessionStorage |
| `tests.md` | Jest, ts-jest, tests React Native, Vue | Config node bloque .tsx, faux test négatif, helpers copiés, test écran indirect, test façade flux réel, tests présence textuelle, dupliquer styles pour tester un hook, fix plumbing visuel sans test, fan-out sans assertion de compte, stub enfant props calculées, module gardé par office (trous de test) |
| `performance.md` | Re-renders, memoization, useCallback, fetch, bundle | Sur-renders bundle non maîtrisé, useCallback inutile inline, fetch sans timeout, bundle SPA non code-splitté, helper ordre figé → reverse caller, `FlatList renderItem` sans `React.memo` |
| `react-native.md` | React Native, fetch, ScrollView, TextInput, mobile web | Focus ring TextInput, contentInset iOS-only, fetch sans response.ok, `accessibilityRole=summary` + enfant header, bouton retour dans ScrollView, pull-to-refresh mobile lib vs custom |
| `general.md` | Accessibilité, regex, patterns transversaux, monorepo, i18n | Accessibilité oubliée a11y, regex globale singleton, Alert.prompt iOS-only, primitive UI couplée, migration partielle classes legacy, ARIA roles sans clavier, duplication logique métier monorepo, event listeners globaux modales, boutons imbriqués, fire-and-forget sans feedback, type client non MAJ extension payload, fichier modifié ≠ propre, sweep grep amorce vs liste finale, sticky bottom + nav persistante, filtre client-side liste paginée, bucket défaut par négation, code smells JS/TS, Pressable disabled a11y, redirection sans allowlist, helper usage futur code mort, helper heure courante figé, migration tokens composant CORE oublié, animation switch binaire, `as unknown as` bypass, hitSlop vs layout, hooks après early return, `useEffect` une fois booléen dérivé, double-announce VoiceOver, prop générique usage unique, icône unicode So polychrome, recherche normalisation filtrage vs alignement, deep-link `<details>` racine, retry form sans refetch, format date sans heure, `toISOString()` throw, icône `inheritAttrs:false` + SVG 100%, i18n cohérence label/a11y/comportement/données |
| `responsive.md` | Responsive, adaptation mobile, tablettes tactiles, media queries input | Gating responsive par capacité d'input (pointer fine/coarse) vs largeur seule, régressions mobile lors d'un chantier desktop (checklist QA mobile) |
@@ -154,4 +154,77 @@ Erreurs courantes :
- Contexte technique : design tokens / audit CSS vars — RL799_V2 17-04-2026
### Généralisation : token inexistant sans fallback = propriété silencieusement non appliquée
- Le piège vaut pour **toutes** les familles de tokens, pas seulement la scale d'espacement (`--rm-space-0-5`). Cas traître : `border: 1px solid var(--rm-color-border-subtle)` quand le token n'existe pas et n'a pas de 2ᵉ argument de fallback → `border-color` retombe sur sa valeur initiale `currentColor` (couleur du texte) : la bordure s'affiche mais avec la mauvaise couleur, encore plus discret qu'une bordure absente.
- **Règle** : avant d'utiliser un nom de token "par analogie" (`-subtle`, `-muted`, `-faint`), grep le nom **EXACT** dans le fichier de thème (`grep -- '--rm-color-border-subtle:' theme.css`). La nomenclature n'est pas devinable (RL799 a `border-base/strong/soft/panel/accent`, PAS `subtle`).
- Un token fantôme déjà présent dans N autres fichiers n'est PAS une excuse : il propage le bug. Le corriger ponctuellement + noter la dette pour un nettoyage transverse.
- Détection review : pour chaque `var(--rm-…)` SANS 2ᵉ argument dans un fichier touché, vérifier l'existence de la définition.
- Contexte technique : Vue 3 / CSS — RL799 (code review v2-2-1, `border-subtle``border-soft`), 18-06-2026
---
<a id="risque-theming-light-dark-pieges-caches"></a>
## Theming light/dark : pièges cachés invisibles aux tests unitaires
### Risques
- La majorité des bugs de migration light/dark ne sont visibles **qu'au smoke device** : typecheck, lint et tests unitaires passent sans warning
- Trois familles de causes : composants tiers non thématisés, `<Text>`/`<TextInput>` sans couleur explicite, et mauvaise résolution du scheme effectif
### Symptômes
- Écran 80 % migré mais un bloc reste noir/illisible en dark
- Texte ou placeholder invisible sur fond sombre malgré le remplacement de tous les hex
### Bonnes pratiques / mitigations
- **Composants tiers** (Markdown, charts, WebView) : ils appliquent leurs styles par défaut (`color: '#000'`) qui n'héritent ni de React Navigation ni du hook. Passer un objet `style` mappant explicitement aux tokens (`body`, `paragraph`, `link`, `blockquote`, `code_inline`). Détection : `grep -rln "Markdown\|svg-charts\|WebView"`.
- **`<Text>` sans `color:`** : en RN Web, hérite du défaut OS → invisible sur fond sombre. Détection : `grep -nB1 "<Text" file.tsx | grep -v "color:"`. Ajouter `color: themed.onSurface` (ou `onSurfaceVariant` pour metadata).
- **`<TextInput>` sur RN Web** : `color` ET `placeholderTextColor` sont **obligatoires** ensemble, sinon input/placeholder illisibles en dark.
- **Préférence `'system'` + toggle binaire** : `isDark = preference === 'dark'` est faux quand `preference === 'system'` + OS dark (rendu dark mais `isDark === false`). Utiliser `isDark = useEffectiveColorScheme() === 'dark'` (refléter le **rendu effectif**, pas la préférence brute).
- **`ThemeProvider` React Navigation** : `DefaultTheme`/`DarkTheme` posent leurs `colors.card/text` au-dessus des composants. Construire un `Theme` custom à partir des tokens (`background`, `card`, `text`, `border`, `notification`).
- Anti-pattern global : laisser le smoke device comme seul filet. Le filet préventif est un **grep méthodique post-migration**.
- Contexte technique : React Native / Expo — app-alexandrie (ux-cleanup-8), 29-05-2026
---
<a id="risque-nouveau-site-colors-pendant-migration-themed"></a>
## Nouveau site `colors.X` introduit pendant une migration `useThemedColors`
### Risques
- Pendant qu'une story migre vers `useThemedColors` (light/dark), une autre story crée un **nouveau** fichier qui utilise `import { colors } from '@/theme'` (palette dark figée) + `backgroundColor: colors.background`
- Typecheck et tests passent ; le mode light cassé n'est visible qu'à l'œil. Dette héritée par la story de migration
### Symptômes
- `+not-found.tsx` créé pendant une migration utilise `colors.X` au lieu de `c.X` (résultat de `useThemedColors`)
### Bonnes pratiques / mitigations
- Toute story qui crée un nouveau fichier de rendu **pendant** une migration thème active utilise `useThemedColors` d'office, même hors scope de la migration
- Review : grep `from '@/theme'` sur les fichiers ajoutés du diff ; `colors.X` au lieu de `c.X` = finding HIGH
- Idéalement : ESLint `no-restricted-imports` interdisant l'import nommé `colors` depuis `@/theme` dans `src/app/` et `src/components/` (sauf opt-out commenté)
- Contexte technique : React Native — app-alexandrie (ux-cleanup-4, `+not-found.tsx`), 29-05-2026
---
<a id="risque-hook-themed-sans-consommateur-mort-ne"></a>
## Hook themed sans consommateur = mort-né
### Risques
- Introduire `useThemedXxx()` (couleurs, shadows, typo) sans migrer aucun caller dans le même commit : le hook est invisible à la review (aucun diff ne montre son usage)
- Les composants `import { xxx } from '@/theme'` restent en static (= dark en light) ; la prochaine PR ré-écrit en static croyant que c'est la convention
### Symptômes
- `use-themed-shadows.ts` livré avec 0 caller pendant 4 commits ; 3 callers importaient encore `shadows.button` static
### Bonnes pratiques / mitigations
- Un PR qui ajoute un hook themed DOIT migrer ≥ 1 consommateur réel + ajouter un test prouvant la différence light/dark (`shadowsLight.card.shadowColor !== shadowsDark.card.shadowColor`)
- Contexte technique : React Native — app-alexandrie (ux-cleanup-8), 29-05-2026
---
+563
View File
@@ -819,3 +819,566 @@ Pour toute extension d'un payload backend (ajout de champ), appliquer une **chec
- Contexte technique : Next.js / Server Actions / TypeScript / Zod — app-template-resto 25-06-2026
---
<a id="risque-fichier-modifie-pas-fichier-propre"></a>
## "Fichier modifié" ≠ "fichier propre"
### Risques
- Un écran ancien (avant adoption du design system) reste 100 % styles inline / hex hardcodés / magic numbers. Une story tactique qui n'y modifie que 3 lignes le fait apparaître dans la `File List` sans nettoyer le reste → faux signal "fichier traité dans la story", dette intacte
### Symptômes
- Fichier "récent" en apparence mais saturé de `style={{ … }}`, `#666`, `spacing` en dur, strings non i18n
### Bonnes pratiques / mitigations
- En review, ne pas conclure qu'un fichier est conforme parce qu'il est dans la `File List`. Vérifier explicitement : styles inline, hex hardcodés, magic numbers, strings en dur
- Si la dette dépasse le scope de la story, la **capitaliser** comme dette à refondre (story dédiée), ne pas l'absorber implicitement
- Contexte technique : React Native — app-alexandrie (`thread/[threadId].tsx`), 27-05-2026
---
<a id="risque-sweep-grep-amorce-vs-liste-finale"></a>
## Sweep statique : grep d'amorce ≠ liste finale de candidats
### Risques
- Un sweep préventif (audit, refacto large) propage la liste **brute** du grep d'amorce comme liste finale, sans appliquer le critère discriminant qui justifie le sweep
- Appliquer un fix à tous les hits de l'amorce → modifications no-op ou nuisibles sur les faux positifs
### Symptômes
- Rapport listant N "fichiers suspectés affectés" alors qu'une validation montre k vrais positifs (ex. amorce `contentContainer:` → 28 hits, vrai pattern = + `flexDirection: 'row'` → 5 fichiers)
### Bonnes pratiques / mitigations
1. Publier **deux listes distinctes** : amorce (grep brut) et finale (filtrée par critère discriminant, vérifiée fichier par fichier)
2. Intégrer au rapport le `awk`/`grep` exact qui produit la liste finale (re-vérifiable en 30 s)
3. **Stop condition** : ne pas fixer les fichiers de l'amorce absents de la liste finale
4. En review : tout écart entre liste finale du rapport et liste recalculée = finding HIGH
- Contexte technique : React Native — app-alexandrie (ux-cleanup-1), 28-05-2026
---
<a id="risque-sticky-bottom-nav-persistante-positionnement"></a>
## Sticky bottom + nav persistante : positionnement à recalculer en dynamique
### Risques
- Un sticky `position: absolute` au-dessus d'une nav persistante calcule son `bottom` depuis une constante arbitraire (`BottomTabInset = 50/80`) déconnectée de la hauteur réelle de la nav et du safe-area
- Le CTA est coupé par la nav. Reproduit sur device, invisible en tests Jest
### Symptômes
- Bouton sticky chevauché/coupé par la BottomBar
### Bonnes pratiques / mitigations
```typescript
// ✅ hauteur réelle de la nav (exportée par le composant nav) + safeArea bottom
const insets = useSafeAreaInsets();
<View style={[styles.stickyCta, { bottom: PERSISTENT_TAB_BAR_HEIGHT + insets.bottom }]} />
```
- Exposer la hauteur réelle depuis le composant nav (`export const PERSISTENT_TAB_BAR_HEIGHT = 64`), ne jamais utiliser une constante "à peu près correcte"
- Si cohabitation avec un ScrollView : `paddingBottom = STICKY_HEIGHT + NAV_HEIGHT + insets.bottom` au `contentContainerStyle`
- Contexte technique : React Native — app-alexandrie (ux-cleanup-7), 29-05-2026
---
<a id="risque-filtre-client-side-liste-paginee"></a>
## Filtrer client-side sur une liste paginée → l'écran ment
### Risques
- Un filtre/recherche via `items.filter(...)` sur une liste paginée côté serveur : les items chargés ne matchent pas, mais des pages non chargées le feraient
- L'utilisateur voit une liste vide et conclut faussement "il n'y a rien de ce type" (inversion d'attribution coûteuse en confiance)
### Symptômes
- `const filtered = items.filter(...)` + `ListEmptyComponent="Aucun résultat"` dans un contexte paginé
### Bonnes pratiques / mitigations
1. **Préférer un param API** (`type`, `category`, `tag`) dans la query de pagination — seule solution propre
2. Si non faisable court terme : message UX explicite — quand la liste filtrée tombe à 0 et `hasMore === true`, afficher "Charger plus pour ce filtre" plutôt qu'un "Aucun résultat" trompeur
3. Ne jamais filtrer client en silence sur une liste paginée
- Contexte technique : React Native — app-alexandrie (ux-cleanup-5), 29-05-2026
---
<a id="risque-bucket-defaut-par-negation"></a>
## Bucket "défaut" défini par négation des autres → zone morte invisible
### Risques
- Définir un 3ᵉ bucket par négation (`isNotStarted = !isCompleted && !isInProgress`) crée une dépendance implicite entre prédicats
- Si la sémantique d'un prédicat change (ou si un input désynchronisé arrive), un item peut tomber dans **aucun** bucket et disparaître silencieusement de l'UI (invisible en typecheck/lint/test)
### Symptômes
- Contenu existant absent de toutes les sections de la liste après un changement de sémantique
### Bonnes pratiques / mitigations
```typescript
// ✅ chaque bucket par sa propre condition affirmative
function isNotStarted(c) {
return c.state === 'NOT_STARTED' && (c.completedAt ?? null) === null;
}
```
- Test "désync" : forcer les 3 prédicats à `false` simultanément → s'il existe un cas, l'invariant "exactement 1 bucket" est cassé (le test documente l'invariant)
- Contexte technique : React Native — app-alexandrie (ux-cleanup-6), 29-05-2026
---
<a id="risque-code-smells-js-ts-review"></a>
## Code smells JS/TS à rechercher en review
### Risques
- Ternaire identique des deux côtés (`cond ? a : a`), `??`/`||` avec deux opérandes identiques, variable affectée puis non utilisée différemment de sa valeur initiale → dead code ou intention incomplète
- Ni le compilateur ni le lint (s'il n'est pas configuré) ne signalent ces cas
### Symptômes
- `const labelColor = isFollowing ? c.primary : c.primary;`
### Bonnes pratiques / mitigations
- Détectables via `eslint-plugin-no-constant-binary-expression` / `no-constant-condition` étendu
- Garde-fou review : grep des ternaires/coalescences à opérandes identiques
- Contexte technique : React Native — app-alexandrie (ux-cleanup-9), 29-05-2026
---
<a id="risque-pressable-disabled-accessibility-state"></a>
## `Pressable` disabled sans `accessibilityState.disabled`
### Risques
- Sur React Native, `<Pressable disabled>` empêche la touche mais le screen reader l'annonce toujours comme tappable si `accessibilityState` n'est pas déclaré
### Symptômes
- VoiceOver/TalkBack annonce "Bouton" au lieu de "Bouton estompé" sur un Pressable désactivé
### Bonnes pratiques / mitigations
- Tout `Pressable` avec `disabled` conditionnel doit passer `accessibilityState={{ disabled }}` (comme `selected` pour les chips actifs)
- Contexte technique : React Native — app-alexandrie (ux-cleanup-9, `directory-user-item.tsx`), 29-05-2026
---
<a id="risque-redirection-controlee-par-data-backend"></a>
## Redirection contrôlée par data backend sans allowlist
### Risques
- Un helper qui transforme un champ backend en path de navigation et accepte `targetId.startsWith('/')` ouvre une redirection vers tout écran (`/auth/reset?token=x`, `/community/admin/secret`) si la table backend est corrompue
- Zéro défense en profondeur côté client face à une compromission d'un tier intermédiaire (Redis, queue, service notifs)
### Symptômes
- `if (targetId.startsWith('/')) return targetId;` dans un résolveur de route
### Bonnes pratiques / mitigations
```ts
const ALLOWED_PREFIXES = ['/notifications', '/content/', '/user/', '/messages/', '/profile'];
const isAllowed = (path: string) => ALLOWED_PREFIXES.some((p) => path === p || path.startsWith(p));
```
- Coût du fix = 1 fonction + 1 constante
- Contexte technique : React Native — app-alexandrie (ux-cleanup-10 H1, notif SYSTEM), 29-05-2026
---
<a id="risque-helper-usage-futur-code-mort"></a>
## Helper livré "pour usage futur" sans JSDoc de statut → code mort
### Risques
- Un helper testé mais importé par aucun composant (livré en avance pour une dépendance arrière non encore livrée) crée du code mort et un faux signal "AC livré" reposant en réalité sur une autre couche
### Symptômes
- Helper avec une suite de tests mais 0 import en runtime (détecté par un audit "unused exports")
### Bonnes pratiques / mitigations
- Tout helper livré "pour usage futur" doit avoir une JSDoc documentant son statut + la story qui le branchera
- Contexte technique : React Native — app-alexandrie (ux-cleanup-10 H3, `i18n/notifications.ts`), 29-05-2026
---
<a id="risque-helper-heure-courante-fige-sans-refresh"></a>
## Helper dépendant de l'heure courante figé sans mécanisme de refresh
### Risques
- Un helper basé sur l'heure (`getTimeBasedGreeting`, `formatRelativeTime`) appelé inline dans le JSX fige sa valeur tant que le composant ne re-render pas
- Sur un écran à durée de vie longue, "Bonjour ☀️" reste affiché après 18h
### Symptômes
- `<Text>{getTimeBasedGreeting()}</Text>` sans state ni effet
### Bonnes pratiques / mitigations
```tsx
const [greeting, setGreeting] = useState(() => getTimeBasedGreeting());
useFocusEffect(useCallback(() => { setGreeting(getTimeBasedGreeting()); }, []));
```
- `useFocusEffect` (rafraîchit à chaque ré-ouverture d'onglet) suffit pour des salutations ; `setInterval` pour des compteurs temps réel ("il y a 2 min" → "3 min")
- Contexte technique : React Native / Expo Router — app-alexandrie (ux-cleanup-15 H1), 30-05-2026
---
<a id="risque-migration-tokens-composant-core-oublie"></a>
## Migration de tokens : le composant CORE de référence oublié
### Risques
- Lors d'une migration de tokens (`typography.ts`, `colors.ts`), le composant CORE qui consomme ces tokens (`<ThemedText>`, `<ThemedView>`) est l'oublié — migré partiellement, ou ignoré car l'audit grep cible les "consommateurs" et pas les "définisseurs"
### Symptômes
- Le fichier qui définit les types garde des `fontWeight: 500` / `fontSize: 48` en dur alors que tout le reste est migré
### Bonnes pratiques / mitigations
```bash
grep -rnE "fontSize: [0-9]+\b|fontWeight: [0-9]+\b" src/ --include="*.tsx"
grep "from '@/theme'" src/components/themed-text.tsx # le CORE consomme-t-il les tokens ?
```
- Auditer EN PREMIER le composant CORE
- Contexte technique : React Native — app-alexandrie (ux-cleanup-12 H1, `themed-text.tsx`), 30-05-2026
---
<a id="risque-animation-switch-binaire-clignotement"></a>
## Animation "shimmer/fade/pulse" via switch binaire → clignotement perçu comme bug
### Risques
- Un `value < 0.5 ? colorA : colorB` produit un blink on/off perçu comme un glitch d'affichage, pas comme une animation
### Symptômes
- Skeleton/placeholder qui clignote au lieu de fondre
### Bonnes pratiques / mitigations
```tsx
// ✅ interpolation = transition douce (reanimated)
backgroundColor: interpolateColor(progress.value, [0, 0.5, 1], [colorA, colorB, colorA])
```
- Contexte technique : React Native / reanimated — app-alexandrie (ux-cleanup-13 H1, `SkeletonScreen.tsx`), 31-05-2026
---
<a id="risque-as-unknown-as-bypass-typage"></a>
## `as unknown as X` — signal d'alarme, la lib a souvent le type prévu
### Risques
- Face à une erreur de typage avec une lib, `as unknown as X` bypasse complètement TypeScript et masque le vrai problème (la lib expose un type spécifique à utiliser, ex. `AnimatedStyle<ViewStyle>` de reanimated)
### Symptômes
- `const animatedStyle = useAnimatedStyle(() => ({…})) as unknown as ViewStyle;`
### Bonnes pratiques / mitigations
- Importer et propager le type strict de la lib
- Tout `as unknown as` / `as any as` doit être justifié par un commentaire démontrant que (a) la lib n'expose pas le type adéquat et (b) le contrat runtime est garanti par ailleurs
- Contexte technique : React Native / reanimated — app-alexandrie (ux-cleanup-13 M1), 31-05-2026
---
<a id="risque-hitslop-isolation-vs-layout"></a>
## `hitSlop` calculé en isolation → chevauchement des voisins
### Risques
- Un `hitSlop` calculé pour atteindre 44pt sans tenir compte du layout peut chevaucher les éléments voisins en layout dense (ScrollView horizontal avec gap)
- Taper entre deux éléments active le mauvais
### Symptômes
- Chips avec `hitSlop left/right=6` et gap=8 → cumul 12 sur 8 → chevauchement 4pt
### Bonnes pratiques / mitigations
- Pour atteindre 44pt en height : augmenter top/bottom est sûr ; pour width, plafonner left/right à `gap / 2`
- SectionHeader suivi d'une liste : `hitSlop bottom``marginBottom` du container (ou 8pt par défaut)
- Contexte technique : React Native — app-alexandrie (ux-cleanup-14 M1/M5), 31-05-2026
---
<a id="risque-hooks-apres-early-return"></a>
## Hook appelé après un early return → "Rendered more hooks" (crash bloquant)
### Risques
- Un Hook (`useRouter`) appelé après un early return conditionnel : quand le composant transitionne vers le mode où le Hook est appelé, React crash `Rendered more hooks than during the previous render`
- Invisible aux tests Jest env=node sans renderer JSX
### Symptômes
- `useThemedColors()``if (isHidden) return …``const router = useRouter();`
### Bonnes pratiques / mitigations
- **Tous les Hooks AVANT tout early return** (règle stricte React) — le coût d'un Hook inutilisé est négligeable, le coût du crash est bloquant
- ESLint `react-hooks/rules-of-hooks` en `error` partout, jamais `warn`
- Garde-fou review : grep `const … = use[A-Z]` après un `if … return` dans la fonction
- Contexte technique : React Native — app-alexandrie (ux-cleanup-17 H1, `ThreadCard`), 31-05-2026
---
<a id="risque-useeffect-une-fois-booleen-derive"></a>
## `useEffect` "une fois" piloté par un booléen dérivé du state → re-fire au cycle reset/remplit
### Risques
- Un `useEffect` censé déclencher une action UNE FOIS, gardé par `threadsLoaded = threads.length > 0`, re-fire à chaque cycle "reset puis remplit" du state (un `fetchThreads` qui commence par `set({ threads: [] })`)
- `markForumSeen` rejoué à chaque pull-to-refresh / changement de catégorie → marque comme lus des threads jamais vus
### Symptômes
- Action idempotente (markSeen/markRead) rejouée silencieusement à chaque refetch
### Bonnes pratiques / mitigations
```typescript
// ✅ tracker l'identité de la ressource dans une ref (pas de re-render, reset au unmount)
const markedRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!threadsLoaded || markedRef.current.has(forumSlug)) return;
markedRef.current.add(forumSlug);
void markForumSeen(forumSlug);
}, [threadsLoaded, forumSlug, markForumSeen]);
```
- Cas typiques : analytics (`screen_viewed`), idempotency mark, one-time API calls
- Contexte technique : React Native / Zustand — app-alexandrie (ux-cleanup-22 H1), 31-05-2026
---
<a id="risque-double-announce-voiceover-parent-enfants"></a>
## Double-announce VoiceOver : `accessibilityLabel` parent + enfants accessibles
### Risques
- Un wrapper avec un `accessibilityLabel` couvrant tout son texte ET des enfants interactifs (`Pressable`, `Text onPress`, `role="link"`) avec leurs propres labels → VoiceOver lit le parent entier puis chaque enfant (double lecture)
### Symptômes
- Bulle DM avec liens : l'URL est lue dans le texte du parent puis dans `Lien : URL` de l'enfant
### Bonnes pratiques / mitigations
```tsx
// ✅ si enfants interactifs → wrapper non-accessible, VoiceOver navigue dans les enfants
const hasInteractiveChildren = segments.some((s) => s.type === 'url');
<View accessible={!hasInteractiveChildren}
accessibilityLabel={hasInteractiveChildren ? undefined : `Message : ${text}`} />
```
- Règle : si descendants interactifs avec labels propres → pas de `accessibilityLabel` global + `accessible={false}` sur le wrapper
- Contexte technique : React Native — app-alexandrie (ux-cleanup-19 M1, `message-bubble.tsx`), 02-06-2026
---
<a id="risque-prop-generique-usage-unique-dead-code"></a>
## Prop "générique" introduite pour un usage unique = dead code latent
### Risques
- Justifier un prop d'extension par "ça pourra resservir" crée une API orpheline dès que son unique consommateur disparaît
- Ni typecheck ni lint ne signalent un prop optionnel non utilisé (il reste valide isolément)
### Symptômes
- Prop `trailing` sur un composant + son rendu + ses tests, sans aucun appelant après suppression du seul consommateur
### Bonnes pratiques / mitigations
- Un prop introduit pour UN seul appelant disparaît avec lui : à la suppression d'un consommateur, grep l'usage du prop dans `src/` ; si zéro → retirer prop + rendu + tests
- La généricité ne se présume pas, elle se constate (≥ 2 usages)
- Contexte technique : React Native — app-alexandrie (`ContentInfoChips.trailing`), 02-06-2026
---
<a id="risque-icone-unicode-symbol-other-polychrome"></a>
## Icônes unicode navbar — éviter les caractères "Symbol, Other" (So) polychromes
### Risques
- Les caractères Unicode catégorie "So" (`⚕` U+2695, `☯`, `☢`) peuvent rendre en polychrome sur Android/iOS selon la police système, comme les emojis
- Des caractères visuellement proches ont des catégories très différentes
### Symptômes
- Icône navbar/FAB rendue en couleur au lieu de suivre `currentColor`
### Bonnes pratiques / mitigations
- Pour les items nav/FAB/chrome, rester sur les catégories sûres : "Po" (`✦ ✧ ◇ ◉`), "Sm", "Ps/Pe", ou SVG inline `stroke="currentColor"`
- Vérifier la catégorie Unicode sur unicode.org avant de choisir un caractère décoratif
- Contexte technique : Vue 3 — RL799 (`⚕``♡` dans `AppLayout.vue`, v2-4-1), 20-06-2026
---
<a id="risque-recherche-normalisation-filtrage-vs-alignement"></a>
## Recherche client accent-insensible : séparer normalisation de filtrage et d'alignement
### Risques
- Une seule fonction de normalisation sert au FILTRAGE (matcher) ET au SURLIGNAGE (aligner des index sur le texte original)
- Les ligatures (`œ→oe`, `æ→ae`) sont une expansion 1→N : indispensables au filtrage, mais elles cassent l'alignement d'index du surlignage
### Symptômes
- "oeuvres" ne matche pas "œuvres", ou les fragments `<mark>` sont décalés
### Bonnes pratiques / mitigations
- `normalizeForSearch` (filtrage) : minuscule + diacritiques + **ligatures expansées** (longueur peut changer)
- `normalizeForHighlight` (surlignage) : minuscule + diacritiques en mapping **strictement 1:1** (longueur préservée), pour `indexOf`/`slice` sur le texte original
- Ne jamais utiliser `normalize('NFD')` pour l'alignement (change la longueur). Surligner via segmentation `<mark>`, jamais `v-html`
- Compromis assumé : un terme sans ligature remonte l'article ligaturé mais le mot ligaturé n'est pas surligné
- Contexte technique : Vue 3 — RL799 (recherche corpus d'autorité), 22-06-2026
---
<a id="risque-deeplink-details-imbriques-racine"></a>
## Deep-link vers un arbre `<details>` imbriqués : lier `:open` à TOUS les niveaux, racine comprise
### Risques
- Un état `openPath` ouvre les nœuds intermédiaires mais pas le `<details>` RACINE → la cible reste sous un conteneur replié
- `scrollIntoView` vise alors un élément à offsetParent null → scroll silencieusement inopérant (aucune erreur)
### Symptômes
- "Rien ne se passe" après un deep-link recherche→sommaire ; les tests passent car ils vérifient les enfants ouverts, pas l'ancêtre racine
### Bonnes pratiques / mitigations
- Le prédicat d'ouverture doit couvrir le niveau racine : `openPath === key || openPath.startsWith(\`${key}::\`)` appliqué uniformément à chaque profondeur
- Test de non-régression : asserter `element.open === true` sur le `<details>` racine ET chaque ancêtre du chemin, pas seulement les feuilles
- Contexte technique : Vue 3 — RL799, 22-06-2026
---
<a id="risque-retry-form-sans-refetch-contexte"></a>
## Bouton "Réessayer" qui réaffiche un formulaire sans re-fetcher le contexte
### Risques
- Une page à machine d'état (loading→choose→error) où l'erreur vient du GET d'hydratation : un `retry()` qui fait juste `status = 'choose'` réaffiche le formulaire sans données → écran fonctionnel mais vide
### Symptômes
- Formulaire vide après "Réessayer" suite à une erreur de chargement
### Bonnes pratiques / mitigations
- `retry()` doit distinguer "erreur au chargement (contexte jamais hydraté)" de "erreur à la soumission (contexte déjà là)" via un flag `contextLoaded` : si faux → relancer le fetch de contexte ; si vrai → réafficher le formulaire
- Tester explicitement le chemin erreur-au-GET-puis-retry (souvent oublié)
- Contexte technique : Vue 3 — RL799 (page publique sondage, v2-6-4), 24-06-2026
---
<a id="risque-format-date-sans-heure-options-ambigues"></a>
## Format de date sans heure → options de créneaux ambiguës
### Risques
- Une liste de créneaux formatée en jour/mois/année seulement affiche deux options identiques si deux dates tombent le même jour à des heures différentes
- La contrainte d'unicité backend est souvent "par instant (timestamp ms)", pas "par jour" → deux créneaux le même jour sont légaux et distincts en données mais indistinguables à l'écran
### Symptômes
- Deux radios/checkboxes affichant le même libellé dans une liste de créneaux
### Bonnes pratiques / mitigations
- Formater AVEC l'heure (et le fuseau, ex. Europe/Paris) dès que l'unicité backend est à la milliseconde
- Aligner le format sur celui du canal d'origine (mail qui affichait déjà l'heure) pour la cohérence cross-surface
- Vérifier la granularité du dédoublonnage backend avant de choisir le format d'affichage
- Contexte technique : Vue 3 — RL799 (v2-6-4), 24-06-2026
---
<a id="risque-toisostring-throw-invalid-time-value"></a>
## `new Date(x).toISOString()` peut throw `RangeError` → form figé sans feedback
### Risques
- Une string de date NON VIDE mais non parsable (`<input type="datetime-local">`) donne `Invalid Date` ; `.toISOString()` **lève** une exception (contrairement à `.getTime()` qui renvoie `NaN` sans throw)
- Si la conversion est dans un `.map()` avant l'appel async et que le `@submit` n'a pas de `.catch`, la promesse rejette en silence → aucun `error` posé → formulaire figé sans message
### Symptômes
- Soumission qui ne fait "rien" sur une date invalide non-vide ; l'attribut `required` ne couvre pas ce cas
### Bonnes pratiques / mitigations
- Valider chaque date via `Number.isNaN(new Date(x).getTime())` dans la validation cliente AVANT toute conversion `toISOString()`
- Contexte technique : Vue 3 — RL799 (`InstructionForm.vue`)
---
<a id="risque-composant-icone-inheritattrs-svg-100"></a>
## Composant icône `inheritAttrs: false` + SVG `100%` : class ignorée + débordement
### Risques
- Un composant icône avec `defineOptions({ inheritAttrs: false })` n'applique pas la `class` passée (les attributs non-props ne sont pas posés sur le nœud racine) → toute règle CSS la ciblant est inerte
- SVG internes en `width/height: 100%` sans parent dimensionné → l'icône grandit sans borne
### Symptômes
- On passe `class="…"` pour dimensionner l'icône, la règle ne s'applique pas, l'icône déborde de son conteneur
### Bonnes pratiques / mitigations
- Envelopper le composant dans un élément natif (`<span>`) dimensionné explicitement (`width/height: 1.4em`) — l'élément natif reçoit bien la classe — et contraindre le SVG interne via `:deep(.classe-interne) { width:100%; height:100% }`
- Ne jamais compter sur une `class` passée directement à un composant en `inheritAttrs: false`
- Valider le rendu visuel (screenshot/app) avant de committer une intégration d'icône réutilisée
- Contexte technique : Vue 3 — RL799, 22-06-2026
---
<a id="risque-i18n-francisation-incoherente"></a>
## i18n / francisation : cohérence label / a11y / comportement / modèle de données
### Risques
- Composant "à moitié internationalisé" : `accessibilityLabel` en FR mais texte visible en EN (`SectionHeader` : a11y `Voir tout — ${title}` vs texte `See All`). Un sweep qui ne regarde que le texte visible rate l'incohérence ; le fix naïf crée une 2ᵉ chaîne FR qui peut diverger
- Langue UI non vérifiée par le typage : les strings en dur ne sont ni typées ni testées → dette d'anglais qui s'accumule écran par écran
- Label vs comportement réel d'un CTA : "GET ENROLL" appelait en réalité `markDetailConsumed` (= marquer terminé) — franciser littéralement en "Commencer" aurait livré un bouton trompeur
- Catégories/filtres UI déconnectés du modèle : chips `Vidéo/Texte/Audio` alors que l'enum backend ne connaît que `TEXT`/`VIDEO` → un chip sur un type inexistant = résultat toujours vide
### Symptômes
- Incohérence FR/EN entre texte et a11y ; CTA dont le label ment ; filtre toujours vide
### Bonnes pratiques / mitigations
- Lors de la francisation d'un composant partagé, vérifier ensemble : texte visible + `accessibilityLabel` + props de surcharge (`seeAllLabel?`), et faire converger vers une **source unique** (`{label ?? 'défaut'}`) plutôt que dupliquer
- Toujours lire le **handler** avant de traduire/relabeller un CTA, pas seulement le texte
- Valider toute taxonomie d'UI (chips, filtres) contre le schéma de données réel (contracts) avant de l'implémenter
- Filet : sweep grep périodique + revue visuelle ; grep des consommateurs avant tout fix transversal
- Contexte technique : React Native — app-alexandrie (ux-cleanup-5), 28-05-2026
---
+59
View File
@@ -320,3 +320,62 @@ const routes = [
- Contexte technique : Expo Router — app-alexandrie 25-06-2026
---
<a id="risque-icone-decouplee-destination-renommage-route"></a>
## Découplage icône / destination lors d'un renommage de route
### Risques
- Lors du renommage d'une route (ex. `/settings``/profile`), l'URL, l'`accessibilityLabel` et le texte visible sont mis à jour, mais **l'icône** est oubliée
- Le grep capte les `href=`/`router.push` mais pas les `<Icon name="..." />`, souvent rangés dans des constantes éloignées de la logique de nav
### Symptômes
- L'utilisateur voit une icône engrenage menant à un écran "Profil"
### Bonnes pratiques / mitigations
À chaque callsite d'une route renommée, auditer **4 dimensions**, pas seulement l'URL :
1. La destination (`href`, `router.push`, `<Redirect>`)
2. L'`accessibilityLabel`
3. Le texte visible (`label`, `title`)
4. **L'icône** (`<Ionicons name="..." />`, `<MaterialIcons name="..." />`)
```tsx
// ❌ engrenage vers un écran Profil
<Pressable onPress={() => router.push('/(tabs)/profile')} accessibilityLabel="Profil">
<Ionicons name="settings-outline" size={28} />
</Pressable>
// ✅
<Pressable onPress={() => router.push('/(tabs)/profile')} accessibilityLabel="Profil">
<Ionicons name="person-circle-outline" size={28} />
</Pressable>
```
- Penser aussi aux constantes de tabs (`TAB_CONFIG`, `TABS`) lors du grep
- Contexte technique : React Native / Expo Router — app-alexandrie review IA-v2.1, 27-05-2026
---
<a id="risque-nav-activation-path-vs-query"></a>
## Sous-items de nav partageant un path, distincts par la query → activation sur le path seul
### Risques
- Deux liens `/x?mode=a` et `/x?mode=b` dans une sidebar dont l'activation fait `route.path.startsWith(item.to)`
- Double piège : si `item.to` est le path pur (`/x`), les deux s'allument ; si `item.to` contient déjà la query (`/x?mode=a`), aucun ne s'allume (`route.path` vaut `/x` sans query)
### Symptômes
- Deux entrées de sidebar actives simultanément, ou aucune active, sur des routes qui ne diffèrent que par la query
### Bonnes pratiques / mitigations
1. Garder `item.to` comme **path pur** pour la cible router-link, OU splitter path/query avant de passer à router-link (Vue Router encode le `?` si on passe une string `path?query` → la route ne matche pas)
2. Passer `{ path, query }` séparés à router-link, pas une string concaténée
3. Ajouter un discriminant explicite `queryMatch: { key, value }` et tester `route.query[key] === value` pour l'activation
- Contexte technique : Vue 3 / Vue Router — RL799 (sidebar admin Surveillants, query `?office=`), revue adversariale de spec, 14-06-2026
---
+21
View File
@@ -376,3 +376,24 @@ function handleToggle(id: string) {
- **Règle** : tout formulaire d'édition réutilisé pour plusieurs entités doit avoir une `key` distincte par entité
- Contexte technique : React / Next.js — app-template-resto 21-03-2026
---
<a id="risque-correlation-multi-onglets-sessionstorage"></a>
## Corrélation d'un retour multi-onglets : `localStorage`, pas `sessionStorage`
### Risques
- Un flux qui ouvre un NOUVEL onglet (checkout Stripe web, OAuth popup) devant renvoyer un signal corrélé (nonce anti-rejeu) pose le nonce en `sessionStorage`
- `sessionStorage` n'est PAS partagé entre onglets : l'onglet de retour a un `sessionStorage` vide → le nonce n'est jamais transmis → la validation doit être tolérante (accepte les messages sans nonce) donc **inopérante**
### Symptômes
- La corrélation anti-message-forgé devient une no-op silencieuse ; impossible d'exiger la présence du nonce
### Bonnes pratiques / mitigations
- Stocker le nonce en `localStorage` (partagé same-origin entre onglets), jamais en `sessionStorage`
- Avec `localStorage`, le nonce est réellement lisible côté retour → on peut **EXIGER** sa présence (mode strict) au lieu de la tolérer ; nettoyer la clé après usage
- `BroadcastChannel` reste le canal de transport (same-origin) mais ne porte pas l'état de session — d'où le besoin du storage partagé
- Contexte technique : Next.js / web — app-alexandrie (review ux-parcours-1/8 + bo-2), 04-06-2026
+94
View File
@@ -78,3 +78,97 @@ const handleToggle = useCallback((id: string) => { ... }, []); // stable ✓
- Contexte technique : frontend / mobile — RL799_V2 06-04-2026
---
<a id="risque-bundle-spa-non-code-splitte"></a>
## Bundle SPA non code-splitté — toute l'app livrée sur la page de login
### Risques
- Imports statiques dans le router → tout le code applicatif (modales, panels admin, vues détail, workers PDF) est livré et parsé sur la page de login
- DOMContentLoaded de plusieurs dizaines de secondes sur connexion mobile dégradée pour une page à 2 champs ; TTI massif au cold start ; les appareils anciens (CPU lent) paient le parse JS même en wifi
- Anti-pattern transverse SPA (Vue Router, React Router, Angular, SvelteKit dev)
### Symptômes
- Network DevTools (Slow 4G, cache vidé) sur `/login` : 300+ requêtes JS, 5+ Mo transférés, DOMContentLoaded > 30 s
- La waterfall liste des composants d'écrans authentifiés (`Dashboard*`, `Admin*`, modales métier)
- Lighthouse mobile : Performance < 50, LCP > 4s, TBT > 1s
### Signal de détection rapide
- Network → "Disable cache" + "Preserve log" → Slow 4G → hard reload sur `/login`. Si on voit des composants d'écrans authentifiés dans les requêtes, le code splitting est absent ou cassé.
### Bonnes pratiques / mitigations
```ts
// ✅ routes en imports dynamiques (Vite + Vue Router natif ; React Router lazy() ; Next auto par page)
const routes = [{ path: '/login', component: () => import('@/pages/Login.vue') }];
// ✅ composants lourds non-critiques en async (modales, viewers PDF)
const DocumentPreviewModal = defineAsyncComponent(() => import('@/components/DocumentPreviewModal.vue'));
// ✅ préchargement post-login : pendant la saisie du mot de passe, pré-charger les chunks suivants
import('@/pages/Dashboard.vue'); // dans Login.vue au mount, sans bloquer
```
- Auditer les barrel files (`export * from`) qui empêchent le tree-shaking → préférer `export { specificThing }`
- **Mesurer avant/après en Slow 4G + 4× CPU throttling** (sans throttling on mesure son MacBook, pas l'usage réel)
- Ordres de grandeur attendus (RL799 estimé) : requêtes login 336 → ~30-50, transfert 6.7 Mo → ~600 ko-1 Mo, DOMContentLoaded 51s → ~5-8s. Risque du fix très faible (mécanique sur le router), coût 2-4 h
- Note : sur Next.js le splitting par page est automatique mais ne dispense PAS du `dynamic()` / `defineAsyncComponent` pour les composants lourds
- Complète l'entrée "Performances : sur-renders + bundle non maîtrisé" par le signal Network observable et les chiffres
- Contexte technique : Vue 3.5 / Vite 7 / Vue Router 4 — RL799_V2 (audit `/login`), 08-05-2026
---
<a id="risque-helper-ordre-fige-reverse-caller"></a>
## Helper qui n'accepte qu'un ordre → force le caller à `reverse()` (O(n)×2 + clone)
### Risques
- Un helper pur qui ne gère que l'ordre ASC force le caller à `helper([...arr].reverse()).reverse()` quand le store sert en DESC (pour `FlatList inverted`)
- Coût caché : 2 × O(n) + 1 clone à CHAQUE re-render. Pour un tableau cumulatif (pagination), 500 items × 10 fetchs = 15000+ ops, invisible jusqu'à une conversation longue en prod
### Symptômes
- `const result = helper([...store.items].reverse()).reverse();`
### Bonnes pratiques / mitigations
```ts
// ✅ paramètre order : itère nativement à l'envers, 1 seul reverse final, 0 clone
export function helper(items, opts, order: 'asc' | 'desc' = 'asc') {
const iterate = order === 'asc'
? (cb) => { for (const m of items) cb(m); }
: (cb) => { for (let i = items.length - 1; i >= 0; i--) cb(items[i]); };
// logique unique basée sur "previousDate", valable dans les deux sens
return order === 'desc' ? result.reverse() : result;
}
```
- Contexte technique : React Native — app-alexandrie (ux-cleanup-11 H2, `groupMessagesByTime`), 30-05-2026
---
<a id="risque-flatlist-renderitem-sans-memo"></a>
## Composant `renderItem` de `FlatList` sans `React.memo` → re-render à chaque page
### Risques
- `FlatList` virtualise (n'instancie que les items visibles) mais ne mémoïze PAS les props : à chaque update de `data` (fetchNextPage cumulatif), tous les items visibles re-rendent même si leurs props sont inchangées
- 100 items visibles → 100 re-renders inutiles à chaque page chargée
### Symptômes
- Lag au scroll / au chargement de page sur une liste longue (DM, notif, contenu)
### Bonnes pratiques / mitigations
```tsx
export const MessageBubble = React.memo(function MessageBubble(props) { /* … */ });
```
- Tout composant rendu dans un `FlatList renderItem` doit être enveloppé dans `React.memo` (audit : grep `<FlatList renderItem`)
- Si les handlers changent à chaque render parent : les mémoïser (`useCallback`) ou accepter le re-render. Comparateur custom rarement nécessaire si les props sont stables côté store
- Contexte technique : React Native — app-alexandrie (ux-cleanup-11 M4/M5), 29-05-2026
@@ -88,3 +88,88 @@ return json;
- Règle : tout `fetch` dans le http-client doit vérifier `response.ok` avant de retourner le JSON parsé
- Contexte technique : React Native / fetch — app-alexandrie review 5.2, 27-03-2026
---
<a id="risque-accessibility-role-summary-wrapper-header"></a>
## `accessibilityRole="summary"` sur wrapper avec enfant `header` → double annonce
### Risques
- `accessibilityRole="summary"` sur un `<View>` wrapper dont un enfant texte porte déjà `accessibilityRole="header"` : sur Android, TalkBack annonce le `accessibilityLabel` du wrapper **et** le titre enfant (double annonce). Sur iOS, `summary` est un no-op silencieux
- Le dev croit renforcer l'a11y, il la dégrade
### Symptômes
- Lecteur d'écran qui répète le titre de section sur Android
### Bonnes pratiques / mitigations
```tsx
// ✅ le role "header" sur le titre enfant suffit à structurer la section
<View accessibilityLabel="Mon compte">
<Text accessibilityRole="header">MON COMPTE</Text>
{children}
</View>
```
- Ne pas poser `accessibilityRole="summary"` sur un wrapper si le titre enfant porte déjà `accessibilityRole="header"` ; conserver uniquement `accessibilityLabel` sur le wrapper si besoin de regrouper
- Contexte technique : React Native — app-alexandrie (`section-card.tsx`, IA-v2.4 H3), 27-05-2026
---
<a id="risque-bouton-retour-dans-scrollview"></a>
## Bouton retour placé dans le `ScrollView` → disparaît au scroll
### Risques
- Sur une app RN/Expo avec une TopBar globale sans back natif, un bouton retour ajouté comme premier enfant du `<ScrollView>` disparaît dès que l'utilisateur défile
- Critique sur une page business (gestion abonnement, résiliation) : perdre l'accès au retour pendant le scroll est un piège UX direct
### Symptômes
- Le back est visible en haut de page puis introuvable une fois scrollé
### Bonnes pratiques / mitigations
```tsx
<View style={styles.root}> {/* flex:1, background */}
<View style={[styles.headerBar, { paddingTop: insets.top }]}>
<Pressable onPress={handleBack} hitSlop={8}>Retour</Pressable>
</View>
<ScrollView contentInset={{ top: 0 }}>{/* contenu */}</ScrollView>
</View>
```
- Le header local sticky vit **hors** du ScrollView (View parent + ScrollView frère)
- Ne PAS doubler `insets.top` (le header le consomme → `contentInset.top = 0`) ; ne PAS mettre le back dans le `contentContainerStyle`
- Si `<Stack.Screen>` Expo Router avec `headerShown: true`, préférer le header natif (`headerBackVisible: true`)
- Test défensif : vérifier statiquement qu'un `<Pressable accessibilityLabel="Retour">` n'est pas dans un `<ScrollView>`
- Contexte technique : React Native / Expo Router — app-alexandrie (ux-cleanup-2, `subscription/manage`), 28-05-2026
---
<a id="risque-ptr-custom-mobile-vs-lib"></a>
## Pull-to-refresh mobile web : préférer une lib battle-test à un PTR custom
### Risques
- Un PTR custom (écouter `pointerdown`/`pointermove` sur un wrapper) est intrinsèquement fragile : le navigateur mobile **préempte** les touch events pour son scroll natif dès que la page est en haut → les `pointerdown`/`pointermove` du composant sont absents ou annulés par `pointercancel`
- Chrome Android a SON PTR natif qui déclenche un vrai `location.reload()` : sur une PWA standalone, ça vide l'état Pinia/Redux et affiche le SW cache (souvent une version pré-login) — l'utilisateur croit s'être déconnecté
- Un wrapper PTR casse les layouts grid (`display: contents` rend `place-items` inopérant, `display: block` brise le centrage)
### Symptômes
- PTR custom OK en simulation desktop, inopérant sur device ; ou rechargement natif affichant le contenu pré-login
### Bonnes pratiques / mitigations
```css
/* AVANT toute autre tentative : neutraliser le PTR natif Chrome Android */
body { overscroll-behavior-y: contain; } /* Chrome 63+/Safari 16+/Firefox 59+ */
```
- Préférer une lib battle-test (`pulltorefreshjs`, ~6 kB) qui s'attache à `<body>` (zéro impact layout grid) et gère iOS/Android/desktop ; API `PullToRefresh.init({ mainElement, onRefresh })`, destroy au unmount
- Indicateur : SVG inline avec viewBox contrôlé, pas un glyphe unicode (rendu variable selon la font)
- **Tester sur vrai device** Android ET iOS (DevTools "device toolbar" ne reproduit pas la préemption native)
- Contexte technique : Vue 3.5 / Vite / PWA Workbox — RL799_V2 (4 itérations custom ratées → `pulltorefreshjs`), 11-05-2026
+78
View File
@@ -0,0 +1,78 @@
# Frontend — Risques & vigilance : Responsive / adaptation mobile
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/frontend/risques/README.md` pour l'index complet.
---
<a id="risque-gating-responsive-largeur-vs-input"></a>
## Gating responsive par largeur seule → fuite sur tablettes tactiles
### Risques
- Le réflexe `@media (min-width: 768px|900px|1024px)` traite la largeur comme un proxy de l'UX cible ; ça casse sur **tablettes tactiles** (iPad Air 1180px, iPad Pro 1366px, Surface, Galaxy Tab) qui ont la largeur d'un desktop mais l'UX d'un doigt
- Conséquences observées : swipe gestures désactivés sur iPad (alors que le swipe est l'UX tactile naturelle), scrollbars custom visibles en permanence, hover states bloquants au tap (1er tap = hover, 2e = click), pagination trop dense pour le doigt
### Symptômes
- Une règle "desktop" gated par `min-width` seul se comporte mal sur iPad : interactions désactivées ou inadaptées, scrollbars inutiles
### Bonnes pratiques / mitigations
**Gater par capacité d'input dès que la règle concerne une interaction** (hover, swipe, scrollbar visible, densité tactile, drag-and-drop, focus-ring discret).
```css
@media (pointer: fine) { /* souris/trackpad précis : desktop, laptop */ }
@media (pointer: coarse) { /* doigt/stylet imprécis : mobile, tablette tactile */ }
@media (hover: hover) { /* hover stable */ }
@media (hover: none) { /* hover simulé (tap) */ }
/* "Vraie expérience desktop" : largeur ≥ X ET souris */
@media (pointer: fine) and (min-width: 900px) { /* scrollbars custom, hover, pagination dense */ }
/* "Expérience tactile" : doigt, peu importe la largeur */
@media (pointer: coarse) { /* swipe, touch targets ≥44px, pas de hover stylé */ }
```
```ts
// équivalent runtime
const isFinePointer = window.matchMedia('(pointer: fine)').matches;
const isCoarseLarge = window.matchMedia('(pointer: coarse) and (min-width: 900px)').matches;
```
- **Garder `min-width` seul** pour le **layout pur** (grille, max-width container) : la largeur détermine la place, pas l'input.
- Critère de décision : « Si je donne à un user iPad cette règle, préfère-t-il le comportement mobile ou desktop ? » Réponse "mobile" (swipe, touch dense) → `pointer: coarse` ; réponse "desktop" (layout 2 colonnes, max-width) → `min-width` seul. Doute → `pointer: fine` + `min-width`.
- Support : `pointer:` / `hover:` (Interaction Media Features L4) universel depuis 2018, aucun fallback en 2026. Pattern CSS pur, applicable à toute stack.
- Contexte technique : Vue 3 / Vite — RL799_V2 (chantier `feat/desktop-experience`, swipe rows / scrollbar globale / pagination Audit), 12-05-2026
---
<a id="risque-regressions-mobile-chantier-desktop"></a>
## Régressions mobile invisibles lors d'un chantier desktop responsive
### Risques
- Un chantier "ajout desktop" sur une app mobile-first crée des régressions mobile par **effets de bord indirects**. Les devs travaillent en majorité sur viewport desktop → les régressions mobile n'apparaissent qu'au test final sur device, tardivement
- 4 vecteurs récurrents : wrappers retirés qui fournissaient un gap implicite, classes globales qui cumulent leurs paddings, `display: contents`/grid qui change l'ordre DOM mobile, règles `min-width` qui fuient sur tablette tactile
### Symptômes
- "Les espaces ont été mangés" (éléments mobiles collés), "le contenu est plus étroit qu'avant" (marges doublées), "l'ordre des cartes a changé" sans changement mobile intentionnel
### Bonnes pratiques / mitigations
Les 4 vecteurs et leur fix :
1. **Wrapper retiré → gap implicite perdu** : un `<PageShell>`/`<ContentCard>` retiré côté desktop portait un `gap`/`padding-top` qui structurait le rythme mobile. Fix : remettre explicitement `gap: var(--space-X)` sur le nouveau wrapper mobile, mesurer en pixels avant/après.
2. **Classes globales qui cumulent les paddings** : `.tenue-detail-container { padding-inline: 16px }` + `.page--dashboard { padding-inline: 16px }` = 32px sur mobile. Fix : neutraliser côté mobile (`.page--dashboard .tenue-detail-container { padding-inline: 0; }`).
3. **`display: contents` qui change l'ordre DOM mobile** : sur mobile le wrapper transparent fait suivre l'ordre HTML (réordonné pour le desktop), pas l'ordre historique mobile. Fix : préserver l'ordre via `flex` + `order` en `@media (max-width: 899px)`.
4. **Règles `@media (min-width)` qui fuient sur tablette tactile** : cf. `#risque-gating-responsive-largeur-vs-input`. Fix : ajouter `and (pointer: fine)` aux règles concernant une interaction.
**Checklist QA mobile post-chantier desktop** (avant merge) :
1. Audit gap/padding mobile (diff visuel page par page vs version précédente)
2. Audit ordre DOM mobile pour chaque page touchée (surtout `display: contents`/grid)
3. Grep `@media (min-width:` dans le diff ; pour chaque règle concernant une interaction, ajouter `and (pointer: fine)`
4. Audit cumul de paddings : pour chaque nouvelle classe wrapper, vérifier qu'aucune classe interne globale n'ajoute son propre `padding-inline`
5. Test sur appareil **physique** réel (iPhone + Android + iPad tactile), pas seulement les émulateurs DevTools (qui simulent la largeur mais pas la nature du pointer)
- Contexte technique : Vue 3.5 / Vite 7 — RL799_V2 (chantier `feat/desktop-experience`, passe QA mobile ~2h sur 7 fichiers), 12-05-2026
+265
View File
@@ -592,4 +592,269 @@ function dbStatusToUiStatus(s: DBStatus): UIStatus {
- Règle : chaque valeur de l'enum DB doit avoir son propre `if` dans le mapping. Éviter le `return` catch-all final qui absorbe les valeurs non prévues.
---
<a id="risque-fetch-reset-early-return-isloading"></a>
## Race `fetchEntity(reset=true)` avalé par un early-return `isLoading`
### Risques
- Une action `fetchEntity(reset)` qui pose `if (isLoading) return` avale le 2ᵉ appel lorsqu'un changement de filtre/critère survient pendant un fetch en vol
- La promesse du 1er fetch (filtre A) résout après le changement et écrase le state avec les résultats de A, alors que l'UI affiche le filtre B
### Symptômes
- 100 % reproductible sur connexion lente : résultats incohérents après un changement rapide de filtre/recherche/onglet
- L'écran affiche le bon filtre sélectionné mais les mauvaises données
### Bonnes pratiques / mitigations
Distinguer `reset` (changement de critère — toujours lancer + ignorer les fetchs périmés) de `loadMore` (pagination — early-return légitime), via un token incrémental.
```ts
fetchEntity: async (token, reset = false) => {
const { isLoading, fetchId } = get();
if (!reset && isLoading) return; // garde seulement pour loadMore
const myId = fetchId + 1;
set({ isLoading: true, fetchId: myId });
try {
const result = await api.getEntity({ filter, ... });
if (get().fetchId !== myId) return; // fetch périmé : drop
set({ ..., isLoading: false });
} catch (err) {
if (get().fetchId !== myId) return;
set({ error: ..., isLoading: false });
}
}
```
- **Test** : 1er fetch deferred (résolu à la main), changement de filtre + 2e fetch, vérifier que le résultat tardif du 1er ne pollue pas le store
- Lien : variante côté pattern dans `patterns/state.md#pattern-race-token-partage-latest-wins`
- Contexte technique : React Native / Zustand — app-alexandrie review IA-v2.7, 28-05-2026
---
<a id="risque-useref-undefined-hook-reactif-store"></a>
## `useRef(undefined)` dans un hook réactif à un store persistant → refresh fantôme
### Risques
- Un hook qui suit la dernière valeur observée d'un store via `useRef<T | undefined>(undefined)` rejoue son effet à chaque remount si le store a été touché entre-temps
- Le ref repart à `undefined` au remount, mais le store conserve sa valeur ≠ undefined → l'effet se redéclenche sans action utilisateur
### Symptômes
- Refresh fantôme au retour sur un écran après navigation (flush UI local, requête réseau inutile, scroll-top non voulu)
### Bonnes pratiques / mitigations
```typescript
// ❌ confond "1er mount d'une session" et "remount session déjà en cours"
const lastSeenRef = useRef<number | undefined>(undefined);
// ✅ initialiser à la valeur courante du store → ne déclenche que sur vraie transition
const lastSeenRef = useRef<number | undefined>(refreshTimestamp);
```
- **Règle** : un `useRef` qui mémorise la dernière valeur observée d'un store doit être initialisé à la valeur courante, pas à `undefined`
- **Test** : mount → store fire → unmount → remount → vérifier que `onRefresh` n'est PAS rappelé tant que la valeur n'a pas changé
- Lien : pattern associé `patterns/state.md#pattern-event-bus-zustand-timestamp`
- Contexte technique : React Native / Zustand — app-alexandrie review IA-v2.8 (H2), 28-05-2026
---
<a id="risque-logique-metier-dispersee-callsites"></a>
## Logique métier dispersée dans les callsites au lieu d'un sélecteur dérivé partagé
### Risques
- Une prop dérivable d'un store global est recalculée dans chaque callsite (`mode="subscribe"` hardcodé au lieu de `mode={trial.active ? 'trial-upgrade' : 'subscribe'}`)
- N callsites sur M oublient la branche conditionnelle → wording/mode incohérent. Ni TypeScript ni les tests unitaires ne capturent ce manque si chaque callsite n'est pas testé
### Symptômes
- Comportement correct sur certains écrans, incorrect sur d'autres rendant le même composant
### Bonnes pratiques / mitigations
```bash
grep -rn '<PaywallModal mode="subscribe"' apps/mobile/src # 5 résultats hardcodés → devrait être 0
```
1. Définir un sélecteur pur dans le store : `selectPaywallMode(state): 'trial-upgrade' | 'subscribe'`
2. Le tester isolément (fonction pure, trivial)
3. Migrer tous les callsites vers `useStore(selectPaywallMode)`
- **Règle** : toute prop dérivable du store doit l'être via un sélecteur exporté, pas recalculée par callsite (le typage TS ne protège pas → la revue humaine reste indispensable)
- Contexte technique : React Native / Zustand — app-alexandrie review IA-v2.5 (H3), 27-05-2026
---
<a id="risque-useeffect-data-length-zero-refetch-infini"></a>
## `useEffect` avec condition `data.length === 0` → re-fetch infini si l'API retourne `[]`
### Risques
- Un `useEffect` qui déclenche un fetch tant que `data.length === 0` boucle indéfiniment quand l'API retourne légitimement un tableau vide
- Insidieux : passe les tests mockés (qui renvoient des données), ne se manifeste qu'avec un serveur réel renvoyant `[]`
### Symptômes
- Fetch en boucle à chaque re-render/remount sur un endpoint vide
### Bonnes pratiques / mitigations
```ts
// ❌ data.length reste 0 si l'API renvoie [] → fetch en boucle
useEffect(() => {
if (data.length === 0) void fetchData();
}, [data.length, fetchData]);
// ✅ flag single-shot (ref local ou flag `attempted` dérivé du store)
const hasFetchedRef = useRef(false);
useEffect(() => {
if (data.length === 0 && !hasFetchedRef.current) {
hasFetchedRef.current = true;
void fetchData();
}
}, [data.length, fetchData]);
```
- **Détection** : grep `length === 0` dans des conditions `useEffect`
- Contexte technique : React Native — app-alexandrie code review ia-v2-6 (`topics.tsx`), 28-05-2026
---
<a id="risque-flag-optimiste-ecrase-par-hydratation"></a>
## Flag local optimiste + endpoint d'hydratation = écrasement silencieux si le POST a échoué
### Risques
- Un flag basculé en optimiste (`markFlagLocal()`) puis persisté en fire-and-forget (`void persistToBackend()`) est ramené à la valeur backend au prochain endpoint d'hydratation si le POST a échoué (offline, 500)
- L'utilisateur perd l'optimistic update sans notification (ex : revoit l'onboarding qu'il avait "passé")
### Symptômes
- Action one-shot (skip onboarding, marquage lu) qui "revient" après un reboot offline
### Bonnes pratiques / mitigations
1. `await` le POST avant `router.replace` (recommandé pour les actions one-shot)
2. ou stocker un pending-flag local (AsyncStorage) à rejouer au prochain online
3. ou ne basculer le flag local qu'au succès du POST (perd la réactivité)
- **Règle** : tout flag local optimiste ré-hydraté depuis le backend doit avoir une stratégie de réconciliation explicite, pas un `void promise.catch(() => undefined)`
- Contexte technique : React Native / Zustand — app-alexandrie ia-v2-6, 28-05-2026
---
<a id="risque-mutation-detail-non-propagee-liste"></a>
## Mutation d'un détail non propagée à la liste (cache stale)
### Risques
- Un store maintient `items: T[]` (liste) ET `currentDetail: T | null` (détail) ; une action mute `currentDetail` mais oublie de propager à `items[]` et aux listes secondaires
- L'invariant "source de vérité unique" est respecté côté backend (les endpoints lisent la même table) mais bafoué côté store (champs dénormalisés)
- Invalider un compteur global (`progression: null`) ne suffit pas : `items[]` est un cache local qui survit au retour à la liste
### Symptômes
- L'utilisateur agit sur un détail puis revient à la liste et voit l'ancien état
- Bug souvent asymétrique (sens A→liste propagé, B→liste oublié) → invisible jusqu'au 1er QA device
### Bonnes pratiques / mitigations
```typescript
// ✅ Propager le même fait à TOUTES les surfaces qui le dénormalisent
async resetDetailConsumption(token) {
await api.reset(currentDetail.id);
const resetId = currentDetail.id;
set((state) => ({
progression: null,
currentDetail: { ...state.currentDetail, consumptionState: 'NOT_STARTED', completedAt: null },
items: state.items.map((i) =>
i.id === resetId ? { ...i, consumptionState: 'NOT_STARTED', completedAt: null } : i),
packContents: state.packContents.map((i) =>
i.id === resetId ? { ...i, consumptionState: 'NOT_STARTED', completedAt: null } : i),
}));
}
```
1. Avant d'écrire l'action, **lister toutes les surfaces du state qui partagent un champ** avec l'entité mutée (liste principale, listes secondaires, caches paginés)
2. Les mettre à jour dans le même `set()` — ou documenter pourquoi une surface n'est pas propagée (re-fetch systématique au mount)
3. **Symétrie obligatoire** : si `markCompleted` propage, `resetConsumption` doit propager aussi
4. Test : asserter que `items[i]` et `currentDetail` sont alignés après chaque mutation
- Note : rencontré 2× sur app-alexandrie (ux-cleanup-6 backend, puis ux-cleanup-7 store mobile) — la leçon a glissé d'une couche à l'autre
- Contexte technique : React Native / Zustand — app-alexandrie ux-cleanup-7, 29-05-2026
---
<a id="risque-latch-sans-reset-changement-session"></a>
## Latch de chargement sans `reset()` → données figées au changement de session
### Risques
- Un latch anti-boucle (`hasLoadedOnce`, posé `true` en succès ET en erreur pour stopper le refetch sur liste vide) survit au changement de compte
- Après un logout→login à chaud, le `useEffect` gardé par le latch ne refetch jamais → l'écran reste figé sur les données du compte précédent
- Divergence entre stores frères : l'un (messaging) a son reset, l'autre (notifications) ne l'a pas
### Symptômes
- Données de l'ancien compte affichées après changement de session sans rechargement complet de l'app
### Bonnes pratiques / mitigations
- **Règle « latch ⇒ reset »** : tout store introduisant un latch de chargement DOIT fournir `reset()` (via un `initialState` factorisé) ET le déclencher sur transition du token d'auth (ref `previousToken` au niveau écran) ou au logout
- Vérifier la **symétrie entre stores frères** : si A reset, B doit reset
- Contexte technique : React Native / Zustand — app-alexandrie review bo-4, 04-06-2026
---
<a id="risque-key-index-liste-editable-inputs"></a>
## `:key` par index sur une liste d'inputs éditable au milieu → désalignement de l'état natif
### Risques
- Un `v-for`/`map` rendant des `<input>` dont l'utilisateur peut retirer un élément du milieu, keyé sur l'index, recycle le nœud DOM de l'ancien index pour la nouvelle donnée
- La valeur contrôlée (`v-model`) se patche bien, mais tout l'état NON contrôlé suit l'ancien nœud : focus, position curseur, sélection, bulle de validation HTML5 `required`
### Symptômes
- On retire une ligne au-dessus d'un champ en cours d'édition → focus/validation restent sur le mauvais champ
### Bonnes pratiques / mitigations
- Transformer la liste en tableau d'objets `{ id, value }` (id stable généré à l'ajout) et keyer sur `item.id` avec `v-model="item.value"`
- Vrai pour toute liste réordonnable ou supprimable au milieu
- Contexte technique : Vue 3 — RL799 (`InstructionForm.vue`), code review adversariale, 13-06-2026
---
<a id="risque-optimistic-update-slice-absente"></a>
## Optimistic update sur slice indexée absente → message perdu visuellement
### Risques
- Dans un store structuré en `Record<id, slice>` (chaque slice = items + pagination + flags), un optimistic update qui `return` sans rien faire quand la slice n'existe pas encore perd visuellement l'item
- Cas typique : "Nouveau message → premier envoi → navigation immédiate vers `/messages/:id`" — la slice n'est jamais créée avant la navigation, l'item disparaît jusqu'au `fetchFirstPage`
### Symptômes
- Trou visuel (message envoyé absent) pendant la latence réseau, comblé seulement au fetch suivant
### Bonnes pratiques / mitigations
```typescript
// ✅ initialiser la slice si absente lors de l'optimistic update
const slice = state.messagesByConversationId[message.conversationId];
const nextSlice = slice
? { ...slice, items: [message, ...slice.items] }
: { items: [message], nextCursor: null, hasMore: false, isReadOnly: false, isLoading: false, error: null };
```
- **Règle** : tout store en `Record<id, slice>` DOIT initialiser la slice manquante lors d'un optimistic update (sinon le fetch suivant l'écrase, mais l'utilisateur voit un trou)
- Lien : voisin de `risque-zustand-optimistic-update-sous-listes` (recherche d'item à travers les sous-listes)
- Contexte technique : React Native / Zustand — app-alexandrie rétro Epic 10 (A2), 13-05-2026
- Contexte technique : frontend / mapping statut DB → UI — app-template-resto 25-06-2026
+107
View File
@@ -313,3 +313,110 @@ Si tes tests doivent être systématiquement mis à jour à chaque refactor, c'e
- Pour les composants interactifs, compléter par au moins un test de mount via `@vue/test-utils` qui vérifie le render sans crash
- Contexte technique : Vue 3 / Vitest — RL799_V2 29-04-2026
---
<a id="risque-dupliquer-styles-pour-tester-hook"></a>
## Dupliquer les styles pour tester un composant qui utilise un hook
### Risques
- Pour rendre testable un composant qui style via un hook (`useThemedColors`), on duplique les styles en `export const xxxStyles = StyleSheet.create({...})` à côté du `makeStyles(themed)` interne
- Toute modif de `makeStyles` ne propage pas à la copie statique → les tests passent sur du code mort ; la régression n'est vue qu'au smoke device
### Symptômes
- Deux `StyleSheet.create` dans le même fichier (interne + exporté pour les tests) avec les mêmes définitions
### Bonnes pratiques / mitigations
```typescript
// ✅ passer la palette statique à makeStyles : 1 ligne, 0 duplication, source unique
export const sectionHeaderStyles = makeStyles(colors);
```
- Si le composant n'expose pas `makeStyles`, exposer la fonction (pas le résultat) et tester `makeStyles(mock)`
- Garde-fou review : deux `StyleSheet.create` dans le même fichier = suspicion de duplication
- Contexte technique : React Native — app-alexandrie (ux-cleanup-5, `SectionHeader.tsx`), 29-05-2026
---
<a id="risque-fix-visuel-plumbing-sans-test"></a>
## Fix visuel de plumbing sans test = régression silencieuse garantie
### Risques
- Un bug "visuel" venant d'un câblage cassé (theme provider, navigation theme, font/locale loader) fixé directement dans le `useMemo` racine → non testable
- Une PR future qui re-câble le mauvais provider ne casse rien en CI mais ré-introduit le bug en prod
### Symptômes
- Fix livré sans test de non-régression alors que la cause root est testable en isolation
### Bonnes pratiques / mitigations
- Extraire la dérivation en module pur (`buildXxxFrom(scheme, tokens) → Theme`)
- Ajouter ≥ 2 tests : un par scheme + un garde-fou prouvant que les valeurs natives OS ne fuient pas (`expect(theme.colors.background).not.toBe('#ffffff')`)
- Contexte technique : React Native — app-alexandrie (ux-cleanup-8 H2, `navigation-theme.ts`), 29-05-2026
---
<a id="risque-test-fanout-sans-compte"></a>
## Test de ciblage/fan-out qui assert le prédicat mais pas le COMPTE
### Risques
- `assert(recipients.every(r => r.grade === 'Compagnon'))` passe AUSSI si on ne notifie qu'1 destinataire sur 9 (ou zéro) — `.every()` sur une liste partielle/vide est vrai par vacuité
- Un bug de complétude (ciblage partiel, exclusion silencieuse d'actifs) reste invisible
### Symptômes
- Test "X notifie/cible Y" vert alors que le ciblage est incomplet
### Bonnes pratiques / mitigations
- Ajouter `assert.equal(recipients.length, EXPECTED)` où EXPECTED est calculé **dynamiquement** depuis les fixtures (`seedUsers.filter(...)`), jamais hardcodé
- Le `.every()` valide la pureté du ciblage, le `.length` valide la complétude — les deux sont nécessaires
- Contexte technique : tests de notification — RL799 (review v2-1-3), 13-06-2026
---
<a id="risque-stub-enfant-props-calculees"></a>
## Stub de composant enfant en mount test : déclarer aussi les props CALCULÉES
### Risques
- Un stub d'enfant qui ne déclare que les props affichées laisse non testée la logique de calcul des props non lues (`:variant="sourceVariant(x)"`)
- `vue-tsc` valide que la valeur est une variante acceptée, mais PAS que la bonne branche est prise au runtime → une inversion de logique passe typecheck + mount
### Symptômes
- Une fonction de calcul de prop n'est jamais exercée au runtime ; seul le typecheck la couvre
### Bonnes pratiques / mitigations
- Le stub expose la prop calculée (`props: ['label','variant']`) et la reflète dans un attribut testable (`:data-variant="variant"`) ; le test asserte la valeur
- Règle : tout `:prop="fn(...)"` dans un template mérite une assertion runtime sur la valeur résultante (surtout si elle prépare une extension future)
- Contexte technique : Vue 3 / @vue/test-utils — RL799, 22-06-2026
---
<a id="risque-module-garde-par-office-trous-test"></a>
## Module gardé par office (rôle dérivé) — 2 trous de test systématiques
### Risques
- Pattern « guard backend `requireOffice(office)` + nav item conditionné + icône `OfficerRoleIcon` » : deux assertions manquent quasi toujours sans que la suite verte ne le rattrape
- (1) aucun test ne valide le rendu de l'icône d'office en navbar mobile (si `isOfficerRole(office)` renvoie `false` ou si le wrapper de taille manque, l'icône ne rend rien / déborde, sans erreur)
- (2) le test d'accès API mocke les offices via le token (`createTestToken({ offices: ['x'] })`), pas via un vrai mandat en DB → la chaîne LIVE `mandat actif → offices résolus → accès` n'est jamais couverte bout-en-bout
### Symptômes
- Tous les tests passent alors que l'icône d'office ne rend rien et que la chaîne mandat→accès n'est pas testée
### Bonnes pratiques / mitigations
1. Test mount transverse : pour CHAQUE office routé, vérifier que le nav item rend une icône d'office non vide et bornée en taille
2. Au moins un test par domaine créant un mandat actif en DB → accès 200, et un mandat révoqué → 403
- À traiter dans un chantier « tests offices » dédié (raffinement transverse, pas story par story) mais à décider sciemment dès le câblage du 1er office
- Contexte technique : Vue 3 / backend — RL799 (review v2-5-2), 22-06-2026