D
P
0
← Semua artikel Read in English

JavaScript, DOM & Animasi Browser

Animasi Kelihatan Patah-patah di Playwright? rAF Ke-throttle Sampai Sekitar 1 fps di Jendela yang Occluded

· · 5 menit baca
Animasi Kelihatan Patah-patah di Playwright? rAF Ke-throttle Sampai Sekitar 1 fps di Jendela yang Occluded

Saya sedang memverifikasi lapisan gerak sebuah landing page lewat Playwright MCP, dijalankan terhadap instalasi WordPress lokal di mesin sendiri, jenis Studio yang basis datanya SQLite. Yang bergerak di halaman itu ada dua lapis: tween anime.js untuk animasi masuk, dan smooth scroll Lenis yang jalan di atas rAF loop manual. Praktis semua gerakan di sana menggantung pada satu sumber yang sama, yaitu requestAnimationFrame.

Lewat jendela Chromium yang dikemudikan Playwright, tween-nya patah-patah. Bukan agak kurang mulus, tapi tersendat seperti animasi yang sebagian besar framenya dibuang di tengah jalan. Saya sudah mulai menyusun daftar tersangka di kepala: easing yang salah, durasi yang kepanjangan, urutan pemicu yang saling tumpang tindih.

Halaman yang sama di browser biasa, mulus sempurna

Sebelum membuka file animasinya saya buka dulu halaman yang sama di browser normal yang sedang fokus di depan saya. Di sana animasinya mulus sempurna. Bukan sedikit lebih baik, gejalanya memang tidak ada sama sekali.

Itu memindahkan pertanyaannya ke tempat lain. Dua percobaan tadi menjalankan kode yang identik pada halaman yang sama persis, jadi selisihnya tidak mungkin ada di dalam kodenya. Yang berbeda cuma siapa yang memegang jendelanya, dan apakah jendela itu sedang terlihat.

Yang direm bukan animasinya, tapi sumber framenya

requestAnimationFrame di jendela otomasi Playwright yang occluded, artinya tidak terlihat karena tertutup jendela lain atau berada di belakang, di-throttle browser sampai kira-kira satu frame per detik.

Begitu angka itu ditaruh di sebelah cara kerja halamannya, gejalanya jadi masuk akal. Lenis di landing itu berjalan dengan lerp sekitar 0,11 di atas rAF loop manual, jadi tiap frame dia menutup 11 persen dari sisa jarak dan menyisakan 0,89 dari jarak sebelumnya. Kalau framenya cuma satu per detik, hitungannya gampang diulang sendiri: setelah satu detik sisa jaraknya 0,89, dan setelah lima detik 0,89 pangkat lima, yaitu sekitar 0,558. Lebih dari separuh perjalanan scroll masih tersisa setelah lima detik penuh. Dilihat mata, itu bukan smooth scroll, itu halaman yang merayap lalu berhenti, lalu merayap lagi.

Catatan yang saya tinggalkan waktu itu menarik kesimpulannya lebih jauh, bahwa tween apa pun yang digerakkan rAF akan terlihat jank di otomasi walaupun kodenya sehat. Saya menulisnya sebagai dugaan, bukan sebagai hasil pengukuran satu per satu, karena yang benar-benar saya ukur hanya laju framenya. Arah dugaannya masuk akal, sebab semua yang bergerak di halaman itu memang menggantung pada sumber frame yang sama.

Ukur rafPerSecond sebelum menuduh kode animasi

Mata tidak bisa membedakan "animasinya rusak" dari "framenya jarang". Keduanya menghasilkan tampilan yang mirip. Jadi langkah pertama yang sekarang saya wajibkan bukan membaca kode animasi, melainkan menghitung berapa kali requestAnimationFrame benar-benar dipanggil dalam satu detik di halaman yang sedang diperiksa.

