Anatomi Hydration Mismatch dan Silent Dead Click

Pada arsitektur Server-Side Rendering (SSR) seperti Next.js, Nuxt, atau SvelteKit, server mengirimkan dokumen HTML statis ke browser untuk mempercepat First Contentful Paint (FCP). Setelah berkas JavaScript dimuat di sisi klien, proses hydration berjalan. Proses ini mencocokkan Virtual DOM klien dengan DOM nyata dari server guna menempelkan (attach) interaktivitas dan event listener.

Ketika struktur DOM yang di-render klien berbeda dari HTML server, terjadi hydration mismatch. Dampak paling berbahaya dari anomali ini bukan layar putih atau hard crash, melainkan silent dead click. Elemen tombol atau form terlihat sempurna secara visual, namun tidak merespons klik pengguna sama sekali.

Kondisi ini terjadi karena reconciler (misalnya pada React 18+) mendeteksi ketidaksesuaian indeks node. Framework mungkin mengabaikan pemasangan event listener pada node yang dianggap korup, mencoba memulihkan sub-tree dengan cara menimpa node tanpa registrasi ulang, atau menempelkan handler ke elemen tetangga yang salah. Pengguna mengira aplikasi macet, padahal UI sekadar kehilangan jembatan JavaScript-nya.

Mengapa Test Runner Konvensional Kerap Lolos

Pengujian End-to-End (E2E) standar sering kali melewatkan silent dead click karena alasan berikut:

  • Assert visual semata: Perintah expect(locator).toBeVisible() hanya mengecek status visibilitas CSS dan keberadaan node pada DOM tree, bukan kesiapan event listener.
  • Toleransi Synthetic Click: Beberapa runner menyuntikkan event langsung via JavaScript jika elemen ditemukan, namun bila verifikasi akhir bergantung pada status internal aplikasi yang tidak terpicu, tes sering kali hanya dianggap flaky alih-alih diidentifikasi sebagai bug hidrasi.
  • Hydration warning diabaikan: Log peringatan hidrasi hanya dicetak di console.error atau console.warn browser tanpa memutus eksekusi tes kecuali dikonfigurasi secara eksplisit.

Deteksi Otomatis Menggunakan Playwright dan CDP

Cara paling andal untuk mendeteksi silent dead click sebelum ke produksi adalah mengombinasikan penangkapan log konsol dengan inspeksi event listener langsung menggunakan Chrome DevTools Protocol (CDP).

CDP menyediakan domain DOMDebugger.getEventListeners yang memungkinkan test runner memeriksa apakah suatu node DOM benar-benar memiliki fungsi listener aktif pada runtime browser.

Contoh Implementasi Test Agent

Berikut skrip uji menggunakan Playwright untuk memvalidasi ketiadaan error hidrasi sekaligus memverifikasi keberadaan listener click pada elemen kritis:

import { test, expect } from '@playwright/test';

test('Verifikasi interaktivitas tombol dan ketiadaan hydration error', async ({ page }) => {
  const hydrationErrors = [];

  // 1. Tangkap seluruh warning dan error hidrasi dari konsol
  page.on('console', (msg) => {
    const text = msg.text();
    if (
      msg.type() === 'error' || msg.type() === 'warning'
    ) {
      if (
        text.includes('Hydration failed') ||
        text.includes('did not match') ||
        text.includes('server did not match')
      ) {
        hydrationErrors.push(text);
      }
    }
  });

  await page.goto('https://app.internal/checkout', { waitUntil: 'networkidle' });

  // 2. Buka sesi CDP untuk inspeksi low-level DOM
  const client = await page.context().newCDPSession(page);

  const buttonLocator = page.locator('#btn-submit-order');
  await expect(buttonLocator).toBeVisible();

  // Ambil remote object ID dari elemen DOM target
  const elementHandle = await buttonLocator.elementHandle();
  const { objectId } = await client.send('DOM.resolveNode', {
    backendNodeId: (await client.send('DOM.describeNode', {
      nodeId: (await client.send('DOM.requestNode', {
        objectId: elementHandle._remoteObject.objectId,
      })).nodeId,
    })).node.backendNodeId,
  });

  // 3. Periksa event listener via DOMDebugger
  const { listeners } = await client.send('DOMDebugger.getEventListeners', {
    objectId,
  });

  const hasClickListener = listeners.some((l) => l.type === 'click');

  // 4. Assert ketiadaan log error dan keberadaan listener
  expect(hydrationErrors, `Ditemukan error hidrasi: ${hydrationErrors.join(' | ')}`).toHaveLength(0);
  expect(hasClickListener, 'Listener click tidak terpasang pada tombol submit').toBeTruthy();

  // Uji fungsionalitas aktual
  await buttonLocator.click();
  await expect(page.locator('#order-success-modal')).toBeVisible();
});

