the bug that taught me what is not a bug
devlog · Oct 4, 2026 · 7 min read
clicking blog in the sidebar changed the url to /blog and left the home page on the screen. click info, same thing. the router was working, the history was working, the click handler was working. only the picture was wrong.
i fixed it by deleting three things at once, and i have thought about that decision more than the bug itself.
the mechanism, before the fix
this is what the page body used to be:
<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>
read the key. it changes on every navigation, so react unmounts the old motion.div and mounts a new one, and AnimatePresence exists to keep the old one alive long enough to play its exit.
and mode="wait" is the instruction not to mount the new one yet. that is the entire semantics of the mode: the incoming child stays unmounted until the outgoing child has finished leaving. the docs say it plainly, and it is a good mode. it gives you a clean cut instead of two pages fighting for the same space.
now put the failure condition next to it. the new page — the thing the user clicked, the thing the url already says — does not appear until an animation finishes. every frame that does not run is a page that does not render.
the three things i deleted, and why i cannot tell you which one did it
git diff src/App.tsx is the whole investigation. three hunks:
1. the presence wrapper. <AnimatePresence mode="wait" initial={false}> around the page body is gone. the keyed motion.div stays, with the same key, the same initial, the same animate, the same transition — only exit is gone, because there is nothing left to exit from. react still remounts on every page change, so the enter animation still plays. the page you navigated to is now rendered by react's own reconciliation, not by an animation completing.
2. React.startTransition in the navigation handler. it was wrapping the two state setters:
React.startTransition(() => {
setPage(newPage);
setBlogPostId(postId);
});
and it is now:
setPage(newPage);
setBlogPostId(postId);
this one is a real suspect and not a stylistic preference. a transition update is, by design, interruptible and lower priority — react is allowed to yield it and pick it back up. the update that changes the key is the update that drives the presence swap. so wrapping it in a transition makes the swap itself interruptible, and an interrupted swap is exactly a case where the exiting child was never told to exit and the incoming child was never mounted.
3. layout on the <motion.main> wrapper. removed. a layout projection on the parent measures and animates the child's box, and it runs as a separate pass from the presence swap. i wrote the reason in the code at the time, and i still believe it: a layout projection on the wrapper fights the AnimatePresence child that is actually swapping pages, and the loser can keep the previous page mounted.
and there it is. i removed all three, in one commit, without bisecting them. i could not narrow it down, so i removed the entire configuration in which the invariant could break. that is not a diagnosis. it is a shotgun, and the honest label for it is "i did not find the bug, i removed the ability to have it".
the measurement that told me the failure mode was real
the environment i test in — the preview panel i keep open while i work — runs no animation frames at all. not slow frames. zero.
here is how i established that. the obvious version hangs:
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);
});
this should resolve after one second. it never resolves, because the only thing that can end it is a frame, and no frame ever comes. the evaluation times out and tells me the page stayed responsive — which it is. it is responsive and completely motionless.
so i asked again without hanging:
requestAnimationFrame(function tick() { frames++; requestAnimationFrame(tick); });
// ... 2.5 seconds later
{ "rafFramesAfterWait": 0 }
zero frames in two and a half seconds. which means that in this environment exit={{ opacity: 0, y: -15 }} can never complete, mode="wait" can never release the incoming child, and the correct page is unconditionally unreachable. the bug was not intermittent there. it was certain.
what is not a bug
this is the part i want to be careful about, because the easy conclusion is wrong.
it is not a bug in AnimatePresence. that component is still in this codebase eleven times across six files, and every one of them works:
| where | what mode="wait" gates |
why it is safe |
|---|---|---|
| HomePage ×2 | the rotating headline words | decoration; if it stalls you see the previous word |
| BlogPage | the featured post block | secondary content on a page that already rendered |
| Code.tsx | the language label on a code block | a badge |
| CopyLinkCapsule ×2 | the "copied!" label | a 400ms confirmation |
| SettingsDialog ×4 | the panel behind a tab | the tab itself already tells you where you are |
| TechStack | one tech item in a list | the rest of the list is already there |
not one of them gates a route. and that is the actual rule i should have written down before the bug instead of after it: mode="wait" is safe exactly when the gated thing is optional, and it is a correctness hazard when the gated thing is the destination. if the thing that fails to appear is what the user asked for, you have turned an easing into a precondition.
it is not a bug in the animation either. deleting the animation would have "fixed" it — set disableAnimations and the exit finishes instantly — and that would have been the wrong fix twice over: the animation was never the defect, and the site would have lost its transition to paper over a state-management problem. the transition is not what i was selling. it is decoration. it should not have been able to veto a route.
so what was the bug? an invariant, written in the wrong language. the property i needed is "the page you navigated to is the page you get". i had expressed it as "the page you navigated to appears after the previous one finishes animating". the first is a statement about correctness that react's reconciler guarantees. the second is a statement about timing that depends on a browser running frames. i had made a correctness property conditional on an animation, and then spent a day looking for a bug in the animation library.
how i know the fix works
in that same zero-frame environment, because a fix that only works where frames run is not a fix:
- home → blog: url
/blog,#primary-content.children.length === 1, blog page rendered - blog → home: url
/,children.length === 1, home page rendered
one child, every time. that number is the whole test — it is the direct observable of the thing that was broken, and it is what i was checking by hand before. kids: 1, url and content in agreement, with no frames running at all.
what i would tell my past self
- name the invariant, not the library. "the url changed and the page did not" is a bug report. "
AnimatePresenceis broken" is a mood. the first one points at a line of code; the second one points at your favourite dependency. - a fix that changes three unrelated things is not a diagnosis. i removed the wrapper, the transition wrapper and the layout projection together and called it fixed. if i had removed one and measured, i would know which one it was — and i would have kept the other two, and this post would be about a bug instead of about a habit.
- if the only place you can reproduce it is the place you also do your work, you have a measurement problem before you have a bug. i spent this one in a preview panel that renders no frames. that is both why it reproduced and why i could never see it in a normal browser, and it means i cannot honestly claim real visitors hit it. what i can say is that the failing condition is one a real browser hits whenever a tab is backgrounded, a frame is dropped mid-transition, or the user navigates faster than the animation — and a bug whose only trigger is a condition you cannot see in your own dev loop is a bug that ships.