Files
MaksTinyWorkshop 9b7af9f1b0 Refonte Structure
2026-03-25 08:34:19 +01:00

12 KiB

Frontend — Risques & vigilance : Next.js

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


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


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

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


Composant React dans un fichier .tsReact.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


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


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

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


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

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


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

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


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

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


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

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


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


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


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

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


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

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