Pendekatan di atas memastikan dua hal: pipeline pengujian langsung gagal jika terjadi perbedaan struktur DOM server-klien, dan elemen kritis terbukti memiliki handler sebelum interaksi dijalankan.

Pola Perbaikan Frontend: Menjaga Determinisme Rendering

Penyebab utama hydration mismatch adalah ketidaksamaan eksekusi logika antara runtime Node.js di server dan V8 di browser. Terapkan pola-pola berikut untuk mencegah anomali tersebut.

1. Hindari Akses API Browser di Luar Siklus Klien

Mengakses objek seperti window, localStorage, atau navigator langsung saat instansiasi komponen menghasilkan output DOM server kosong atau berbeda dari klien.

Gunakan siklus hidup yang hanya dieksekusi di klien (seperti useEffect pada React) untuk membaca nilai dinamis tersebut:

// SALAH: Render berbeda langsung saat inisialisasi
function AuthStatus() {
  const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
  return <div>{token ? <DashboardLink /> : <LoginForm />}</div>;
}

// BENAR: Two-pass rendering deterministik
import { useState, useEffect } from 'react';

function AuthStatus() {
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
    if (localStorage.getItem('token')) {
      setIsAuthenticated(true);
    }
  }, []);

  // Server dan first client render identik
  if (!mounted) {
    return <div className="skeleton-placeholder" />;
  }

  return <div>{isAuthenticated ? <DashboardLink /> : <LoginForm />}</div>;
}

2. Standarisasi Format Tanggal dan Zona Waktu

Server yang berjalan dalam zona waktu UTC akan merender representasi tanggal berbeda dengan browser pengguna yang berada di WIB (UTC+7). Lakukan formatting tanggal hanya setelah komponen terpasang di klien, atau gunakan nilai zona waktu eksplisit yang dioper dari konfigurasi server ke klien.

3. Bersihkan Manipulasi DOM oleh Pihak Ketiga

Ekstensi browser (seperti pengelola kata sandi atau penerjemah otomatis) sering menyuntikkan elemen atau atribut sebelum skrip hidrasi selesai dieksekusi. Jika elemen tertentu memang memiliki konten yang tidak dapat diprediksi dari server (misalnya badge dinamis iklan), batasi dampaknya menggunakan properti framework:

<!-- React: abaikan perbedaan pada atribut/text spesifik -->
<span suppressHydrationWarning>
  {new Date().toLocaleTimeString()}
</span>
Catatan: Gunakan suppressHydrationWarning hanya untuk text-node atau atribut isolatif. Jangan gunakan atribut ini pada level root container karena hanya akan menyembunyikan silent dead click tanpa memperbaiki reconciler yang rusak.

Integrasi ke CI/CD Pipeline

Untuk mencegah bug masuk ke staging atau produksi, standarkan aturan pengujian berikut pada pipeline integrasi:

  1. Jadikan Hydration Warning Fatal: Konfigurasikan runner tes unit/komponen (seperti Vitest atau Jest) dan E2E (Playwright/Cypress) untuk melempar error bila terdapat panggilan console.error yang mengandung kata kunci hidrasi.
  2. Validasi Halaman SSR Kritis: Fokuskan pengujian CDP listener pada halaman dengan konversi tinggi (alur autentikasi, checkout, pembayaran).
  3. Audit HTML Hasil Build: Jalankan verifikasi statis terhadap markup hasil SSR sebelum dideploy guna memastikan tidak ada tag HTML ilegal (seperti <p> di dalam <p> atau <div> di dalam <span>) yang memicu perbaikan struktur otomatis oleh parser browser.