projects

Scroll dies after the first client-side navigation

status
resolved
severity
high (site unusable after any in-app navigation)
detected
2026-08-10
resolved
2026-08-10
duration
74 days live in production, undetected

Every page on the site stopped responding to wheel and trackpad input as soon as a visitor clicked a single link. Nobody reported it for two and a half months.

Symptom

Load the home page, click a project card, try to scroll the case study: nothing moves. The scrollbar still works. So do the space bar, the arrow keys, and Page Down. Only the wheel and the trackpad are dead, and they stay dead on every page visited afterwards until a full reload.

That split is the whole diagnosis. Keyboard scrolling and scrollbar dragging go through the browser's native path. Wheel and touch go through Lenis, which binds a non-passive listener and calls preventDefault() on the gesture before running its own animation. A dead wheel with a live keyboard means something intercepted the gesture and then declined to act on it.

Root cause

Lenis caches the scrollable height rather than reading it per event, and keeps that cache fresh with a ResizeObserver on document.documentElement.

app/layout.tsx set h-full on <html>height: 100% — which pins that element's box to the viewport permanently. Its height never changes when page content grows, so after the initial measurement the observer never fires again. On macOS, with overlay scrollbars, the width never changes either, so there is no accidental self-heal.

The cached scrollHeight therefore froze at whatever the first page measured. Lenis computes limit.y = scrollHeight - height, and the home page is deliberately short — one viewport plus a little. onVirtualScroll still called preventDefault() on every wheel event, then clamped its scrollTo to that stale limit. The gesture was consumed and discarded.

Measured against production before the fix, at a 757px viewport:

<html> computed heightwheel travel
/757px
/projects/serlei (5476px of content)757px186px

186px is not arbitrary. The home page's own overflow is 945 − 757 = 188px. That number was being carried, intact, onto a page nearly six times taller.

Lenis ships a stylesheet whose entire content exists to prevent this:

html.lenis,
html.lenis body {
  height: auto;
}

lenis/dist/lenis.css was never imported. Nothing in the repo referenced it, and nothing failed loudly when it was absent.

Two commits set this up, two days apart and neither wrong on its own. f52efb9 (2026-05-26) scaffolded the project with h-full on <html> — a conventional full-height shell. 7313c86 (2026-05-28) added the Lenis provider, which silently requires that exact declaration to be absent. The defect is the interaction, and it shipped with the second commit.

Why it wasn't caught

Every automated check in this repository loads a route directly. The Playwright captures navigate with goto. The Lighthouse runs target a single URL. A direct load re-runs the Lenis constructor against the page you are actually on, so the cached height is correct and scrolling works perfectly. The one interaction that triggers the bug — moving from one page to another without a document load — was never exercised by anything.

The site also has almost no internal links. From the home page a visitor can reach four case studies and nothing else; /status, /availability, /repl, /changelog and /resume had no inbound link from anywhere. Manual passes reached those pages by typing the URL, which is a document load, which hides the bug.

This is the second time this file has been the subject of a scroll fix. f57b67b (2026-06-05) corrected a genuinely different failure in the same provider — a stopped-but-still-mounted Lenis instance locking the page under prefers-reduced-motion — and the post-mortem at /incidents/hydration-mismatch already records that commit as having been aimed at the wrong bug once before. It was aimed at the wrong bug twice. The reduced-motion branch it introduced is also why the bug never appeared under reduced motion: that path skips Lenis entirely and hands scrolling back to the browser.

Fix

Four changes, three of them independent, so no single one is load-bearing:

  • Import lenis/dist/lenis.css, which unpins <html> whenever Lenis is active.
  • Drop h-full from <html> and min-h-full from <body> so nothing competes for that declaration in the first place.
  • Recompute explicitly on route change. usePathname() drives a lenis.resize() two animation frames after the App Router commits — the first frame lands before the browser has laid out the new subtree, the second guarantees it has. This covers the case the stylesheet cannot: two pages of identical height, where the observer correctly never fires and the limit is nonetheless wrong.
  • min-h-dvh instead of min-h-screen. Lenis measures window.innerHeight, the small viewport; 100vh is the large one. On mobile the difference is a permanent scroll the page has no content to fill.

Verified on a production build: seven consecutive client-side navigations, wheel responding on every one, with <html> tracking real content height each time (5476, 945, 5054, 945, 5549, 945, 5885 px). Reduced motion still mounts no Lenis instance and scrolls natively. The command palette's overflow lock releases cleanly across eight open/close cycles.

Pattern

The hydration post-mortem closed on "nothing was watching." This one is narrower and more actionable: everything was watching the wrong thing. Tests, audits and manual passes all existed and all passed, because every one of them tested a page in isolation. The bug lived in the transition between two pages, which no check ever performed.

The 106 assertions in lib/ had also sat unexecuted for two months — there was no test script in package.json and no CI step to run one. That is fixed now, and the deploy is gated on lint, typecheck, tests and a build. None of that would have caught this one. A check that navigates would have.