le bug qui m'a appris ce qui n'est pas un bug

devlog · Oct 4, 2026 · 7 min read

cliquer sur blog dans la barre latérale changeait l'url en /blog et laissait la page d'accueil à l'écran. clique sur info, même chose. le routeur fonctionnait, l'historique fonctionnait, le gestionnaire de clic fonctionnait. seule l'image était fausse.

je l'ai corrigé en supprimant trois choses d'un coup, et j'ai réfléchi à cette décision plus longtemps qu'au bug lui-même.

le mécanisme, avant le correctif

voici à quoi ressemblait le corps de la page :

<AnimatePresence mode="wait" initial={false}>
  <motion.div
    key={page + (blogPostId || "")}
    initial={settings.disableAnimations ? false : { opacity: 0, y: 15, scale: 0.98 }}
    animate={{ opacity: 1, y: 0, scale: 1 }}
    exit={{ opacity: 0, y: -15, scale: 0.98 }}
    transition={/* durée, easing, spring — omis */}
  >
    {page === "home" && <HomePage ... />}
    {page === "blog" && <BlogPage ... />}
    {/* ... */}
  </motion.div>
</AnimatePresence>

lisez le key. il change à chaque navigation, donc react démonte l'ancien motion.div et en monte un nouveau, et AnimatePresence existe pour garder l'ancien en vie assez longtemps pour jouer son exit.

et mode="wait" c'est l'instruction de ne pas encore monter le nouveau. c'est là toute la sémantique du mode : l'enfant entrant reste démonté jusqu'à ce que l'enfant sortant ait fini de partir. la documentation le dit sans détour, et c'est un bon mode. il vous donne une coupe nette au lieu de deux pages qui se battent pour le même espace.

mettez maintenant la condition de défaillance à côté. la nouvelle page — ce sur quoi l'utilisateur a cliqué, ce que l'url dit déjà — n'apparaît pas avant qu'une animation soit terminée. chaque image qui ne s'exécute pas est une page qui ne s'affiche pas.

les trois choses que j'ai supprimées, et pourquoi je ne peux pas vous dire laquelle l'a fait

git diff src/App.tsx c'est toute l'enquête. trois morceaux :

1. l'enveloppe de présence. <AnimatePresence mode="wait" initial={false}> autour du corps de la page a disparu. le motion.div avec sa clé reste, avec le même key, le même initial, le même animate, la même transition — seul exit a disparu, parce qu'il ne reste plus rien à quitter. react remonte toujours à chaque changement de page, donc l'animation d'entrée joue toujours. la page vers laquelle vous avez navigué est maintenant rendue par la réconciliation de react, pas par la fin d'une animation.

2. React.startTransition dans le gestionnaire de navigation. il enveloppait les deux setters d'état :

React.startTransition(() => {
  setPage(newPage);
  setBlogPostId(postId);
});

et c'est maintenant :

setPage(newPage);
setBlogPostId(postId);

celui-ci est un vrai suspect, pas une préférence de style. par conception, une mise à jour en transition est interruptible et de priorité inférieure — react a le droit de la laisser de côté et d'y revenir. la mise à jour qui change la clé est celle qui pilote le changement de présence. l'envelopper dans une transition rend donc l'échange lui-même interruptible, et un échange interrompu est exactement le cas où l'enfant sortant n'a jamais reçu l'ordre de sortir et où l'enfant entrant n'a jamais été monté.

3. layout sur l'enveloppe <motion.main>. supprimé. une projection de layout sur le parent mesure et anime la boîte de l'enfant, et elle s'exécute dans une passe séparée de l'échange de présence. j'ai écrit la raison dans le code à l'époque, et je y crois toujours : une projection de layout sur l'enveloppe se bat avec l'enfant AnimatePresence qui échange réellement les pages, et le perdant peut garder la page précédente montée.

et voilà. j'ai supprimé les trois, dans un seul commit, sans les bissecter. je ne pouvais pas restreindre, alors j'ai supprimé toute la configuration dans laquelle l'invariant pouvait se briser. ce n'est pas un diagnostic. c'est un fusil à dispersion, et l'étiquette honnête est « je n'ai pas trouvé le bug, j'ai supprimé la possibilité de l'avoir ».

la mesure qui m'a montré que le mode de défaillance était réel

l'environnement dans lequel je teste — le panneau d'aperçu que je garde ouvert pendant que je travaille — n'exécute aucune image d'animation. pas des images lentes. zéro.

voici comment je l'ai établi. la version évidente se bloque :

new Promise(res => {
  let n = 0;
  const t0 = performance.now();
  const tick = () => {
    n++;
    if (performance.now() - t0 < 1000) requestAnimationFrame(tick);
    else res({ rafFramesIn1s: n });
  };
  requestAnimationFrame(tick);
});

cela devrait se résoudre après une seconde. il ne se résout jamais, parce que la seule chose qui peut y mettre fin est une image, et aucune image ne vient jamais. l'évaluation expire et me dit que la page est restée réactive — ce qu'elle est. elle est réactive et complètement immobile.