const rafPerSecond = await page.evaluate(() => new Promise((resolve) => {
  let frames = 0;
  const start = performance.now();
 
  const tick = () => {
    frames += 1;
    if (performance.now() - start >= 1000) resolve(frames);
    else requestAnimationFrame(tick);
  };
 
  requestAnimationFrame(tick);
}));

Kalau angkanya terbaca sekitar 1, janknya adalah throttling, bukan bug di kode animasi. Satu pembacaan itu menutup seluruh cabang investigasi yang tadi hampir saya masuki. Tidak ada gunanya menyetel easing di lingkungan yang cuma memberi satu frame per detik untuk merendernya.

Yang pantas diserahkan ke otomasi: state, urutan, dan nilai akhir

Aturan yang saya ambil dari kejadian ini cukup satu kalimat: jangan pernah mendiagnosis kemulusan animasi dari jendela otomasi yang occluded. Yang diverifikasi lewat otomasi adalah logikanya, yaitu state, urutan, dan nilai akhir. Ketiganya tidak peduli berapa frame yang sempat dirender.

Di landing itu ada beberapa hal yang memang berbentuk logika dan enak diperiksa dengan cara begitu. Animasi masuknya dibangun di dalam callback IntersectionObserver tepat pada saat dipicu, sekali jalan lalu observernya dilepas. Pre-state anti-flash hero-nya dipasang sebagai inline style yang sinkron sebelum CSS hold dilepas, dengan huruf-hurufnya di translateY 105 persen di dalam clip dan elemen lain di opacity 0. Dan animasi masuk tidak boleh replay saat pengunjung scroll bolak-balik, sementara efek yang tersinkron scroll memang boleh terus berjalan.

Semua itu bisa dinyatakan sebagai perbandingan nilai, bukan sebagai penilaian rasa.

// pre-state harus sudah terpasang sebelum elemennya masuk viewport
const before = await page.evaluate(() => {
  const el = document.querySelector('[data-reveal]');
  return { opacity: el.style.opacity, transform: el.style.transform };
});
 
await page.evaluate(() => {
  document.querySelector('[data-reveal]').scrollIntoView();
});
 
// nilai akhir, setelah pemicunya jalan
const after = await page.evaluate(() => {
  const cs = getComputedStyle(document.querySelector('[data-reveal]'));
  return { opacity: cs.opacity, transform: cs.transform };
});

Satu bug di halaman itu justru jenis yang tertangkap cara ini dan tidak akan pernah tertangkap dengan cara ditonton. Safety net reveal yang meng-unhide semua elemen setelah sekian detik memunculkan urutan yang aneh: teks muncul, lalu animasinya jalan, lalu teksnya muncul lagi, dan itu kelihatan waktu saya scroll dengan santai. Perbaikannya mempersempit kewenangan net itu. Dia hanya boleh menolong elemen yang sedang ada di layar tapi observernya gagal, dengan ambang dua strike pada interval 1,5 detik, bukan konten yang masih di bawah fold. Itu murni soal urutan dan state, dan satu frame per detik sudah lebih dari cukup untuk membuktikannya.

Jank yang benar-benar nyata tetap ada, dan tempatnya bukan di sini

Kesimpulan tadi gampang ditarik terlalu jauh, jadi perlu saya pisahkan. Bukan berarti semua yang tersendat di halaman itu palsu.

Di landing yang sama ada jitter yang memang nyata, dan sebabnya menumpuk ken burns atau scale CSS di atas video yang sudah punya camera motion sendiri. Resample tiap framenya terasa tersendat. Perbaikannya bukan soal alat, melainkan soal layer: videonya butuh compositor layer sendiri, dan huruf-huruf di intro juga dipromosikan ke layer lalu dilepas begitu animasinya selesai.

.hero-video {
  transform: translateZ(0);
  will-change: transform;
}

Bedanya jadi jelas begitu langkah pengukuran ada di depan. Jitter jenis itu tetap ada waktu laju framenya normal, sedangkan patah-patah yang saya kejar di awal hilang begitu jendelanya dipandang manusia.

Yang berubah di cara saya memverifikasi