Code Block Tampil Dua Kali: Kenapan Regex JavaScript Bisa Race
Bug code block dobel di blog: regex /g di level module punya lastIndex yang saling ditimpa antar panggilan async paralel. Solusi delapan baris.
Block code yang numpuk dua kali
Artikel baru aja di-publish, saya buka halamannya, dan code block pertama muncul dua kali. Bukan double render sepenuhnya, lebih jelek: satu blok tampil normal pake jendela editor, terus di bawahnya ada salinan mentahnya. Segmen HTML sekitarnya kepotong nyaris random. Refresh sekali, kadang ilang. Refresh lagi, muncul lagi di blok lain.
Buat konteks: blog ini nge-highlight semua code block server-side pake Shiki. Fungsi highlightCodeBlocks motong HTML artikel, nyari tiap <pre><code>, terus ganti sama hasil highlight. Regex pencarinya waktu itu dideklarasi di level module:
// shared di seluruh proses — satu objek regex
const CODE_RE =
/<pre><code class="language-([^"]+)">([\s\S]*?)<\/code><\/pre>/g;
// tiap artikel jalanin ini paralel:
while ((m = CODE_RE.exec(html)) !== null) { ... }
Dugaan pertama saya: cache. Sitemap dan halaman di blog ini di-revalidate tiap ±120 detik, dan bug-nya hilang-pergi, jadi wajar cache jadi tersangka pertama. Nunggu cache expired, tes ulang, masih rusak. Baru saya sadar polanya: kejadian cuma pas beberapa artikel di-render barengan.
Di situ letak masalahnya. Regex JavaScript itu stateful pas dipake bendera g. Tiap exec() nyimpen posisi terakhir di properti lastIndex, dan panggilan berikutnya mulai dari situ, bukan dari awal. Perilaku ini tercatat di dokumentasi MDN. Satu objek regex di level module artinya satu lastIndex dipake bareng semua pemanggil.
Nah highlightCodeBlocks itu async dan dijalanin Promise.all buat beberapa blok sekaligus. Artikel A jalan exec(), dapet match, lastIndex pindah ke, misal, 800. Sebelum sempat lanjut, article B (panggilan lain, regex yang sama) jalan exec() dari posisi 800 di string dia sendiri. Dua-duanya saling nimpa jejak. Hasilnya: blok kelewat, segmen kepotong di tempat yang salah, atau kebaca dua kali dan didorong dua kali ke array output.
Race klasik. Cuma yang direbut bukan file atau koneksi, tapi satu angka kecil di dalam objek regex.
Fix yang kecil banget kerasanya
Solusinya bikin regex jadi lokal per panggilan, bukan dibagi:
// simpan source-nya aja di level module
const CODE_RE_SRC =
'<pre><code class="language-([^"]+)">([\\s\\S]*?)<\\/code><\\/pre>';
function highlightCodeBlocks(html) {
// baru instance baru tiap eksekusi — lastIndex privat
const codeRe = new RegExp(CODE_RE_SRC, "g");
while ((m = codeRe.exec(html)) !== null) { ... }
}
Delapan baris berubah. Regex module-level jadi string source yang iners, instance RegExp dibikin lokal tiap fungsi jalan. Nggak ada lock, nggak ada antrean, nggak ada library. Dua pemanggilan paralel masing-masing pegang objek sendiri, dan lastIndex masing-masing juga.
Yang bikin bug ini geli: kode-nya keliatan benar. Loop while + exec itu pola standar buat iterasi match, persis kayak contoh di MDN. Masalahnya pola itu ditulis di zaman kode synchronous, terus dibungkus async tanpa ada yang sadar regex-nya ternyata punya state. Bug-nya juga nggak konsisten: jalan sendirian, aman. Dua artikel di-render berbarengan, rusak. Makanya lolos dari testing manual awal.
Pelajaran yang saya bawa: regex berbendera g itu resource bersama. Di modul yang bisa dipanggil paralel, deklarasi dia di scope atas itu nggak beda sama variable global yang ditulis-timpa tanpa antrean. Kalau mau hemat allocation, simpan string source-nya aja, biar new RegExp murah itu yang bayar biaya kecil per panggilan. Biaya itu lebih murah daripada bug yang cuma muncul pas dua halaman ke-render pas beneran berdekatan.