Di sebuah landing page yang saya bangun, ada animasi frame-sequence yang digerakkan oleh scroll: ratusan frame gambar diputar di atas canvas seiring pengguna menggulir, mirip efek "scrubbing" video tapi bikinnya dari gambar terpisah. Di lokal semuanya mulus. Preloader naik dari 0 ke 100 persen, animasi mengunci ke scroll, selesai. Lalu saya deploy ke produksi, dan tiba-tiba preloader mentok di 100 persen tanpa pernah menyerahkan layar ke animasi. Kadang macet, kadang tidak. Persis jenis bug yang bikin saya paling curiga.
Saya buka console di produksi, dan di situlah ada petunjuknya:
Uncaught InvalidStateError: Failed to execute 'drawImage' on 'CanvasRenderingContext2D': The image argument is a broken image.
Yang bikin bingung: error ini muncul sesekali di produksi, tapi tidak pernah sekali pun di lokal. Refleks pertama saya jelek, seperti biasa. Saya pikir ini masalah timing GSAP, atau canvas yang belum siap saat render pertama dipanggil. Saya utak-atik urutan inisialisasi selama setengah jam sebelum sadar saya salah menuduh.
Gejalanya
Preloader saya bekerja dengan cara sederhana: memuat semua frame, menghitung berapa yang sudah selesai, dan begitu hitungannya mencapai total frame, bar diisi ke 100 persen lalu animasi di-handoff. Di produksi bar mencapai 100 persen, jadi secara logika semua frame "selesai" dimuat. Tapi animasinya tidak pernah jalan. Itu kontradiksi yang aneh: loader bilang beres, render bilang ada gambar rusak.
Saya cek tab Network. Di sanalah tersangkanya muncul: satu frame .webp balik dengan status 404. Path-nya salah ketik satu digit di penomoran urutan. Di lokal, file itu ada di disk, jadi tidak pernah 404. Di produksi, file itu tidak pernah ke-upload dengan nama yang benar, jadi satu request dari sekian ratus itu gagal. Satu frame busuk, dan seluruh animasi tumbang.
Akar masalahnya
Ini bagian yang bikin saya diam sejenak. Preloader saya memakai handler onerror di setiap Image, dan handler itu memanggil resolve(), bukan reject(). Niatnya baik: saya tidak mau satu gambar gagal menggantung seluruh Promise.all selamanya. Tapi efek sampingnya fatal. Frame yang 404 tetap menghasilkan objek HTMLImageElement, cuma isinya kosong. Preloader menganggapnya "selesai" karena promise-nya resolve, hitungan naik, dan bar mencapai 100 persen. Loader sama sekali tidak tahu ada yang salah.
Lalu fungsi render saya menggambar frame demi frame ke canvas. Satu-satunya penjaga yang saya punya adalah cek kebenaran: kalau objek gambarnya ada, gambar. Masalahnya, Image yang rusak itu tetap truthy. Bahkan lebih menyesatkan lagi:
console.log(brokenImg.complete); // true
console.log(brokenImg.naturalWidth); // 0Jadi complete bernilai true seolah gambar sudah selesai dimuat, padahal naturalWidth nol karena tidak ada piksel apa pun. Cek if (img) saya lolos, ctx.drawImage(img, ...) dipanggil dengan gambar kosong, dan browser melempar InvalidStateError. Lemparan itu naik lewat callback matchMedia GSAP masuk ke loader, yang menangkapnya tapi tidak bisa memulihkan keadaan, jadi handoff tidak pernah terjadi dan preloader membeku di 100 persen.
Kenapa cuma di produksi? Karena cuma di produksi yang ada frame 404. Di lokal setiap frame nyata, naturalWidth selalu lebih dari nol, dan drawImage tidak pernah punya alasan untuk melempar. Bug-nya selalu ada di kode saya; lokal cuma tidak pernah memicunya.
Perbaikannya
Solusinya bukan mengubah onerror jadi reject, itu justru bikin satu frame busuk menggantung seluruh loader. Solusinya adalah berhenti mempercayai kebenaran objek gambar dan mulai memeriksa apakah gambar itu benar-benar bisa digambar. Saya tambahkan satu penjaga kecil:
function isImageReady(img) {
return img && img.complete && img.naturalWidth > 0;
}Lalu setiap panggilan drawImage saya gerbangi dengan penjaga itu:
if (isImageReady(img)) {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
}Sekarang frame yang rusak dilewati diam-diam. Canvas cuma menahan frame valid terakhir untuk indeks itu, dan karena visibilitas animasi memang sudah digerbangi oleh autoAlpha, satu frame yang hilang tidak terlihat oleh mata. Tidak ada lagi lemparan, tidak ada lagi loader yang membeku.
Saya juga menunda pemanggilan render() mana pun keluar dari inisialisasi matchMedia. Sebelumnya render pertama dipicu saat setup animasi, jadi gambar busuk bisa merobohkan seluruh setup sebelum pengguna sempat menggulir sedikit pun. Dengan menunda render awal sampai ada interaksi, satu frame yang gagal tidak akan pernah lagi meledak di tengah inisialisasi.
Dan tentu saja, saya perbaiki path yang salah ketik itu supaya frame tidak lagi 404. Tapi yang penting: sekarang situs tidak lagi tumbang meski suatu hari nanti ada frame yang benar-benar hilang.
Checklist
Kalau drawImage melempar InvalidStateError cuma di produksi, ini yang saya periksa:
- Cek tab Network untuk request gambar yang balik
404atau403. Satu frame busuk cukup untuk menumbangkan urutan. - Ingat bahwa
Imageyang rusak tetap truthy dancompletetetaptrue; cek kebenaran objek tidak cukup. - Selalu gerbangi
drawImagedengan penjaga sepertiimg && img.complete && img.naturalWidth > 0. - Hati-hati kalau
onerrordi preloader memanggilresolve(); itu menyembunyikan frame yang gagal jadi terlihat "selesai". - Tunda render pertama keluar dari callback setup animasi supaya satu gambar buruk tidak meledak sebelum interaksi.
Bug ini mengajari saya lagi bahwa "ada" dan "bisa dipakai" itu dua hal berbeda. Objek gambar bisa berdiri di memori, lolos setiap cek kebenaran, dan tetap tidak punya satu piksel pun untuk digambar. Sejak itu, penjaga naturalWidth > 0 jadi hal pertama yang saya tulis setiap kali menyentuh canvas.
