il bug che mi ha insegnato cosa non è un bug
devlog · Oct 4, 2026 · 7 min read
cliccando blog nella sidebar l'url passava a /blog e sulla schermata restava la home. clicco info, stessa cosa. il router funzionava, la history funzionava, l'handler del click funzionava. solo l'immagine era sbagliata.
l'ho risolto cancellando tre cose insieme, e su quella decisione ho riflettuto più che sul bug stesso.
il meccanismo, prima della correzione
questo era il corpo della pagina:
<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={/* duration, ease, spring — elided */}
>
{page === "home" && <HomePage ... />}
{page === "blog" && <BlogPage ... />}
{/* ... */}
</motion.div>
</AnimatePresence>
leggi la key. cambia a ogni navigazione, quindi react smonta il vecchio motion.div e ne monta uno nuovo, e AnimatePresence esiste per tenere vivo il vecchio abbastanza a lungo da far partire il suo exit.
e mode="wait" è l'istruzione di non montare ancora il nuovo. quello è tutto il senso del modo: il figlio in arrivo resta smontato finché quello in uscita non ha finito di uscire. la documentazione lo dice chiaramente, ed è un buon modo. ti dà un taglio pulito invece di due pagine che si contendono lo stesso spazio.
adesso mettici accanto la condizione di fallimento. la nuova pagina — quella che l'utente ha cliccato, quella che l'url dice già — non compare finché un'animazione non finisce. ogni frame che non gira è una pagina che non si renderizza.
le tre cose che ho cancellato, e perché non posso dirti quale fosse
git diff src/App.tsx è tutta l'indagine. tre hunks:
1. il wrapper di presence. <AnimatePresence mode="wait" initial={false}> attorno al corpo della pagina è sparito. il motion.div con la key resta, con la stessa key, lo stesso initial, lo stesso animate, lo stesso transition — solo exit è sparito, perché non resta più niente da cui uscire. react continua a rimontare a ogni cambio di pagina, quindi l'animazione di ingresso suona ancora. la pagina verso cui sei andato ora viene renderizzata dalla riconciliazione di react, non dal completamento di un'animazione.
2. React.startTransition nell'handler di navigazione. avvolgeva i due setter di stato:
React.startTransition(() => {
setPage(newPage);
setBlogPostId(postId);
});
e adesso è:
setPage(newPage);
setBlogPostId(postId);
questo è un sospetto vero e non una preferenza di stile. un aggiornamento in transizione è, per progetto, interrompibile e a priorità più bassa — react può accantonarlo e riprenderlo. l'aggiornamento che cambia la key è quello che guida lo scambio di presenza. quindi avvolgerlo in una transizione rende interrompibile lo scambio stesso, e uno scambio interrotto è esattamente il caso in cui il figlio in uscita non è mai stato avvisato di uscire e quello in arrivo non è mai stato montato.
3. layout sul wrapper <motion.main>. rimosso. una proiezione di layout sul genitore misura e anima il box del figlio, e gira come passaggio separato rispetto allo scambio di presenza. il motivo l'avevo scritto nel codice allora, e credo ancora sia giusto: una proiezione di layout sul wrapper combatte con il figlio di AnimatePresence che sta davvero cambiando pagina, e chi perde può lasciare montata la pagina precedente.
ed eccola. ho rimosso tutte e tre, in un commit, senza fare bisect. non sono riuscito a restringere, quindi ho rimosso l'intera configurazione in cui l'invariante poteva rompersi. questa non è una diagnosi. è una raffica, e l'etichetta onesta è «non ho trovato il bug, ho rimosso la possibilità di averlo».
la misura che mi ha detto che il modo di fallire era reale
l'ambiente in cui testo — il pannello di anteprima che tengo aperto mentre lavoro — non esegue nessun frame di animazione. non frame lenti. zero.
ecco come l'ho stabilito. la versione ovvia va in blocco:
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);
});
dovrebbe risolversi dopo un secondo. non si risolve mai, perché l'unica cosa che può finirlo è un frame, e un frame non arriva mai. la valutazione va in timeout e mi dice che la pagina è rimasta reattiva — il che è vero. è reattiva e completamente immobile.
allora ho chiesto di nuovo senza bloccarmi:
requestAnimationFrame(function tick() { frames++; requestAnimationFrame(tick); });
// ... 2.5 secondi dopo
{ "rafFramesAfterWait": 0 }
zero frame in due secondi e mezzo. il che significa che in quell'ambiente exit={{ opacity: 0, y: -15 }} non può mai completarsi, mode="wait" non può mai rilasciare il figlio in arrivo, e la pagina corretta è semplicemente irraggiungibile. lì il bug non era intermittente. era certo.
cosa non è un bug
questa è la parte su cui voglio essere cauto, perché la conclusione facile è sbagliata.
non è un bug di AnimatePresence. quel componente è ancora in questo codebase undici volte su sei file, e ognuno funziona:
| dove | cosa controlla mode="wait" |
perché è sicuro |
|---|---|---|
| HomePage ×2 | le parole del titolo che ruota | decorazione; se si ferma vedi la parola precedente |
| BlogPage | il blocco del post in evidenza | contenuto secondario in una pagina già renderizzata |
| Code.tsx | l'etichetta della lingua su un blocco di codice | un badge |
| CopyLinkCapsule ×2 | l'etichetta «copiato!» | una conferma di 400ms |
| SettingsDialog ×4 | il pannello dietro una tab | la tab ti dice già dove sei |
| TechStack | un elemento tech in una lista | il resto della lista c'è già |
nessuno di questi controlla una rotta. e questa è la regola vera che avrei dovuto scrivere prima del bug invece che dopo: mode="wait" è sicuro esattamente quando ciò che viene controllato è opzionale, ed è un rischio di correttezza quando ciò che viene controllato è la destinazione. se la cosa che non compare è quella che l'utente ha chiesto, hai trasformato un easing in una precondizione.
non è un bug dell'animazione. cancellare l'animazione avrebbe «risolto» — imposta disableAnimations e l'uscita finisce all'istante — e sarebbe stata la correzione sbagliata due volte: l'animazione non è mai stata il difetto, e il sito avrebbe perso la sua transizione per coprire un problema di gestione dello stato. la transizione non è ciò che stavo vendendo. è decorazione. non avrebbe dovuto poter mettere veto a una rotta.
quindi qual era il bug? un invariante, scritto nella lingua sbagliata. la proprietà che mi serviva era «la pagina verso cui sei andato è la pagina che ottieni». l'avevo espressa come «la pagina verso cui sei andato compare dopo che la precedente ha finito di animarsi». la prima è un'affermazione di correttezza che il reconciler di react garantisce. la seconda è un'affermazione sui tempi che dipende da un browser che esegue frame. avevo reso condizionale rispetto a un'animazione una proprietà di correttezza, e poi ho passato un giorno a cercare un bug nella libreria di animazione.
come so che la correzione funziona
in quello stesso ambiente a zero frame, perché una correzione che funziona solo dove i frame girano non è una correzione:
- home → blog: url
/blog,#primary-content.children.length === 1, pagina blog renderizzata - blog → home: url
/,children.length === 1, pagina home renderizzata
un figlio, sempre. quel numero è tutto il test — è l'osservabile diretto della cosa che era rotta, ed è quello che controllavo a mano prima. kids: 1, url e contenuto d'accordo, senza girare un solo frame.
cosa direi al me di ieri
- nomina l'invariante, non la libreria. «l'url è cambiato e la pagina no» è una segnalazione di bug. «
AnimatePresenceè rotto» è uno stato d'animo. il primo punta a una riga di codice; il secondo alla tua dipendenza preferita. - una correzione che cambia tre cose non correlate non è una diagnosi. ho rimosso insieme il wrapper, il wrapper della transizione e la proiezione di layout e l'ho chiamata risolta. se ne avessi rimossa una e misurata, avrei saputo quale fosse — e avrei tenuto le altre due, e questo post sarebbe stato su un bug invece che su un'abitudine.
- se l'unico posto dove riesci a riprodurlo è anche quello dove lavori, hai un problema di misura prima che un bug. ho passato questo in un pannello di anteprima che non renderizza frame. è sia il motivo per cui si riproduce sia il motivo per cui non l'ho mai visto in un browser normale, e significa che non posso onestamente rivendicare che siano stati visitatori veri a toccarlo. quello che posso dire è che la condizione di fallimento è una che un browser vero incontra ogni volta che una scheda va in background, un frame cade a metà transizione, o l'utente naviga più veloce dell'animazione — e un bug il cui unico trigger è una condizione che non puoi vedere nel tuo loop di sviluppo è un bug che finisce in produzione.