mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-06-28 10:03:40 +02:00
5f5c87296e
Triage et intégration des propositions frontend du buffer 95_a_capitaliser.md (lot local RL799_V2/Vue3 + app-alexandrie/RN-Expo, mai-juin 2026). ~73 entrées intégrées sur knowledge/frontend/ + 1 nouveau fichier, dont : - patterns/state.md : race-token partagé latest-wins (fusion 3 props), capture sync anti-race, event bus timestamp, clé cache composite, état dérivé = computed - risques/state.md : 9 risques Zustand/store (fetchId reset, useRef remount, re-fetch infini sur [], flag optimiste écrasé, cache détail/liste stale, latch sans reset, :key index) - patterns/navigation.md : Expo Router (tab bar, useFocusEffect, Href typé, routing pur fusionné) - patterns/general.md : helpers temps purs, composants génériques + skeleton, fail-fast, touch target - risques/general.md : 24 risques (sweep statique, filtre client liste paginée, hooks avant return, a11y VoiceOver/disabled, redirection allowlist, RangeError toISOString, section i18n...) - design-tokens (cluster theming light/dark MD3), tests, performance, react-native, nextjs - NOUVEAU risques/responsive.md (gating par capacité d'input + checklist régressions mobile) - READMEs patterns/risques mis à jour Doublons inter-fichiers évités (vérifié : aucune ancre dupliquée introduite). Rejets (doublons 91/9/87), reciblages workflow (156/257) et bloc 32 (CLAUDE projet) non intégrés ici. Source 95_a_capitaliser.md non purgée (purge en fin de capitalisation complète). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
495 lines
19 KiB
Markdown
495 lines
19 KiB
Markdown
---
|
|
title: Frontend — Patterns : Navigation
|
|
domain: frontend
|
|
bucket: patterns
|
|
tags: [navigation, react, expo-router, useeffect, async]
|
|
applies_to: [analysis, implementation, review]
|
|
severity: medium
|
|
validated_on: 2026-03-21
|
|
source_projects: [app-template-resto, app-alexandrie]
|
|
---
|
|
|
|
# Frontend — Patterns : Navigation
|
|
|
|
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/frontend/patterns/README.md` pour l'index complet.
|
|
|
|
---
|
|
|
|
<a id="pattern-navigation-reactive-post-action-async"></a>
|
|
## Pattern : Navigation réactive post-action async (React / Expo Router)
|
|
|
|
### Synthèse
|
|
|
|
- **Objectif** : déclencher la navigation après une action asynchrone (login, register, submit) de façon idiomatique et sans bypasser la réactivité React.
|
|
- **Contexte** : SPA ou app mobile React avec state management (Zustand, Redux, Context) et router déclaratif (React Router, Expo Router, Next.js App Router).
|
|
- **Quand l'utiliser** : dès qu'une navigation dépend du résultat d'une action async.
|
|
- **Quand l'éviter** : navigations synchrones sans état async impliqué.
|
|
|
|
### Analyse
|
|
|
|
- **Avantages** :
|
|
- Respecte le cycle de vie React (pas de lecture de state hors cycle)
|
|
- Re-render automatique si l'état change entre-temps
|
|
- Testable : on peut assert sur l'état, pas sur des effets de bord
|
|
- **Limites / vigilance** :
|
|
- Ne pas oublier les dépendances du `useEffect` (ESLint react-hooks/exhaustive-deps)
|
|
- Gérer le cas "composant démonté" si la navigation peut être annulée
|
|
|
|
### Validation
|
|
|
|
- Validé le : 07-03-2026
|
|
- Contexte technique : React 18+ / Zustand / Expo Router — pattern applicable sur React Router, Next.js App Router
|
|
|
|
### Implémentation (exemple minimal)
|
|
|
|
```typescript
|
|
// ❌ Anti-pattern : lecture de state hors cycle React
|
|
const handleSubmit = async () => {
|
|
await login(email, password);
|
|
const { accessToken } = useAuthStore.getState(); // bypasse la réactivité
|
|
if (accessToken) router.replace('/(tabs)');
|
|
};
|
|
|
|
// ✅ Pattern correct : useEffect réactif sur le state
|
|
const { accessToken, isLoading, error } = useAuthStore();
|
|
|
|
useEffect(() => {
|
|
if (accessToken && !isLoading && !error) {
|
|
router.replace('/(tabs)');
|
|
}
|
|
}, [accessToken, isLoading, error]);
|
|
|
|
const handleSubmit = async () => {
|
|
await login(email, password);
|
|
// la navigation se déclenche via useEffect quand le store se met à jour
|
|
};
|
|
```
|
|
|
|
### Pour les callbacks OAuth (ref nécessaire)
|
|
|
|
```typescript
|
|
// Quand un callback externe déclenche la navigation
|
|
const pendingOAuth = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (pendingOAuth.current && accessToken) {
|
|
pendingOAuth.current = false;
|
|
router.replace('/(tabs)');
|
|
}
|
|
}, [accessToken]);
|
|
|
|
const handleOAuth = async () => {
|
|
pendingOAuth.current = true;
|
|
await exchangeWithIdp(token);
|
|
};
|
|
```
|
|
|
|
### Checklist
|
|
|
|
- [ ] Aucun `store.getState()` utilisé pour lire l'état post-action dans un handler
|
|
- [ ] `useEffect` avec dépendances explicites (state pertinent + isLoading + error)
|
|
- [ ] Cas d'erreur géré (ne pas naviguer si error est défini)
|
|
- [ ] `useRef` si le trigger vient d'un callback externe (OAuth, deep link)
|
|
- [ ] Convention documentée dans la story foundations / project-context avant les premiers écrans
|
|
|
|
---
|
|
|
|
<a id="pattern-link-out-page-locale-canonique"></a>
|
|
## Pattern : Intégration tierce en mode link-out — préférer une page locale canonique
|
|
|
|
### Synthèse
|
|
|
|
- **Objectif** : éviter les parcours concurrents et centraliser les garde-fous UX quand une fonctionnalité publique dépend d'un service tiers externe.
|
|
- **Contexte** : site ou webapp avec CTA publics menant vers un tiers de réservation, paiement, prise de rendez-vous ou formulaire externe.
|
|
- **Quand l'utiliser** : dès qu'une fonctionnalité externe dispose d'une page locale dédiée côté produit (`/reservation`, `/booking`, etc.).
|
|
- **Quand l'éviter** : si le produit assume volontairement une sortie directe unique vers le tiers, sans page locale intermédiaire ni besoin de contextualisation.
|
|
|
|
### Analyse
|
|
|
|
- **Avantages** :
|
|
- UX plus cohérente entre home, navigation et pages dédiées
|
|
- garde-fous, wording et fallbacks centralisés au même endroit
|
|
- facilite l'évolution future vers embed, click-to-load ou variantes de parcours
|
|
- **Limites / vigilance** :
|
|
- ajoute une étape intermédiaire si la page locale n'apporte aucune valeur
|
|
- demande de maintenir l'alignement entre les CTA internes et le parcours canonique
|
|
|
|
### Validation
|
|
|
|
- Validé le : 19-03-2026
|
|
- Contexte technique : site web public / intégration tierce en mode lien externe
|
|
|
|
### Implémentation (exemple minimal)
|
|
|
|
```txt
|
|
- faire pointer les CTA internes (home, nav, landing) vers une page locale dédiée
|
|
- faire de cette page locale le point canonique vers le service tiers externe
|
|
- centraliser sur cette page le contexte utile, les garde-fous et les fallbacks
|
|
- éviter les sorties directes concurrentes vers le tiers depuis plusieurs endroits du site
|
|
```
|
|
|
|
### Checklist
|
|
|
|
- [ ] Un parcours canonique unique est défini
|
|
- [ ] Les CTA internes convergent vers la page locale dédiée
|
|
- [ ] Les garde-fous et fallbacks sont centralisés
|
|
- [ ] Les sorties directes concurrentes vers le tiers sont évitées ou justifiées
|
|
|
|
---
|
|
|
|
<a id="pattern-click-to-load-embeds-tiers"></a>
|
|
## Pattern : Click-to-load strict pour les embeds tiers (iframe/widget)
|
|
|
|
### Synthèse
|
|
|
|
- **Objectif** : ne charger aucun service tiers sans action explicite de l'utilisateur (performance + consentement implicite).
|
|
- **Contexte** : site/webapp avec modules de réservation, map, chat ou tout embed iframe à la demande.
|
|
- **Quand l'utiliser** : dès qu'un embed tiers est chargé à la demande (pas au premier rendu).
|
|
- **Quand l'éviter** : si l'embed est central à la page et doit être visible immédiatement.
|
|
|
|
### Analyse
|
|
|
|
- **Avantages** :
|
|
- LCP non pollué par des tiers (performance-first)
|
|
- Aucun tiers ne reçoit de données utilisateur sans action volontaire (consentement implicite)
|
|
- Fallback toujours disponible en cas d'erreur iframe
|
|
- **Limites / vigilance** :
|
|
- Le fallback (lien externe + `tel:`) doit être actionnable même si l'embed échoue
|
|
|
|
### Validation
|
|
|
|
- Validé le : 21-03-2026
|
|
- Contexte technique : React / Next.js — app-template-resto
|
|
|
|
### Implémentation
|
|
|
|
```tsx
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [errored, setErrored] = useState(false);
|
|
|
|
if (errored) return <a href={url}>Ouvrir {label}</a>;
|
|
|
|
return (
|
|
<>
|
|
{!loaded && <button onClick={() => setLoaded(true)}>Charger {label}</button>}
|
|
{loaded && <iframe src={url} onError={() => setErrored(true)} />}
|
|
</>
|
|
);
|
|
```
|
|
---
|
|
|
|
<a id="pattern-navigation-overlay-focus-trap"></a>
|
|
## Pattern : Overlay/drawer accessible avec focus trap
|
|
|
|
### Synthèse
|
|
Teleport + backdrop + Escape + scroll-lock n'est pas suffisant : le focus trap est obligatoire.
|
|
|
|
### Analyse
|
|
Sans focus trap, le clavier peut sortir de la sheet/panel et casser l'ordre de navigation.
|
|
|
|
### Validation
|
|
- Validé le : 09-04-2026
|
|
- Contexte technique : Vue 3 / accessibilité overlays — RL799_V2
|
|
- Applicable à tout drawer/sheet/modal custom en SPA (desktop et mobile clavier).
|
|
|
|
### Implémentation
|
|
- Capturer le focus à l'ouverture (premier élément interactif).
|
|
- Boucler Tab/Shift+Tab dans le conteneur.
|
|
- Restaurer le focus au trigger à la fermeture.
|
|
|
|
---
|
|
|
|
<a id="pattern-factorisation-page-meta-mode"></a>
|
|
## Pattern : Factorisation page mode dynamique via `route.meta.mode` typé
|
|
|
|
### Synthèse
|
|
|
|
- **Objectif** : factoriser un composant Vue qui partage 95 % de sa logique entre plusieurs routes ne différant que par le wording, sans recourir à `route.name` (fragile) ou query string (manipulable).
|
|
- **Contexte** : projet Vue avec deux routes (ex : `/invitation` + `/reset-password`) qui partagent la mécanique technique stricte (validation token + saisie mot de passe + consume) et ne diffèrent que par le wording.
|
|
- **Quand l'utiliser** : factorisation justifiée par un partage > 90 % de logique entre routes.
|
|
- **Quand l'éviter** : routes qui diffèrent fonctionnellement au-delà du wording — préférer deux composants distincts.
|
|
|
|
### Analyse
|
|
|
|
- **Avantages** :
|
|
- explicite, déclaratif, type-checkable
|
|
- augmenter `RouteMeta` dans `vue-router` pour qu'un mode manquant produise une erreur build
|
|
- **Limites / vigilance** :
|
|
- wording centralisé en un seul `computed` — pas de `v-if` éparpillés dans le template
|
|
- endpoint dynamique en un seul `if/else` dans le handler
|
|
|
|
### Validation
|
|
|
|
- Validé le : 28-04-2026
|
|
- Contexte technique : Vue 3 / vue-router — RL799_V2
|
|
|
|
### Implémentation
|
|
|
|
```typescript
|
|
// router/index.ts
|
|
{
|
|
path: '/invitation',
|
|
name: 'invitation',
|
|
component: ResetPasswordPage,
|
|
meta: { requiresGuest: true, mode: 'invitation' as const },
|
|
},
|
|
{
|
|
path: '/reset-password',
|
|
name: 'reset-password',
|
|
component: ResetPasswordPage,
|
|
meta: { requiresGuest: true, mode: 'reset' as const },
|
|
},
|
|
```
|
|
|
|
```typescript
|
|
// ResetPasswordPage.vue
|
|
const pageMode = computed<PageMode>(() => {
|
|
const meta = route.meta as { mode?: PageMode } | undefined;
|
|
return meta?.mode === 'invitation' ? 'invitation' : 'reset';
|
|
});
|
|
|
|
const wording = computed(() => {
|
|
if (pageMode.value === 'invitation') {
|
|
return { eyebrow: 'Bienvenue', title: 'Définissez votre mot de passe', /* … */ };
|
|
}
|
|
return { eyebrow: 'Réinitialisation', title: '...', /* … */ };
|
|
});
|
|
|
|
const handleSubmit = async () => {
|
|
if (pageMode.value === 'invitation') {
|
|
await consumeInvitationToken(token.value, newPassword.value);
|
|
} else {
|
|
await consumeResetToken(token.value, newPassword.value);
|
|
}
|
|
// Post-consume uniforme : redirige /login pour les deux modes
|
|
};
|
|
```
|
|
|
|
### Règles d'or
|
|
|
|
- `meta.mode` typé en literal union (`'invitation' | 'reset'`)
|
|
- A11y obligatoire : autofocus sur le champ password au mount post-validation, `<label for>` associés, `<AppMessage variant="error">` avec `role="alert"`, `<AppMessage variant="success">` avec `aria-live="polite"`
|
|
- Test obligatoire : monter la page avec chaque `meta.mode` et vérifier que le wording correspond
|
|
|
|
### Anti-patterns
|
|
|
|
- Détecter le mode via `route.name` (fragile, couplage implicite, signal d'intention faible)
|
|
- 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);
|
|
```
|
|
|
|
---
|