Hydration mismatch pada aplikasi WebAssembly (WASM) berbasis Rust seperti Leptos atau Yew terjadi ketika representasi DOM hasil Server-Side Rendering (SSR) berbeda dengan pohon komponen yang dibangun klien pada saat hidrasi berjalan. Salah satu sumber masalah paling sulit dilacak adalah eksekusi inisialisasi state sebelum fungsi hidrasi sempat membaca DOM.

Akar Masalah: Siklus Hidrasi dan Pola Life-Before-Main

Saat browser memuat artefak WebAssembly, modul instansiasi akan mengeksekusi fungsi yang ditandai dengan atribut #[wasm_bindgen(start)] atau inisialisasi variabel statis global (misalnya via std::sync::OnceLock atau lazy_static!) secara sinkron sebelum kontrol diserahkan ke fungsi hidrasi framework.

Perbedaan snapshot terjadi melalui urutan berikut:

  1. Server phase: Server merender HTML dengan nilai default atau snapshot state tertentu, lalu mengirimkannya sebagai markup statis ke browser.
  2. WASM instantiation phase: Runtime browser memuat biner .wasm. Macro #[wasm_bindgen(start)] langsung dieksekusi. Jika tahap ini memodifikasi state global (misalnya membaca window.localStorage, mengambil resolusi layar, atau membangkitkan UUID/timestamp), state klien berubah sebelum proses hidrasi dimulai.
  3. Hydration phase: Framework memanggil fungsi hidrasi (seperti leptos::mount::hydrate_to_body). Framework mengevaluasi view klien menggunakan state global yang telah bermutasi. Hasil virtual tree klien tidak cocok dengan markup SSR yang ada di DOM, sehingga hidrasi gagal atau memicu rekalkulasi ulang layout yang merusak performa.

Contoh Kode Minimal Pemicu Mismatch

Kode di bawah menunjukkan mutasi state global di dalam fungsi inisialisasi WASM yang langsung menyebabkan divergensi markup:

use leptos::prelude::*;
use wasm_bindgen::prelude::*;
use std::sync::atomic::{AtomicBool, Ordering};

static IS_DARK_MODE: AtomicBool = AtomicBool::new(false);

#[wasm_bindgen(start)]
pub fn wasm_init() {
    // ANTI-PATTERN: Membaca storage/lingkungan klien sebelum hidrasi
    if let Some(window) = web_sys::window() {
        if let Ok(Some(storage)) = window.local_storage() {
            let dark = storage.get_item("theme").unwrap_or_default() == Some("dark".into());
            IS_DARK_MODE.store(dark, Ordering::Relaxed);
        }
    }
}

#[component]
pub fn App() -> impl IntoView {
    // Server selalu merender false (mode terang),
    // namun klien yang memiliki token dark di localStorage membaca true.
    let is_dark = IS_DARK_MODE.load(Ordering::Relaxed);
    
    view! {
        <div class=if is_dark { "theme-dark" } else { "theme-light" }>
            <h1>"Hydration Test"</h1>
        </div>
    }
}

Ketika klien memiliki preferensi tema gelap di localStorage, server mengirim tag <div class="theme-light">, tetapi hidrasi klien mengharapkan <div class="theme-dark">. DOM walker framework akan mendeteksi perbedaan atribut kelas dan membatalkan adopsi node yang mulus.

Solusi 1: Deserialisasi State Awal dari Skrip SSR Deterministik

Untuk menjaga sinkronisasi, state awal klien tidak boleh ditebak dari lingkungan runtime klien sebelum hidrasi selesai. Klien harus menggunakan state eksak yang digunakan server ketika merender markup. Ini dilakukan dengan menyisipkan payload JSON ke dalam tag <script> pada saat SSR.

<!-- Disisipkan oleh server sebelum tag <script> WASM dimuat -->
<script id="__SERVER_STATE__" type="application/json">
  {"theme":"light","user_id":null}
</script>

Pada sisi Rust, klien membaca script tag tersebut tanpa melakukan evaluasi independen terhadap API browser lokal selama fase bootstrap:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Clone, Default)]
pub struct InitialState {
    pub theme: String,
    pub user_id: Option<u64>,
}

pub fn extract_ssr_state() -> InitialState {
    web_sys::window()
        .and_then(|w| w.document())
        .and_then(|doc| doc.get_element_by_id("__SERVER_STATE__"))
        .and_then(|el| el.text_content())
        .and_then(|json| serde_json::from_str(&json).ok())
        .unwrap_or_default()
}

Solusi 2: Pola Penundaan Mutasi DOM Pasca-Hidrasi (Deferred Mutation)

Jika state lokal browser (seperti localStorage atau status autentikasi cookie) harus diterapkan, tunda eksekusi mutasi sampai framework selesai menempelkan listener ke DOM asli. Di Leptos, gunakan Effect untuk memastikan kode hanya dieksekusi di platform klien setelah hidrasi pertama selesai.

use leptos::prelude::*;

#[component]
pub fn SafeApp() -> impl IntoView {
    // Inisialisasi sinyal dengan nilai default deterministik yang identik dengan server
    let (theme, set_theme) = signal("theme-light".to_string());

    // Effect HANYA berjalan di sisi klien setelah proses mounting/hidrasi selesai
    Effect::new(move |_| {
        if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok()).flatten() {
            if let Ok(Some(saved_theme)) = storage.get_item("theme") {
                if saved_theme == "dark" {
                    set_theme.set("theme-dark".to_string());
                }
            }
        }
    });

    view! {
        <div class=move || theme.get()>
            <h1>"Hydration Aman"</h1>
        </div>
    }
}

Catatan: Pola ini sengaja membiarkan DOM hidrasi selesai dalam kondisi tema server (misalnya light), lalu merender ulang bagian yang diperlukan secara reaktif ke tema klien (dark). Hal ini mencegah framework mendeteksi perbedaan DOM tree saat initial traversal.

Verifikasi Menggunakan Browser Hydration Diff

Untuk mendiagnosis hydration mismatch, aktifkan log framework mode debug saat kompilasi. Jalankan kompilasi dengan target pengembangan browser tanpa flag optimasi strip:

# Kompilasi WASM development untuk mempertahankan log debug
cargo leptos watch

Buka Developer Tools konsol browser (F12). Jika hidrasi gagal karena eksekusi state prematur, framework memunculkan pesan peringatan terstruktur:

Hydration mismatch detected at node: <div class="theme-light">
Expected client value: "theme-dark"
Actual server value: "theme-light"
Action: Node replaced. Performance penalty applied.

Verifikasi keberhasilan perbaikan:

  1. Bersihkan cache browser dan pastikan localStorage memiliki data yang memicu percabangan state.
  2. Muat ulang halaman dengan log konsol tetap terbuka.
  3. Pastikan tidak ada peringatan level WARN bertuliskan mismatch, unmatched node, atau hydration dropped.
  4. Inspect element untuk memastikan class atau atribut berubah tepat setelah skrip WASM selesai terhidrasi, bukan sebelum hydrate() selesai melintasi child node DOM.