Hydration mismatch terjadi ketika representasi Virtual DOM yang di-generate oleh klien pada tahap awal (initial client render) tidak identik dengan struktur HTML hasil kompilasi server (SSR). Pada arsitektur Inertia.js (menggunakan Vue 3 atau React), ketidakcocokan ini memicu DOM drift: node DOM terpaksa dirombak ulang secara paksa di browser, memicu hilangnya state internal, degradasi performa, hingga UI glitch.

Akar Masalah: Evaluasi Non-Deterministik Node.js vs Browser

Server Inertia SSR mengeksekusi kode komponen di runtime Node.js, sementara hidrasi berjalan di mesin JavaScript browser. Mismatch timbul saat komponen mengevaluasi state yang berbeda di kedua lingkungan tersebut:

  • Perbedaan Format Tanggal dan Timezone: Server beroperasi di zona waktu UTC, sedangkan browser menggunakan zona waktu lokal pengguna (misal: GMT+7). Fungsi seperti toLocaleDateString() akan menghasilkan string berbeda.
  • Akses Langsung ke Browser API: Membaca objek window, document, atau localStorage langsung saat render awal menghasilkan undefined pada SSR, namun bernilai riil di browser.
  • Dynamic State Instan: Penggunaan fungsi non-deterministik seperti Math.random() atau crypto.randomUUID() saat inisialisasi state template.

Solusi 1: Normalisasi Tanggal Melalui ISO UTC

Hindari pemformatan tanggal langsung di level server template jika bergantung pada locale klien. Kirimkan raw timestamp (ISO-8601) dari Laravel/Inertia props, lalu tangani format spesifik klien hanya setelah komponen terpasang, atau gunakan tag semantik <time>.

Contoh perbaikan pada komponen Vue 3:

<!-- SEBELUM: Memicu Hydration Mismatch -->
<template>
  <span>{{ new Date(post.created_at).toLocaleString() }}</span>
</template>

<!-- SESUDAH: Aman dari Mismatch -->
<script setup>
import { ref, onMounted } from 'vue';

const props = defineProps<{ post: { created_at: string } }>();
const formattedDate = ref(props.post.created_at); // Fallback deterministik (ISO)

onMounted(() => {
  // Eksekusi mutasi hanya di runtime client
  formattedDate.value = new Date(props.post.created_at).toLocaleString('id-ID', {
    timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
  });
});
</script>

<template>
  <time :datetime='post.created_at'>{{ formattedDate }}</time>
</template>

Solusi 2: Two-Pass Rendering Menggunakan Mounted Flag

Untuk komponen yang memerlukan akses ke localStorage, ukuran viewport (window.innerWidth), atau token sesi lokal, gunakan strategi two-pass rendering. Pass pertama merender representasi dasar yang cocok dengan output SSR, pass kedua memperbarui UI dengan state lokal.

<script setup>
import { ref, onMounted } from 'vue';

const isMounted = ref(false);
const userTheme = ref('light'); // Default deterministik untuk SSR

onMounted(() => {
  isMounted.value = true;
  userTheme.value = localStorage.getItem('theme') || 'light';
});
</script>

<template>
  <div :class='userTheme'>
    <!-- Jangan render blok khusus browser sebelum mount selesai -->
    <span v-if='isMounted'>Tema aktif: {{ userTheme }}</span>
    <span v-else>Memuat preferensi...</span>
  </div>
</template>

Solusi 3: Wrapper Komponen ClientOnly Tanpa Layout Shift

Abstraksikan logika penundaan render ke dalam satu komponen wrapper ClientOnly. Sediakan slot fallback dengan reservasi dimensi (height/min-height) untuk mengeliminasi Cumulative Layout Shift (CLS).

<!-- components/ClientOnly.vue -->
<script setup>
import { ref, onMounted } from 'vue';

defineProps<{
  fallbackHeight?: string;
}>();

const isMounted = ref(false);

onMounted(() => {
  isMounted.value = true;
});
</script>

<template>
  <template v-if='isMounted'>
    <slot />
  </template>
  <div 
    v-else 
    :style='{ height: fallbackHeight || "auto" }' 
    aria-hidden='true'
  >
    <slot name='fallback' />
  </div>
</template>

Penggunaan pada halaman Inertia:

<template>
  <ClientOnly fallbackHeight='40px'>
    <GeoLocationBanner />
    <template #fallback>
      <div class='skeleton-loader' style='height: 40px;' />
    </template>
  </ClientOnly>
</template>

Verifikasi Hilangnya Warning di Browser Console

Jalankan proses SSR Inertia secara lokal untuk memverifikasi perbaikan:

npm run build
php artisan inertia:start-ssr
php artisan serve

Buka DevTools pada browser (tab Console). Jika hydration mismatch masih terjadi, framework menampilkan log eksplisit:

  • Vue 3: [Vue warn]: Hydration node mismatch atau Hydration text content mismatch disertai path elemen terkait.
  • React: Warning: Text content did not match. Server: "..." Client: "...".

Terapkan perbaikan di atas secara selektif. Gunakan normalisasi props ISO untuk data berbasis teks, dan manfaatkan ClientOnly hanya pada modul interaktif yang benar-benar membutuhkan API peramban.