der bug, der mir beibrachte, was kein bug ist

devlog · Oct 4, 2026 · 7 min read

ein klick auf blog in der sidebar hat die url auf /blog geändert und die home-seite auf dem bildschirm gelassen. klick auf info, dasselbe. der router funktionierte, die history funktionierte, der click-handler funktionierte. nur das bild war falsch.

ich habe es behoben, indem ich drei dinge auf einmal gelöscht habe, und ich habe über diese entscheidung nachgedacht als über den bug selbst.

## der mechanismus, vor der behebung

so sah der seiten-body früher aus:

```tsx
<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>
```

lies das `key`. es ändert sich bei jeder navigation, also unmountet react das alte `motion.div` und mountet ein neues, und `AnimatePresence` existiert, um das alte lange genug am leben zu halten, damit es sein `exit` spielen kann.

**und `mode="wait"` ist die anweisung, das neue noch nicht zu mounten.** das ist die gesamte semantik des mode: das einkommende kind bleibt unmountet, bis das ausgehende kind fertig ist. die docs sagen es klar, und es ist ein guter mode. er gibt dir einen sauberen schnitt, statt zwei seiten im selben raum kämpfen zu lassen.

setzen wir jetzt die fehlerbedingung daneben. **die neue seite — das, worauf der nutzer geklickt hat, das, was die url bereits sagt — erscheint erst, wenn eine animation fertig ist.** jedes frame, das nicht läuft, ist eine seite, die nicht rendert.

## die drei dinge, die ich gelöscht habe, und warum ich nicht sagen kann welches es war

`git diff src/App.tsx` ist die gesamte untersuchung. drei hunks:

**1. der presence-wrapper.** `<AnimatePresence mode="wait" initial={false}>` um den seiten-body ist weg. das getastete `motion.div` bleibt, mit demselben `key`, demselben `initial`, demselben `animate`, demselben `transition` — nur `exit` ist weg, weil es nichts mehr gibt, woraus man aussteigen könnte. react remountet weiterhin bei jedem seitenwechsel, also spielt die enter-animation weiterhin ab. die seite, zu der du navigiert bist, wird jetzt von react eigener reconciliation gerendert, nicht von einer animation, die fertig wird.

**2. `React.startTransition` im navigation-handler.** es umschloss die beiden state-setter:

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

und jetzt ist es:

```tsx
setPage(newPage);
setBlogPostId(postId);
```

das ist ein echter verdächtiger und keine stilfrage. ein transition-update ist von bauart **unterbrechbar und niedriger priorität** — react darf es unterbrechen und später wieder aufnehmen. das update, das den key ändert, ist das update, das den presence-tausch antreibt. es in eine transition zu wickeln macht also *den tausch selbst* unterbrechbar, und ein unterbrochener tausch ist genau der fall, in dem dem ausgehenden kind nie gesagt wurde, dass es aussteigen soll, und das einkommende kind nie gemountet wurde.

**3. `layout` auf dem `<motion.main>`-wrapper.** entfernt. eine layout-projektion auf dem elternelement misst und animiert die box des kindes, und sie läuft als separater durchgang getrennt vom presence-tausch. ich habe den grund damals in den code geschrieben und glaube immer noch daran: *eine layout-projektion auf dem wrapper kämpft gegen das AnimatePresence-kind, das tatsächlich die seiten wechselt, und der verlierer kann die vorherige seite gemountet lassen.*

und da ist er. **ich habe alle drei entfernt, in einem commit, ohne sie zu halbieren.** ich konnte es nicht eingrenzen, also habe ich die gesamte konfiguration entfernt, in der die invariant brechen konnte. das ist keine diagnose. das ist ein schrotflinten, und das ehrliche etikett dafür ist „ich habe den bug nicht gefunden, ich habe die möglichkeit entfernt, ihn zu haben".

## die messung, die mir sagte, dass die fehlerform echt war

die umgebung, in der ich teste — das preview-panel, das ich offen habe, während ich arbeite — **führt überhaupt keine animations-frames aus.** nicht langsame frames. null.

so habe ich das festgestellt. die naheliegende version hängt:

```js
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);
});
```

das sollte nach einer sekunde auflösen. es löst nie auf, denn das einzige, was es beenden kann, ist ein frame, und es kommt nie eins. die auswertung läuft in einen timeout und sagt mir, die seite sei reaktionsfähig geblieben — was sie ist. sie ist reaktionsfähig und völlig bewegungslos.

also habe ich nochmal gefragt, ohne zu hängen:

```js
requestAnimationFrame(function tick() { frames++; requestAnimationFrame(tick); });
// ... 2,5 sekunden später
{ "rafFramesAfterWait": 0 }
```

**null frames in zweieinhalb sekunden.** das bedeutet, dass in dieser umgebung `exit={{ opacity: 0, y: -15 }}` nie fertig werden kann, `mode="wait"` das einkommende kind nie freigeben kann, und die richtige seite unbedingt unerreichbar ist. der bug war dort nicht intermittierend. er war gewiss.

