Bug paling menyebalkan biasanya bukan yang lahir dari kode ceroboh, tapi yang lahir dari kode pengaman. Saya menabrak satu contoh sempurna di sebuah landing page pre-launch: fitur yang saya pasang khusus supaya animasi tidak pernah gagal justru jadi sumber bug yang dilaporkan klien.
Laporannya masuk dengan kalimat yang sangat deskriptif dan sangat tidak teknis: "teks muncul, terus animasi, terus teks lagi".
Gejalanya: cuma kelihatan kalau scroll-nya santai
Yang bikin saya lama menemukannya, bug ini tidak muncul di cara saya menguji. Saya biasa scroll cepat, lompat ke section, refresh, ulang. Semua mulus.
Klien tidak begitu. Dia membaca. Dia buka halamannya, diam sebentar di hero, baca headline, lalu turun pelan-pelan sambil menyerap isinya. Dan di pola itu bug-nya konsisten:
- Dia scroll pelan ke bawah.
- Satu blok teks sudah terlihat penuh sebelum benar-benar masuk viewport.
- Begitu blok itu masuk viewport, teksnya menghilang.
- Lalu animasi masuknya jalan dari nol, dan teksnya muncul lagi.
Jadi konten yang sudah dia baca setengah tiba-tiba dicabut dari depan matanya lalu dipasang ulang dengan gaya. Dari sisi teknis tidak ada yang error. Console bersih. Dari sisi orang yang sedang membaca, itu terasa seperti halamannya rusak.
Kenapa ada safety net-nya sejak awal
Konteksnya penting, karena net ini bukan kode iseng. Aturan animasi di proyek itu memang saya buat paranoid, hasil dari luka lama di proyek lain: elemen reveal tidak boleh pernah tersembunyi total, observer dipasang dengan threshold rendah (0.05), setiap elemen ditandai eksplisit satu per satu, dan ada satu jaring pengaman terakhir yang memaksa semua elemen tampil sekitar empat detik setelah load.
Satu hal yang harus saya akui di sini. Di proyek lama itu, net-nya sempat mengecek dulu apakah elemennya benar-benar ada di layar sebelum dipaksa tampil. Yang ikut pindah ke proyek ini cuma versi ringkas aturannya, "net force-reveal sekitar empat detik", dan cek itu tertinggal di jalan. Aturan yang dipadatkan jadi satu baris memang gampang kehilangan bagian yang justru bikin dia aman.
Alasannya masuk akal. IntersectionObserver bisa gagal memicu karena banyak hal: scroll super cepat yang melewati elemen tanpa frame intersection yang tertangkap, layout yang bergeser setelah font atau gambar masuk, quirk browser. Kalau itu terjadi dan elemennya tetap dalam pre-state, konten hilang permanen tanpa jejak.
Jadi saya pasang ini:
window.addEventListener("load", () => {
setTimeout(() => {
document.querySelectorAll(".reveal:not(.is-visible)").forEach((el) => {
el.classList.add("is-visible");
});
}, 4000);
});Empat detik lewat, semua yang belum tampil dipaksa tampil. Aman, pikir saya.
Akar masalahnya: net-nya menyelamatkan yang tidak minta diselamatkan
Perhatikan selektornya. document.querySelectorAll(".reveal:not(.is-visible)") mengambil semua elemen reveal di halaman. Semuanya. Termasuk yang ada di footer, tiga layar di bawah tempat pengunjung sedang berdiri.
Empat detik setelah load, net itu membuka semuanya. Elemen di bawah fold, yang observer-nya memang belum seharusnya jalan karena elemennya belum kelihatan, ikut dibuka. Tidak ada yang salah secara visual saat itu, karena tidak ada yang melihat area itu.
Masalahnya baru meledak nanti. Saat pengunjung akhirnya scroll ke sana, observer bekerja persis seperti seharusnya: elemennya masuk viewport, callback jalan, animasi masuk dieksekusi. Dan animasi masuknya digerakkan anime.js, yang menetapkan pre-state-nya sendiri di detik animasi diputar, bukan di detik halaman dimuat.
Urutannya jadi begini:
- Net membuka elemen. Teks terlihat.
- Pengunjung scroll pelan, elemennya masuk viewport.
- anime.js memasang pre-state, teks jatuh ke opacity rendah dan bergeser. Teks hilang.
- Tween jalan. Teks muncul lagi.
Persis kalimat klien: teks, animasi, teks. Net dan observer sebenarnya sama-sama bekerja sempurna. Yang tidak pernah terjadi adalah keduanya saling memberi tahu.
Dan kenapa cuma kelihatan saat scroll santai? Karena kalau scroll-nya cepat, pengunjung sudah lewat jauh sebelum timer empat detik itu relevan, atau elemennya masuk viewport sebelum net sempat menyentuhnya. Bug ini butuh orang yang membaca untuk muncul, dan itu justru pengunjung yang paling penting.
Perbaikannya: net yang sadar viewport, dan pakai strikes
Saya kembali ke pertanyaan dasar: net ini sebenarnya melindungi dari apa?
Jawabannya cuma satu kasus, dan kasusnya sangat sempit: elemen yang sedang ada di layar sekarang tapi observer-nya tidak kunjung memicu. Itu saja. Elemen di bawah fold yang belum tampil bukan kegagalan, itu perilaku normal. Menyelamatkannya bukan cuma tidak perlu, tapi justru merusak.
Jadi net-nya saya ubah dari sekali sapu jadi patroli berkala yang cuma melirik apa yang benar-benar ada di layar. Dan supaya tidak salah tuduh pada elemen yang observer-nya baru saja mau jalan, saya pakai sistem strikes: sebuah elemen harus tertangkap dalam kondisi "di layar tapi masih belum tampil" sebanyak dua kali berturut-turut, dengan jeda 1,5 detik, sebelum dipaksa tampil.
const STRIKE_INTERVAL = 1500;
const STRIKES_NEEDED = 2;
const strikes = new WeakMap();
function isOnScreen(el) {
const rect = el.getBoundingClientRect();
return rect.top < window.innerHeight && rect.bottom > 0;
}
function patrol() {
const pending = document.querySelectorAll(".reveal:not(.is-visible)");
pending.forEach((el) => {
if (!isOnScreen(el)) {
strikes.delete(el); // di bawah fold, bukan urusan net
return;
}
const count = (strikes.get(el) || 0) + 1;
strikes.set(el, count);
if (count >= STRIKES_NEEDED) settle(el);
});
}
window.addEventListener("load", () => {
setInterval(patrol, STRIKE_INTERVAL);
});Bagian kedua perbaikannya sama pentingnya. Net tidak boleh cuma mengubah tampilan, dia harus menutup kasusnya supaya observer tidak mengulang apa pun nanti. Karena itu keduanya lewat satu pintu yang sama:
function settle(el) {
if (el.classList.contains("is-visible")) return; // sekali saja per elemen
el.classList.add("is-visible");
io.unobserve(el); // observer tidak akan pernah memicu ulang
playEntrance(el); // pemanggilan anime.js, dari pre-state
}
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) settle(entry.target);
});
},
{ threshold: 0.05 }
);playEntrance di situ adalah pemanggilan anime.js yang dulu duduk langsung di dalam callback observer. Sekarang dia cuma bisa dicapai lewat settle, dan settle berhenti di baris pertama kalau elemennya sudah pernah ditampilkan. Jadi animasi masuk tetap jalan, tapi maksimal sekali seumur hidup elemen itu.
Sekarang siapa pun yang menang duluan, net atau observer, elemennya berhenti jadi kandidat animasi. Tidak ada lagi jalur kedua yang bisa memasang pre-state di atas konten yang sudah tampil.
Setelah itu, scroll pelan di landing yang sudah live jadi tenang. Teks muncul sekali, dari pre-state, seperti niat awalnya.
Yang saya bawa pulang
- Kode pengaman perlu ruang lingkup, bukan cuma niat baik. "Selamatkan semuanya" terdengar lebih aman daripada "selamatkan yang di layar", padahal yang pertama justru menciptakan bug baru.
- Fallback dan trigger utama harus tahu satu sama lain. Kalau dua jalur bisa menampilkan elemen yang sama, keduanya wajib lewat satu fungsi yang juga mematikan jalur satunya.
- Timer buta lebih rapuh daripada patroli yang mengecek kondisi. Strikes dua kali dengan jeda 1,5 detik jauh lebih sabar daripada satu sapuan di detik keempat, dan hasilnya lebih jarang salah tuduh.
- Cara kamu menguji menentukan bug apa yang boleh lolos. Saya scroll seperti developer yang buru-buru. Klien scroll seperti orang yang membaca. Bug ini cuma ada di dunia yang kedua.