mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-04-06 21:41:42 +02:00
379 lines
12 KiB
Markdown
379 lines
12 KiB
Markdown
# Frontend — Risques & vigilance : Next.js
|
|
|
|
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/frontend/risques/README.md` pour l'index complet.
|
|
|
|
---
|
|
|
|
<a id="risque-usesearchparams-sans-suspense"></a>
|
|
## `useSearchParams()` sans `Suspense` casse le build Next.js App Router
|
|
|
|
### Risques
|
|
|
|
- Un composant client utilisant `useSearchParams()` peut provoquer un échec de prerender/build s'il est rendu sans boundary `Suspense` depuis la page/layout serveur
|
|
|
|
### Symptômes
|
|
|
|
- `Error: useSearchParams() should be wrapped in a suspense boundary` au `next build`
|
|
- Fonctionne en dev mais échoue à la CI/CD
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
- Isoler le composant client qui utilise `useSearchParams()` et le rendre sous `<Suspense fallback={...}>` au niveau de la page
|
|
- Ne jamais appeler `useSearchParams()` directement dans un composant rendu sans `Suspense` depuis un Server Component
|
|
|
|
- Contexte technique : Next.js App Router récent / Turbopack — app-template-resto 16-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-type-viewdata-duplique"></a>
|
|
## Type `ViewData` dupliqué entre couche serveur et composant UI (Next.js)
|
|
|
|
### Risques
|
|
|
|
- TypeScript accepte deux structures identiques par structural typing — si le type source évolue, la couche UI reste désynchronisée sans erreur de compilation tant que les formes correspondent
|
|
|
|
### Symptômes
|
|
|
|
- Deux définitions du même type dans `src/server/` et `src/app/`
|
|
- Champ ajouté côté serveur mais absent dans le composant UI sans warning
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
```typescript
|
|
// ✅ La couche UI importe et re-exporte
|
|
export type { PublicHomeViewData } from "@/server/public/getPublicHomeData";
|
|
|
|
// ❌ À éviter — redéfinition locale
|
|
export type PublicHomeViewData = { tenantName: string; ... };
|
|
```
|
|
|
|
- Règle : le type appartient à la couche qui le produit. La couche UI importe uniquement.
|
|
|
|
- Contexte technique : Next.js App Router / TypeScript — app-template-resto 16-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-composant-react-fichier-ts"></a>
|
|
## Composant React dans un fichier `.ts` — `React.createElement` workaround
|
|
|
|
### Risques
|
|
|
|
- Code illisible vs JSX natif
|
|
- Fausse impression que le fichier est "sans JSX" — peut tromper les outils de linting et les reviewers
|
|
- Empêche l'utilisation de la syntaxe JSX si on doit ajouter des enfants complexes
|
|
|
|
### Symptômes
|
|
|
|
- `React.createElement(...)` dans un fichier `.ts`
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
- Tout fichier exportant une fonction retournant un `ReactElement` ou utilisant React doit avoir l'extension `.tsx`
|
|
- Sans exception
|
|
|
|
- Contexte technique : TypeScript / React — app-template-resto 16-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-double-validation-segment-app-router"></a>
|
|
## Double validation de segment dynamique App Router (layout + page)
|
|
|
|
### Risques
|
|
|
|
- Si le layout fait `notFound()` sur un segment invalide ET que la page répète la même condition, les deux deviennent désynchronisés silencieusement lors d'une modification
|
|
|
|
### Symptômes
|
|
|
|
- Même condition de validation dans `layout.tsx` et `page.tsx` d'un même segment
|
|
- Modification du layout n'est pas reportée dans la page (comportement divergent)
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
- Si le layout garde, la page consomme — une seule responsabilité par couche
|
|
- La page doit faire confiance à son layout parent
|
|
- **Règle** : un seul composant est responsable de la garde sur un segment dynamique
|
|
|
|
- Contexte technique : Next.js App Router — app-template-resto 17-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-window-location-reload-nextjs"></a>
|
|
## Next.js App Router : `window.location.reload()` au lieu de `router.refresh()`
|
|
|
|
### Risques
|
|
|
|
- Full reload = perd l'état React, navigation complète, plus lent
|
|
- `router.refresh()` est l'outil idoine : retrigger le fetch des Server Components sans détruire l'état client
|
|
|
|
### Symptômes
|
|
|
|
- `window.location.reload()` après un Server Action dans un Client Component
|
|
- Flash de rechargement visible, perte de l'état local (scroll, focus, état de formulaire)
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
```tsx
|
|
// ❌ Anti-pattern — full reload
|
|
await createCategoryAction(formData);
|
|
window.location.reload();
|
|
|
|
// ✅ Pattern correct — RSC diff, préserve l'état client
|
|
const router = useRouter();
|
|
await createCategoryAction(formData);
|
|
router.refresh();
|
|
```
|
|
|
|
- `router.refresh()` refetch uniquement les Server Components affectés (via `revalidatePath`) et applique un diff. L'état des Client Components est préservé.
|
|
|
|
- Contexte technique : Next.js App Router — app-template-resto 21-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-consent-state-false-ambigu"></a>
|
|
## Consent state : `false` ambigu entre "pas de décision" et "refus explicite"
|
|
|
|
### Risques
|
|
|
|
- Sans champ `decided`, `analytics: false` peut signifier "première visite" ou "refus explicite" — indistinguables
|
|
- Le banner de consentement réapparaît à chaque visite après un refus, violant l'AC de persistance du choix
|
|
|
|
### Symptômes
|
|
|
|
- Banner qui réapparaît après rechargement malgré un refus explicite
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
```typescript
|
|
type ConsentState = {
|
|
analytics: boolean;
|
|
decided: boolean; // true = l'utilisateur a fait un choix (cookie présent)
|
|
};
|
|
|
|
const DEFAULT: ConsentState = { analytics: false, decided: false };
|
|
|
|
// À la lecture du cookie :
|
|
if (!cookieValue) return DEFAULT; // decided=false (première visite)
|
|
return { analytics: parsed.analytics, decided: true };
|
|
```
|
|
|
|
- L'état initial du banner doit être `!decided`, pas `!analytics`
|
|
|
|
- Contexte technique : Next.js / cookies — app-template-resto 21-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-script-inline-interpolation-directe"></a>
|
|
## Script inline : interpolation directe au lieu de `JSON.stringify`
|
|
|
|
### Risques
|
|
|
|
- Injection XSS potentielle via une valeur de configuration interpolée directement dans un `<Script>` inline
|
|
- La regex de validation en amont peut évoluer et laisser passer des valeurs dangereuses
|
|
|
|
### Symptômes
|
|
|
|
- `` {`gtag('config', '${measurementId}');`} `` — interpolation directe sans échappement
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
```tsx
|
|
// ❌ Anti-pattern — interpolation directe
|
|
{`gtag('config', '${measurementId}');`}
|
|
|
|
// ✅ Pattern correct — JSON.stringify garantit l'échappement
|
|
{`gtag('config', ${JSON.stringify(measurementId)});`}
|
|
```
|
|
|
|
- S'applique aussi aux `dangerouslySetInnerHTML` et aux attributs `data-*` injectés en JS
|
|
|
|
- Contexte technique : Next.js / `<Script>` — app-template-resto 21-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-usetransition-snapshot-apres-setstate"></a>
|
|
## `useTransition` + optimistic update : snapshot capturé après `setState`
|
|
|
|
### Risques
|
|
|
|
- Stale closure classique : le snapshot est capturé après `setState`, donc `categories` peut déjà référencer la nouvelle liste au moment du rollback
|
|
|
|
### Symptômes
|
|
|
|
- Rollback optimiste qui ne restaure pas l'ancienne valeur
|
|
- Après une erreur serveur, l'état reste sur la nouvelle liste au lieu de revenir à l'état précédent
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
```tsx
|
|
// ❌ Anti-pattern — snapshot capturé après setState
|
|
const newList = [...categories];
|
|
setCategories(newList);
|
|
startTransition(async () => {
|
|
try { await action(); }
|
|
catch { setCategories(categories); } // peut être newList
|
|
});
|
|
|
|
// ✅ Pattern correct — snapshot AVANT toute mutation d'état
|
|
const snapshot = categories; // capturer AVANT setCategories
|
|
setCategories(newList);
|
|
startTransition(async () => {
|
|
try { await action(); }
|
|
catch { setCategories(snapshot); } // rollback garanti
|
|
});
|
|
```
|
|
|
|
- **Règle** : toujours assigner le snapshot dans un `const` **avant** le premier `setState`
|
|
|
|
- Contexte technique : React / Next.js App Router — app-template-resto 21-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-window-confirm-react"></a>
|
|
## `window.confirm()` dans une app React/Next.js
|
|
|
|
### Risques
|
|
|
|
- Bloque le thread principal
|
|
- Ne fonctionne pas en SSR
|
|
- Non stylable, UX mobile mauvaise
|
|
|
|
### Symptômes
|
|
|
|
- `if (!confirm("Supprimer ?")) return;` dans un Client Component
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
```tsx
|
|
// ❌ Anti-pattern
|
|
if (!confirm("Supprimer ?")) return;
|
|
|
|
// ✅ Pattern correct — confirmation inline via état React
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
|
|
{deletingId === item.id && (
|
|
<div>
|
|
<span>Supprimer « {item.label} » ?</span>
|
|
<button onClick={() => { setDeletingId(null); doDelete(item.id); }}>Confirmer</button>
|
|
<button onClick={() => setDeletingId(null)}>Annuler</button>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
- S'applique aussi à `window.alert()` et `window.prompt()`
|
|
|
|
- Contexte technique : React / Next.js — app-template-resto 21-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-import-type-server-composant-client"></a>
|
|
## `import type` depuis `src/server/**` dans un composant client
|
|
|
|
### Risques
|
|
|
|
- Violation de boundary même si l'import est type-only (effacé à la compilation)
|
|
- Ouvre la porte à des imports runtime si le code est refactoré rapidement
|
|
- La règle ESLint `no-restricted-imports` doit couvrir les `import type` aussi
|
|
|
|
### Symptômes
|
|
|
|
- `import type { Foo } from "@/server/..."` dans un fichier `"use client"`
|
|
- Passe en review car le compilateur ne bloque pas les type-only imports
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
- Types partagés entre server et client doivent vivre dans `src/types/` ou `src/lib/`
|
|
- Configurer `no-restricted-imports` avec `allowTypeImports: false` pour les paths serveur
|
|
|
|
- Contexte technique : Next.js App Router / TypeScript — app-template-resto 22-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-img-natif-nextjs"></a>
|
|
## Next.js : `<img>` natif interdit dans les composants
|
|
|
|
### Risques
|
|
|
|
- Warning ESLint `@next/next/no-img-element` → avec `--max-warnings=0` : erreur CI
|
|
- Pas de lazy loading, pas d'optimisation WebP, risque de layout shift (CLS)
|
|
|
|
### Symptômes
|
|
|
|
- `<img src="..." />` dans un composant Next.js
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
- Toujours utiliser `<Image>` de `next/image` à la place
|
|
- Exception acceptable : composants de test ou storybook uniquement
|
|
|
|
- Contexte technique : Next.js / ESLint — app-template-resto 22-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-usetransition-global-liste-items"></a>
|
|
## `useTransition` global pour des listes d'items interactifs
|
|
|
|
### Risques
|
|
|
|
- `isPending` global désactive **tous** les boutons de tous les items pendant qu'une opération est en cours sur un seul item
|
|
- Sur mobile : UX bloquée, impossible d'agir pendant qu'une autre opération tourne
|
|
|
|
### Symptômes
|
|
|
|
- Clic sur "Masquer" pour l'item A → boutons des items B et C grisés
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
```tsx
|
|
// ❌ Avant — bloque tout
|
|
const [isPending, startTransition] = useTransition();
|
|
// render : disabled={isPending}
|
|
|
|
// ✅ Après — per-item
|
|
const [pendingId, setPendingId] = useState<string | null>(null);
|
|
|
|
function handleToggle(id: string) {
|
|
setPendingId(id);
|
|
(async () => {
|
|
try { await toggleAction(id); }
|
|
catch (err) { handleError(err); }
|
|
finally { setPendingId(null); }
|
|
})();
|
|
}
|
|
// render : disabled={pendingId === item.id}
|
|
```
|
|
|
|
**Règles :**
|
|
- `pendingId === item.id` pour les boutons d'item (désactive uniquement l'item en cours)
|
|
- `pendingId !== null` pour les boutons globaux (ex: "Ajouter")
|
|
- `finally` garantit la réinitialisation même en cas d'erreur
|
|
|
|
- Contexte technique : React / Next.js — app-template-resto 22-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-formulaire-defaultvalue-sans-key"></a>
|
|
## Formulaire React avec `defaultValue` sans `key` prop
|
|
|
|
### Risques
|
|
|
|
- `defaultValue`, `defaultChecked`, `defaultSelected` ne s'appliquent qu'au montage
|
|
- Si le composant est réutilisé (même nœud DOM, nouvelle prop) sans être démonté, les valeurs ne se mettent pas à jour
|
|
|
|
### Symptômes
|
|
|
|
- L'utilisateur édite l'entité A, clique sur "Modifier" pour l'entité B → le formulaire affiche encore les données de A
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
```tsx
|
|
// Fix obligatoire : key unique basée sur l'ID de l'entité éditée
|
|
<EntityForm
|
|
key={formState.mode === "edit" ? formState.entity.id : `create-${formState.contextId}`}
|
|
...
|
|
/>
|
|
```
|
|
|
|
- **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
|