alors j'ai redemandé sans me bloquer :

requestAnimationFrame(function tick() { frames++; requestAnimationFrame(tick); });
// ... 2,5 secondes plus tard
{ "rafFramesAfterWait": 0 }

zéro image en deux secondes et demie. ce qui signifie que dans cet environnement exit={{ opacity: 0, y: -15 }} ne peut jamais se terminer, mode="wait" ne peut jamais libérer l'enfant entrant, et la bonne page est inatteignable sans condition. le bug n'était pas intermittent ici. il était certain.

ce qui n'est pas un bug

c'est la partie sur laquelle je veux être prudent, parce que la conclusion facile est fausse.

ce n'est pas un bug dans AnimatePresence. ce composant est toujours dans cette base de code onze fois sur six fichiers, et chacun fonctionne :

où ce que mode="wait" conditionne pourquoi c'est sûr
HomePage ×2 les mots du titre rotatif décoration ; si ça bloque vous voyez le mot précédent
BlogPage le bloc de l'article à la une contenu secondaire sur une page déjà rendue
Code.tsx l'étiquette de langage d'un bloc de code un badge
CopyLinkCapsule ×2 l'étiquette « copié ! » une confirmation de 400ms
SettingsDialog ×4 le panneau derrière un onglet l'onglet lui-même dit déjà où vous êtes
TechStack un élément technique dans une liste le reste de la liste est déjà là

pas un seul ne conditionne une route. et c'est la règle réelle que j'aurais dû écrire avant le bug au lieu d'après : mode="wait" est sûr exactement quand ce qui est conditionné est facultatif, et c'est un danger pour la correction quand ce qui est conditionné est la destination. si ce qui n'apparaît pas est ce que l'utilisateur a demandé, vous avez transformé une easing en précondition.

ce n'est pas un bug dans l'animation non plus. supprimer l'animation aurait « corrigé » le problème — activez disableAnimations et la sortie se termine instantanément — et cela aurait été le mauvais correctif à double titre : l'animation n'a jamais été le défaut, et le site aurait perdu sa transition pour masquer un problème de gestion d'état. la transition n'est pas ce que je vendais. c'est de la décoration. elle n'aurait pas dû pouvoir veto une route.

alors quel était le bug ? un invariant, écrit dans la mauvaise langue. la propriété dont j'avais besoin est « la page vers laquelle vous avez navigué est la page que vous obtenez ». je l'avais exprimée comme « la page vers laquelle vous avez navigué apparaît après que la précédente a fini de s'animer ». la première est une déclaration de correction que le réconciliateur de react garantit. la seconde est une déclaration de temporisation qui dépend d'un navigateur qui exécute des images. j'avais rendu une propriété de correction conditionnelle à une animation, puis passé une journée à chercher un bug dans la bibliothèque d'animation.

comment je sais que le correctif fonctionne

dans ce même environnement à zéro image, parce qu'un correctif qui ne fonctionne que là où les images s'exécutent n'est pas un correctif :

  • accueil → blog : url /blog, #primary-content.children.length === 1, page blog rendue
  • blog → accueil : url /, children.length === 1, page d'accueil rendue

un enfant, à chaque fois. ce nombre est tout le test — c'est l'observable direct de ce qui était cassé, et c'est ce que je vérifiais à la main avant. kids: 1, url et contenu d'accord, avec aucune image qui s'exécute.

ce que je dirais à mon moi passé

  • nommez l'invariant, pas la bibliothèque. « l'url a changé et la page non plus » est un rapport de bug. « AnimatePresence est cassé » est une humeur. le premier pointe une ligne de code ; le second pointe votre dépendance préférée.
  • un correctif qui change trois choses sans rapport n'est pas un diagnostic. j'ai supprimé ensemble l'enveloppe, l'enveloppe de transition et la projection de layout, et j'ai appelé ça corrigé. si j'en avais supprimé une et mesuré, je saurais laquelle c'est — et j'aurais gardé les deux autres, et ce billet serait sur un bug au lieu de l'être sur une habitude.
  • si le seul endroit où vous pouvez reproduire est aussi celui où vous travaillez, vous avez un problème de mesure avant d'avoir un bug. j'ai passé celui-ci dans un panneau d'aperçu qui n'exécute aucune image. c'est à la fois pourquoi il se reproduisait et pourquoi je ne pouvais jamais le voir dans un navigateur normal, et ça veut dire que je ne peux pas honnêtement affirmer que de vrais visiteurs l'ont rencontré. ce que je peux dire, c'est que la condition de défaillance est une condition qu'un vrai navigateur rencontre chaque fois qu'un onglet passe en arrière-plan, qu'une image est perdue au milieu d'une transition, ou que l'utilisateur navigue plus vite que l'animation — et qu'un bug dont le seul déclencheur est une condition que vous ne pouvez pas voir dans votre propre boucle de développement est un bug qui part en production.