Halaman yang dimuat langsung bisa digulir seperti biasa. Halaman yang sama, kalau didatangi lewat klik tautan, diam total: roda tetikus diputar dan tidak ada yang bergerak. Saya menemukannya beberapa menit setelah domain situs portofolio saya dipindah ke versi Astro-nya pada 8 September 2026, dan gejalanya bisa direproduksi persis.
Waktu itu situsnya berjalan di Astro 7.3.1 dengan ClientRouter, Lenis 1.3.18 untuk gulir halus, dan GSAP 3.14.2. Yang paling menarik dari bug ini adalah asalnya. Setiap navigasi klien merusak dua hal sekaligus, dan dua-duanya akibat langsung dari keputusan yang benar, yaitu tidak membuat ulang instance Lenis.
Cangkang yang sengaja tidak pernah dibongkar
Elemen-elemen di cangkang situs ini dipersist demi pemutar musik yang harus tetap berbunyi waktu pengunjung berpindah halaman. Waktu menakar pindah dari Next ke Astro pada 6 September, saya mencatat bahwa pemutar seperti itu butuh View Transitions dengan transition:persist. Komentar di cangkangnya merangkum hasilnya: elemen cangkang dipersist lintas navigasi, itu yang membuat pemutar musik tidak terputus, dan spike M0 sudah membuktikan iframe YouTube selamat melewatinya.
Bentuknya kira-kira seperti ini. Nama komponen saya ganti, dan bagian lain saya buang:
---
// Disederhanakan. Nama komponen dan nama persist diganti.
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>Di cangkang aslinya ada lima elemen yang dipersist dengan nama masing-masing, termasuk bilah navigasi, toggle bahasa, dan pemutar musik mini. <ClientRouter /> dipasang di <head> untuk setiap halaman yang tidak ditandai bare.
Bagi gulir halus, persist ini membalik arah dari versi Next. Di Next, gulir halus berbentuk komponen pembungkus yang membuat instance Lenis di dalam efek dan membuangnya saat komponen dilepas. Waktu tahap M1 dikerjakan, komponen itu ternyata termasuk empat komponen yang sama sekali tidak butuh React, jadi ia dijadikan skrip. Di Astro, instance-nya justru tidak boleh dibuat ulang, karena cangkangnya dipersist.
Penjaganya sebuah variabel global yang kebetulan sudah ada sejak versi Next untuk alasan lain: dua komponen, salah satunya bilah navigasi, memanggil scrollTo lewat variabel itu alih-alih scrollTo bawaan, karena Lenis kadang menimpanya saat inisialisasi. Di contoh berikut namanya saya ganti jadi window.__scroller.
// Disederhanakan dari skrip gulir halus situs ini, nama diganti.
export function setupScroller() {
// Cangkang dipersist, jadi instance ini tidak boleh dibuat ulang.
if (window.__scroller) return;
const lenis = new Lenis({
lerp: 0.08,
wheelMultiplier: 0.8,
touchMultiplier: 1.5,
autoRaf: false,
});
window.__scroller = lenis;
// Satu lingkaran RAF saja, Lenis menumpang ticker GSAP.
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.lagSmoothing(0);
gsap.ticker.add((time) => lenis.raf(time * 1000));
}Skrip cangkang memanggil fungsi itu, lalu mendaftarkan pendengar astro:after-swap pada document. Pendengar itu sudah ada sebelum bug ini ketahuan, karena commit perbaikannya tidak menyentuh cangkang sama sekali, dan waktu itu isinya mengurus jangkar hash dan posisi gulir. Satu hal yang tidak pernah ia lakukan adalah menyuruh Lenis mengukur ulang dokumen yang baru.
Dua hal yang rusak di setiap navigasi klien
Angka pertamanya datang dari produksi. Di halaman hasil navigasi klien, roda tetikus menghasilkan scroll 0 dan targetScroll 0 pada dokumen setinggi 3050px. targetScroll adalah posisi yang sedang dituju Lenis, dan scroll posisinya saat ini. Dua-duanya nol di dokumen setinggi itu, artinya halaman tidak bisa digulir sama sekali.
Menurut komentar yang sekarang menempel di kodenya, Lenis menyimpan batas gulirnya sendiri, dan batas itu dihitung sekali saat instance dibuat. Sesudah ClientRouter menukar dokumen, tingginya berubah tapi batasnya tidak, sehingga targetScroll mentok di nilai lama. Instance yang sengaja dibiarkan hidup itu masih memegang ukuran dokumen lama.
Kerusakan kedua ada di <html>. ClientRouter menimpa atribut class di <html> dengan milik dokumen yang masuk, sedangkan lenis dan lenis-smooth dipasang Lenis saat runtime, jadi HTML kiriman server tidak pernah membawanya. Yang terukur sesudah navigasi: dari kelas Lenis, yang tersisa di <html> cuma lenis. Bersama lenis-smooth, aturan scroll-behavior yang menggantung padanya ikut hilang. Di stylesheet situs ini aturan itu berbunyi .lenis.lenis-smooth { scroll-behavior: auto !important; }.
Dari dua kerusakan itu, yang oleh komentar di kodenya dikaitkan dengan targetScroll yang mentok adalah batas gulir yang basi. Kelasnya tetap saya pasang kembali, tapi saya tidak mau membesar-besarkan perannya di situs ini: di stylesheet yang sama, elemen html sendiri sudah disetel scroll-behavior: auto tanpa bergantung pada kelas Lenis. Seberapa terasa akibat kelas yang hilang bergantung pada CSS apa saja yang menggantung padanya di situsmu.
Sinkronisasi ulang di astro:after-swap
Instance-nya tetap tidak dibuat ulang. Perbaikannya berupa sinkronisasi ulang di astro:after-swap: kelasnya dipasang kembali, lalu resize() dipanggil tiga kali dengan jeda.
// Disederhanakan, nama diganti. Dipanggil paling awal oleh pendengar astro:after-swap.
function resyncScroller() {
const lenis = window.__scroller;
if (!lenis) return;
// ClientRouter menimpa class di <html>, jadi kelas runtime Lenis dipasang lagi.
document.documentElement.classList.add('lenis', 'lenis-smooth');
// Hitung ulang batas gulir untuk tinggi dokumen yang baru. Diulang karena
// gambar dan pulau yang menyusul masih bisa mengubah tingginya.
lenis.resize();
setTimeout(() => lenis.resize(), 250);
setTimeout(() => {
lenis.resize();
ScrollTrigger.refresh(true);
}, 1000);
}
export function onAfterSwap() {
resyncScroller(); // selalu duluan, sebelum urusan hash dan posisi gulir
// ...penanganan hash dan posisi gulir
}Fungsi itu keluar kalau instance-nya tidak ada, lalu menambahkan lenis dan lenis-smooth ke document.documentElement. Panggilan resize() pertama terjadi langsung, yang kedua lewat setTimeout 250 ms, dan yang ketiga lewat setTimeout 1000 ms yang sekalian menjalankan ScrollTrigger.refresh(true). Alasan dua panggilan susulan itu tertulis di komentarnya: gambar dan pulau Astro yang menyusul masih bisa mengubah tinggi dokumen sesudah pertukaran selesai.
Commit perbaikannya tercatat 8 September pukul 22.21, di hari yang sama dengan pemindahan domain. Ia hanya menyentuh skrip gulir halus, dan seluruhnya berupa 41 baris tambahan tanpa satu baris pun dihapus. Keputusan untuk tidak membuat ulang Lenis dibiarkan persis seperti semula.
Sebelum di-push, perbaikannya diverifikasi di build lokal dengan dua navigasi klien berturut-turut. Batas gulirnya mengikuti tinggi halaman yang baru, 2150 untuk dokumen 3050px lalu 405 untuk dokumen 1305px, dan kelas Lenis di <html> tidak hilang lagi. Dua angka batas itu juga konsisten satu sama lain: 3050 dikurangi 2150 dan 1305 dikurangi 405 sama-sama 900. Selisih tetap seperti itu memang yang diharapkan kalau batas gulir adalah tinggi dokumen dikurangi tinggi jendela yang tidak berubah.
Yang tidak ada di catatan saya adalah verifikasi ulang khusus bug ini di situs hidup sesudah deploy. Bukti untuk perbaikan ini berhenti di build lokal tadi.
Ceklis yang tidak pernah menggulir sesudah klik
Migrasinya saya kerjakan dalam tahap bernomor. Cacat ini lahir di tahap M1 bersama cangkangnya, lalu bertahan sampai M11. Bukan karena gulir halus tidak pernah diperiksa. Ceklis M4 memuat lenis dan navigasi klien, dan semuanya dinyatakan lulus di deployment. Masalahnya, seluruh ceklis sebelumnya menguji halaman yang dimuat langsung, dan tidak satu pun pernah menggulir sesudah mengklik tautan. Butir lenis dan butir navigasi klien sama-sama lulus, tapi tidak ada ceklis yang menggabungkan keduanya, padahal di gabungan itulah bug ini hidup.
Pemeriksa paritas juga tidak ada yang menemukannya, karena semuanya memakai curl. curl mengambil HTML dari server lalu berhenti di situ. Ia tidak menjalankan skrip, tidak mengklik tautan, dan tidak menggulir, sedangkan cacat ini baru muncul sesudah ketiganya terjadi.
Hal ini berlaku untuk situs apa pun yang memakai router klien. Halaman yang dimuat langsung dan halaman yang didatangi lewat tautan berjalan di jalur yang berbeda, dan ceklis yang hanya menguji jalur pertama tidak mengatakan apa-apa soal jalur kedua, sebanyak apa pun butirnya yang lulus.
Sehari kemudian, asumsi yang sama muncul dua kali lagi
Audit sesudah domain dipotong mencatat, di daftar yang belum dikerjakan, bahwa garis kemajuan gulir mati permanen sesudah satu navigasi klien dan tombol Back membuang posisi gulir yang sudah dipulihkan ClientRouter. Keduanya dibereskan keesokan harinya, 9 September, dalam satu commit yang pesannya menyebut keduanya dua cacat terpisah yang sama-sama lahir dari asumsi tentang cangkang yang dipersist.
Cacat tombol Back ada di pendengar astro:after-swap yang sama. Sebelum perbaikan pertama, jalur tanpa hash-nya mengembalikan gulir ke nol lewat Lenis dan lewat window.scrollTo, dengan komentar yang menyebut bentuk itu sama seperti versi Next, dan reset itu masih ada sampai 9 September. Reset ganda itulah yang dulu saya pakai di Next untuk posisi yang tidak kembali ke atas saat pindah rute, dan ceritanya sudah pernah saya tulis terpisah. Di bawah ClientRouter, reset yang sama justru jadi penyebab. Astro sudah menyetel posisi gulir sebelum astro:after-swap dipancarkan, ke puncak untuk navigasi maju dan ke posisi tersimpan untuk Back, jadi memaksa nol berarti membuang posisi yang baru saja dipulihkan.
Perbaikannya berhenti memutuskan posisi. Posisi yang sudah disetel Astro dibaca dari window.scrollY, lalu diserahkan ke Lenis. Membiarkan posisi itu di window saja tidak cukup, karena Lenis memegang offset gulirnya sendiri, mengabaikan window.scrollY yang disetel dari luar, dan akan menarik halaman kembali ke angka yang ia simpan.
// Jalur tanpa hash di pendengar astro:after-swap, disederhanakan.
// Sebelum: memaksa nol, sama seperti versi Next.
if (lenis) lenis.scrollTo(0, { immediate: true });
window.scrollTo(0, 0);
// Sesudah 9 September: posisi yang sudah disetel Astro diserahkan ke Lenis.
const y = window.scrollY;
if (lenis) lenis.scrollTo(y, { immediate: true, force: true });Cacat garis kemajuan datang dari sisi sebaliknya. Elemen garisnya tidak dipersist, jadi pendengarnya menulis ke elemen yang sudah dicabut dari dokumen, dan skrip modul cuma dievaluasi sekali per muat penuh, jadi pendengarnya tidak pernah dipasang ulang. Lenis rusak karena ia sengaja dibiarkan hidup sementara dokumennya berganti. Garis kemajuan rusak karena elemennya berganti sementara pendengarnya dibiarkan hidup.
Sesudah perbaikan itu, tiga navigasi klien berturut-turut menghasilkan garis 23,9 persen pada gulir 900 dari dokumen setinggi 4665, dan Back mengembalikan gulir ke 900 dengan lenis.scroll ikut 900. Angka itu diukur di peramban sungguhan. Ke situs hidup, lima commit hari itu diverifikasi lewat curl, bukan peramban, supaya tidak ada pageview yang masuk ke Redis produksi. Jadi curl memastikan apa yang bisa ia lihat, dan perilaku gulirnya bersandar pada pengukuran di peramban tadi.
Objeknya hidup terus, dokumennya berganti
Dengan ClientRouter, halaman tidak pernah dimuat ulang di antara navigasi. Elemen yang dipersist dipindahkan utuh ke dokumen baru, dan apa pun yang hidup di tingkat window ikut hidup terus, sementara sisa dokumen di bawahnya ditukar setiap kali pengunjung mengklik tautan. Susunan itu yang membuat pemutar musik tidak terputus, dan susunan yang sama membuat Lenis memegang ukuran halaman yang sudah lewat.
Jadi setiap pustaka yang mengukur dokumen sekali lalu menyimpan hasilnya perlu diberi tahu bahwa dokumennya sudah berganti, dan astro:after-swap tempat yang wajar untuk itu. Kelas yang dipasang pustaka ke <html> saat runtime perlu dipasang lagi, karena ClientRouter menimpa atribut class di sana dengan milik dokumen yang masuk. Kode yang dibawa dari versi Next juga perlu ditanya ulang satu per satu, karena di Astro posisi gulir sudah diputuskan sebelum pendengarmu sempat berjalan.
Keputusan untuk tidak membuat ulang Lenis tetap benar. Yang kurang hanya satu langkah sesudahnya: membuat Lenis setuju lagi dengan dokumen yang baru.