A page loaded directly scrolls as usual. The same page, reached by clicking a link, sits completely still: the mouse wheel turns and nothing moves. I found it a few minutes after the domain of my portfolio site was moved over to its Astro version on 8 September 2026, and the symptom reproduced exactly.
At the time the site ran Astro 7.3.1 with ClientRouter, Lenis 1.3.18 for smooth scrolling, and GSAP 3.14.2. The most interesting thing about this bug is where it came from. Every client navigation broke two things at once, and both were a direct consequence of a correct decision, which was not to recreate the Lenis instance.
A shell that is deliberately never torn down
The elements in this site's shell are persisted for the sake of a music player that has to keep playing while visitors move between pages. When I was sizing up the move from Next to Astro on 6 September, I noted that a player like that needs View Transitions with transition:persist. The comment in the shell sums up the result: shell elements are persisted across navigations, that is what keeps the music player from cutting out, and the M0 spike had already shown that a YouTube iframe survives it.
The shape is roughly this. I changed the component names and stripped out everything else:
---
// Simplified. Component names and persist names changed.
import { ClientRouter } from 'astro:transitions';
---
<html>
<head>
<ClientRouter />
</head>
<body>
<SiteNav client:load transition:persist="nav" />
<slot />
<MusicPlayer client:only="react" transition:persist="player" />
<script>
import { setupScroller, onAfterSwap } from '../scripts/smooth-scroll';
setupScroller();
document.addEventListener('astro:after-swap', onAfterSwap);
</script>
</body>
</html>The real shell persists five elements under their own names, including the navigation bar, the language toggle and the mini music player. <ClientRouter /> goes into the <head> of every page that is not flagged bare.
For smooth scrolling, this persistence flips the direction of the Next version. In Next, smooth scrolling was a wrapper component that created the Lenis instance inside an effect and destroyed it on unmount. When stage M1 was being built, it turned out to be one of four components that did not need React at all, so it became a script. In Astro, the instance must not be recreated, precisely because the shell is persisted.
The guard is a global variable that happened to exist since the Next version for a different reason: two components, one of them the navigation bar, call scrollTo through that variable instead of the native scrollTo, because Lenis sometimes overrides it during initialisation. In the example below I renamed it to window.__scroller.
// Simplified from this site's smooth scroll script, names changed.
export function setupScroller() {
// The shell is persisted, so this instance must not be recreated.
if (window.__scroller) return;
const lenis = new Lenis({
lerp: 0.08,
wheelMultiplier: 0.8,
touchMultiplier: 1.5,
autoRaf: false,
});
window.__scroller = lenis;
// A single RAF loop: Lenis rides on the GSAP ticker.
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.lagSmoothing(0);
gsap.ticker.add((time) => lenis.raf(time * 1000));
}The shell script calls that function and then registers an astro:after-swap listener on document. That listener existed before this bug was found, since the fix commit never touched the shell, and back then it handled hash anchors and the scroll position. The one thing it never did was ask Lenis to remeasure the new document.
Two things break on every client navigation
The first number came from production. On a page reached through client navigation, the mouse wheel produced scroll 0 and targetScroll 0 on a document 3050px tall. targetScroll is the position Lenis is heading towards, and scroll is where it currently is. Both at zero on a document that tall means the page cannot be scrolled at all.
According to the comment that now sits in the code, Lenis stores its own scroll limit, and that limit is computed once when the instance is created. After ClientRouter swaps the document, the height changes but the limit does not, so targetScroll gets stuck at the old value. The instance that was deliberately kept alive is still holding the measurements of the old document.
The second break is on <html>. ClientRouter overwrites the class attribute on <html> with the incoming document's, while lenis and lenis-smooth are added by Lenis at runtime, so the HTML sent by the server never carries them. What was measured after a navigation: of the Lenis classes, only lenis was left on <html>. Along with lenis-smooth, the scroll-behavior rule that hangs off it went away too. In this site's stylesheet that rule reads .lenis.lenis-smooth { scroll-behavior: auto !important; }.
Of the two breaks, the one the code comment ties to the stuck targetScroll is the stale limit. I still put the classes back, but I do not want to overstate their role on this site: in the same stylesheet, the html element itself is already set to scroll-behavior: auto without relying on any Lenis class. How much a missing class hurts depends on what CSS hangs off it on your own site.
Resyncing on astro:after-swap
The instance is still never recreated. The fix is a resync on astro:after-swap: the classes go back on, then resize() is called three times with delays.
// Simplified, names changed. Called first by the astro:after-swap listener.
function resyncScroller() {
const lenis = window.__scroller;
if (!lenis) return;
// ClientRouter overwrites the class on <html>, so the Lenis runtime classes go back on.
document.documentElement.classList.add('lenis', 'lenis-smooth');
// Recompute the scroll limit for the new document height. Repeated because
// images and islands that arrive late can still change that height.
lenis.resize();
setTimeout(() => lenis.resize(), 250);
setTimeout(() => {
lenis.resize();
ScrollTrigger.refresh(true);
}, 1000);
}
export function onAfterSwap() {
resyncScroller(); // always first, before the hash and scroll position work
// ...hash and scroll position handling
}The function returns early if there is no instance, then adds lenis and lenis-smooth to document.documentElement. The first resize() runs immediately, the second through a 250 ms setTimeout, and the third through a 1000 ms setTimeout that also runs ScrollTrigger.refresh(true). The reason for the two follow-up calls is written in the comment: images and Astro islands that arrive late can still change the document height after the swap has finished.
The fix commit is timestamped 8 September at 22:21, the same day as the domain move. It touches only the smooth scroll script and consists entirely of 41 added lines, with not a single line removed. The decision not to recreate Lenis was left exactly as it was.
Before it was pushed, the fix was verified on a local build with two client navigations in a row. The scroll limit followed the height of the new page, 2150 for a 3050px document and then 405 for a 1305px one, and the Lenis classes on <html> no longer went missing. The two limits are also consistent with each other: 3050 minus 2150 and 1305 minus 405 are both 900. A constant gap like that is exactly what you would expect if the scroll limit is the document height minus a viewport height that did not change.
What my notes do not contain is a re-check of this particular bug on the live site after deploy. The evidence for this fix stops at that local build.
Checklists that never scrolled after a click
I did the migration in numbered stages. This defect was born in stage M1 along with the shell and survived until M11. Not because smooth scrolling was never checked. The M4 checklist included Lenis and client navigation, and all of it was marked as passing on the deployment. The problem is that every checklist before that point tested pages loaded directly, and not one of them ever scrolled after clicking a link. The Lenis item and the client navigation item both passed, but no checklist combined the two, and the combination is exactly where this bug lived.
None of the parity checkers found it either, because all of them used curl. curl fetches the HTML from the server and stops there. It does not run scripts, click links or scroll, and this defect only appears after all three have happened.
That holds for any site with a client-side router. A page loaded directly and a page reached through a link run down different paths, and a checklist that only tests the first path says nothing about the second, however many of its items pass.
A day later, the same assumption showed up twice more
The audit after the domain cut recorded, on its list of things not yet done, that the scroll progress bar died for good after a single client navigation and that the Back button threw away the scroll position ClientRouter had already restored. Both were fixed the next day, 9 September, in one commit whose message calls them two separate defects that both grew out of assumptions about the persisted shell.
The Back defect lived in the same astro:after-swap listener. Before the first fix, its no-hash path reset the scroll to zero through Lenis and through window.scrollTo, with a comment saying this matched the Next version, and that reset was still there until 9 September. That double reset is what I used in Next for a position that did not go back to the top on route changes, a story I have written up separately. Under ClientRouter, the same reset became the cause. Astro already sets the scroll position before astro:after-swap fires, to the top for a forward navigation and to the saved position for Back, so forcing zero meant throwing away a position that had just been restored.
The fix stops deciding the position. The position Astro already set is read from window.scrollY and handed to Lenis. Leaving it on window alone is not enough, because Lenis holds its own scroll offset, ignores a window.scrollY set from outside, and pulls the page back to the number it stored.
// The no-hash path in the astro:after-swap listener, simplified.
// Before: force zero, same as the Next version.
if (lenis) lenis.scrollTo(0, { immediate: true });
window.scrollTo(0, 0);
// After 9 September: hand the position Astro already set over to Lenis.
const y = window.scrollY;
if (lenis) lenis.scrollTo(y, { immediate: true, force: true });The progress bar defect came from the opposite side. The bar element is not persisted, so its listener wrote to an element that had already been removed from the document, and a module script is evaluated only once per full page load, so the listener was never attached again. Lenis broke because it was deliberately kept alive while its document changed. The progress bar broke because its element changed while its listener was kept alive.
After that fix, three client navigations in a row gave a bar reading of 23.9 percent at a scroll of 900 in a document 4665 tall, and Back returned the scroll to 900 with lenis.scroll following to 900. Those numbers were measured in a real browser. On the live site, the five commits of that day were verified with curl rather than a browser, so that no pageviews would land in the production Redis. So curl confirmed what it is able to see, and the scrolling behaviour rests on that browser measurement.
The object lives on, the document changes
With ClientRouter, the page is never reloaded between navigations. Persisted elements are moved intact into the new document, and anything living at the window level stays alive, while the rest of the document underneath is swapped every time a visitor clicks a link. That arrangement is what keeps the music player from cutting out, and the same arrangement left Lenis holding the dimensions of a page that was already gone.
So any library that measures the document once and keeps the result needs to be told that the document has changed, and astro:after-swap is a natural place to do it. Classes a library adds to <html> at runtime need to be added again, because ClientRouter overwrites the class attribute there with the incoming document's. And code brought over from a Next version needs to be questioned piece by piece, because in Astro the scroll position has already been decided before your listener gets to run.
The decision not to recreate Lenis is still the right one. What was missing was a single step after it: getting Lenis to agree with the new document again.