Di situs portofolio saya sendiri, pemutar musik latarnya sengaja tidak diletakkan di halaman mana pun. Dia duduk di shell layout, di atas router, dan state-nya dipegang satu provider React, supaya lagunya tetap jalan waktu pengunjung berpindah halaman. Untuk hampir semua tautan, susunan itu bekerja persis seperti maunya.
Yang lolos justru kelompok tautan yang paling tidak saya curigai. Menu hamburger punya beberapa tautan #-anchor, dan mengekliknya dari halaman mana pun selain beranda menghentikan pemutar musiknya, setiap kali. Bukan sesekali, bukan tergantung lagunya.
Bedanya bukan tujuannya, tapi cara pindahnya
Tautan-tautan itu tidak istimewa dari sisi tujuan. Bedanya ada di handler-nya: navigasi #-anchor di menu dikerjakan dengan menyetel window.location.href.
// Sebelumnya: semua anchor di menu diperlakukan sama
function handleAnchor(href: string) {
window.location.href = href; // "/#work"
}Menyetel window.location.href ke alamat yang path-nya berbeda bukan berpindah di dalam aplikasi. Itu menyuruh browser membuang dokumen yang sedang hidup dan memuat dokumen baru dari nol, alias muat ulang halaman penuh. Dan muat ulang penuh berarti pohon React dibangun ulang dari awal, jadi shell layout ikut remount, provider audio di dalamnya ikut remount, dan state pemutarnya mati bersama remount itu. Elemen audio yang tadi berbunyi adalah milik dokumen yang sudah dibuang, jadi tidak ada yang tersisa untuk dilanjutkan.
Ini juga yang menjelaskan kenapa gejalanya cuma muncul dari halaman selain beranda. Kalau anchor yang sama diklik dari beranda, yang berubah hanya bagian hash dari alamat yang sedang dibuka, dan perubahan hash saja tidak memuat ulang dokumen. Begitu path-nya ikut berubah, browser memperlakukannya sebagai dokumen baru, dan pemutarnya kena.
Perbaikannya memisahkan dua kasus anchor
Petunjuk perbaikannya memecah tautan anchor jadi dua kasus yang ditangani berbeda: anchor yang menyeberang halaman, dan anchor yang menunjuk ke halaman yang sedang dibuka.
Untuk anchor lintas halaman, window.location.href diganti router.push(). Ini navigasi lunak, artinya perpindahannya dikerjakan router di dalam aplikasi yang sudah berjalan, bukan dengan memuat dokumen baru, sehingga shell dan provider di atas router tidak pernah di-remount dan pemutarnya selamat.
Untuk anchor sehalaman, lokasinya tidak disentuh sama sekali. Yang dipanggil scrollTo(selector) milik instance Lenis, yang saya simpan di sebuah handle global.
function handleAnchor(e: React.MouseEvent, href: string) {
e.preventDefault();
const [path, hash] = href.split("#");
const selector = `#${hash}`;
// Anchor sehalaman: jangan ubah lokasi, cukup scroll.
if (isSamePage(path)) {
window.__scroller?.scrollTo(selector);
return;
}
// Anchor lintas halaman: navigasi lunak, shell tetap hidup.
router.push(href);
}Tidak ada yang salah dengan kode audionya. Yang salah adalah satu jalur navigasi yang diam-diam keluar dari aplikasi.
Bug kedua yang berangkat di commit yang sama
Perbaikan nav ini masuk dalam satu commit bersama perbaikan scroll, dan bukan kebetulan keduanya bertetangga. Lenis memegang offset scroll-nya sendiri, dan akibatnya perpindahan rute tidak mengembalikan posisi halaman ke atas.
Perbaikannya ada di komponen transisi halaman, pada onExitComplete, dan memanggil dua reset sekaligus: scrollTo(0, { immediate: true }) untuk offset yang dipegang Lenis, dan window.scrollTo(0, 0) untuk posisi scroll browser. Lalu ada satu perkecualian yang harus dijaga: kalau alamatnya membawa hash, misalnya anchor lintas halaman seperti /#work, kodenya justru mencoba lagi scroll ke elemen itu begitu elemennya mount.
onExitComplete={() => {
window.__scroller?.scrollTo(0, { immediate: true });
window.scrollTo(0, 0);
const hash = window.location.hash;
if (hash) {
// Ada hash: bukan reset ke atas, tapi susul elemennya setelah mount.
retryScrollTo(hash);
}
}}Bagian retry-nya perlu karena kedua hal ini terjadi pada waktu yang berbeda. Transisi keluar selesai lebih dulu, sementara elemen tujuan baru ada setelah halaman berikutnya dirender.
Pelajaran
Kalau ada sesuatu yang sengaja kamu taruh di atas router supaya hidup lintas halaman, entah pemutar audio, koneksi socket, atau state yang mahal dibangun ulang, maka ia hanya selamat lewat navigasi lunak. window.location.href bukan versi ringkas dari router.push(). Ia meninggalkan aplikasi dan memulai satu dokumen baru, dan semua yang kamu titipkan di shell ikut dibuang bersama dokumen lamanya.
Yang bikin kelas bug ini awet adalah gejalanya menunjuk ke tempat yang salah. Musik yang berhenti terasa seperti masalah audio, padahal audionya tidak pernah tahu apa-apa. Kalau ada state yang mati hanya lewat sebagian tautan, jangan mulai dari state-nya, mulai dari bagaimana tautan-tautan itu berpindah.