## was kein bug ist

das ist der teil, bei dem ich vorsichtig sein will, weil die einfache schlussfolgerung falsch ist.

**es ist kein bug in `AnimatePresence`.** diese komponente ist in diesem codebase **elfmal über sechs dateien**, und jede einzelne funktioniert:

| wo | was `mode="wait"` freigibt | warum es sicher ist |
| --- | --- | --- |
| HomePage ×2 | die wechselnden headline-wörter | deko; wenn es stockt, siehst du das vorherige wort |
| BlogPage | der hervorgehobene post-block | sekundärer inhalt auf einer seite, die schon gerendert ist |
| Code.tsx | das sprachlabel auf einem code-block | ein badge |
| CopyLinkCapsule ×2 | das „kopiert!"-label | eine 400-ms-bestätigung |
| SettingsDialog ×4 | das panel hinter einem tab | der tab selbst sagt dir schon, wo du bist |
| TechStack | ein tech-eintrag in einer liste | der rest der liste ist ohnehin schon da |

keiner davon gibt eine **route** frei. und das ist die eigentliche regel, die ich vor dem bug hätte aufschreiben sollen statt danach: `mode="wait"` ist genau dann sicher, wenn das freigegebene optional ist, und ein korrektheitsrisiko, wenn das freigegebene das ziel ist. wenn das ding, das nicht erscheint, das ist, worum der nutzer gebeten hat, hast du aus einer easing eine vorbedingung gemacht.

**und es ist auch kein bug in der animation.** die animation zu löschen hätte es „behoben" — `disableAnimations` setzen und der exit ist sofort fertig — und das wäre zweimal der falsche fix: die animation war nie der defekt, und die seite hätte ihren übergang verloren, um ein state-management-problem zu kaschieren. der übergang ist nicht das, was ich verkauft habe. er ist deko. er hätte nie eine route vetoieren dürfen.

**also was war der bug?** eine invariant, in der falschen sprache geschrieben. die eigenschaft, die ich brauchte, ist *„die seite, zu der du navigiert bist, ist die seite, die du bekommst"*. ich hatte sie ausgedrückt als *„die seite, zu der du navigiert bist, erscheint, nachdem die vorherige fertig animiert ist"*. die erste ist eine aussage über korrektheit, die reacts reconciler garantiert. die zweite ist eine aussage über timing, die davon abhängt, dass ein browser frames ausführt. ich hatte eine korrektheitseigenschaft von einer animation abhängig gemacht und dann einen tag damit verbracht, in einer animationsbibliothek nach einem bug zu suchen.

## wie ich weiß, dass die behebung funktioniert

in derselben frame-freien umgebung, denn eine behebung, die nur dort funktioniert wo frames laufen, ist keine behebung:

* home → blog: url `/blog`, `#primary-content.children.length === 1`, blog-seite gerendert
* blog → home: url `/`, `children.length === 1`, home-seite gerendert

ein kind, jedes mal. diese zahl ist der ganze test — sie ist das direkte beobachtbare des kaputten dings, und es ist, was ich vorher von hand geprüft habe. `kids: 1`, url und inhalt in übereinstimmung, mit überhaupt keinen laufenden frames.

## was ich meinem vergangenen ich sagen würde

* **benenne die invariant, nicht die bibliothek.** „die url hat sich geändert und die seite nicht" ist ein bugbericht. „`AnimatePresence` ist kaputt" ist eine stimmung. der erste zeigt auf eine zeile code; der zweite zeigt auf deine lieblings-dependency.
* **eine behebung, die drei unverbundene dinge ändert, ist keine diagnose.** ich habe den wrapper, den transition-wrapper und die layout-projektion zusammen entfernt und es behoben genannt. hätte ich eines entfernt und gemessen, wüsste ich welches es war — und ich hätte die anderen beiden behalten, und dieser artikel wäre über einen bug statt über eine angewohnheit.
* **wenn der einzige ort, an dem du es reproduzieren kannst, auch der ort ist, an dem du arbeitest, hast du ein messproblem bevor du einen bug hast.** ich habe den hier in einem preview-panel verbracht, das keine frames rendert. das ist sowohl der grund, warum er sich reproduziert hat, als auch der grund, warum ich ihn in einem normalen browser nie sehen konnte, und es heißt, ich kann nicht ehrlich behaupten, dass echte besucher ihn getroffen haben. was ich *sagen* kann: die fehlerbedingung ist eine, die ein echter browser trifft, sobald ein tab in den hintergrund geht, ein frame mitten im übergang verworfen wird, oder der nutzer schneller navigiert als die animation — und ein bug, dessen einziger auslöser eine bedingung ist, die du in deiner eigenen entwicklungsschleife nicht sehen kannst, ist ein bug, der ausgeliefert wird.