Akar Masalah Hydration Drift pada Tema

Pola implementasi dark mode konvensional pada aplikasi Single Page Application (SPA) umumnya mengandalkan localStorage untuk menyimpan preferensi pengguna. Pada arsitektur Inertia.js dengan Server-Side Rendering (SSR), pola ini menyebabkan masalah mendasar: lingkungan eksekusi Node.js di server tidak memiliki akses ke Web Storage API (window atau localStorage).

Ketika request masuk, SSR engine mengeksekusi komponen dan merender representasi HTML awal menggunakan fallback state default (misalnya tema light). Namun, begitu HTML tiba di browser, runtime client (Vue atau React) membaca state tema dari localStorage (misalnya tema dark) dan memodifikasi Virtual DOM. Perbedaan antara DOM hasil render server dan Virtual DOM awal di client memicu hydration drift.

Dampak Teknis di Lingkungan Produksi

  • Hydration Mismatch Warning: Console browser memunculkan peringatan fatal hidrasi (misal: "Hydration completed but contains mismatches" di Vue atau "Text content did not match" di React).
  • Flash of Unstyled Content (FOUC): Layar berkedip putih sesaat sebelum JavaScript client-side selesai diunduh, dieksekusi, dan menambahkan class dark pada elemen root.
  • Decoupling UI Listener: Pada kasus hidrasi yang rusak parah, framework frontend membatalkan attachment event listener, menyebabkan tombol toggle tema tidak merespons interaksi pengguna.

Arsitektur Solusi: Cookie-Driven State Synchronization

Solusi deterministik untuk mengeliminasi hydration mismatch adalah memindahkan persistensi preferensi tema dari localStorage ke HTTP Cookie. Berbeda dengan localStorage, cookie dikirimkan secara otomatis dalam header HTTP (Cookie) pada request awal halaman. Hal ini memungkinkan backend Laravel dan renderer Node.js SSR mengetahui preferensi pengguna secara sinkron sebelum HTML dikirim ke browser.

1. Baca Cookie di Laravel Middleware

Tangkap cookie preferensi tema dan bagikan ke Inertia shared data melalui middleware HandleInertiaRequests.

<?php

namespace App\Http\Middleware;

use Illuminate\Http\Request;
use Inertia\Middleware;

class HandleInertiaRequests extends Middleware
{
    protected $rootView = 'app';

    public function share(Request $request): array
    {
        return array_merge(parent::share($request), [
            'theme' => $request->cookie('app_theme', 'light'),
        ]);
    }
}

Catatan: Jika cookie dienkripsi secara default oleh EncryptCookies middleware, pastikan nilai dibaca melalui helper $request->cookie() standar Laravel, bukan via global $_COOKIE.

2. Inject Class Sinkron di Root Template Blade

Hindari eksekusi script blocking di <head> untuk memanipulasi class. Letakkan class tema langsung pada tag <html> di file app.blade.php berdasarkan nilai cookie yang dibaca request server.

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="{{ request()->cookie('app_theme') === 'dark' ? 'dark' : '' }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    @vite(['resources/js/app.ts', "resources/js/Pages/{$page['component']}.vue"])
    @inertiaHead
</head>
<body class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
    @inertia
</body>
</html>

Dengan menyematkan class langsung di HTML mentah, browser merender styling yang tepat pada first paint tanpa bergantung pada parsing JavaScript, sehingga menghilangkan FOUC secara penuh.

3. Frontend State & Cookie Mutator (Vue 3 / TypeScript)

Pada sisi frontend, sinkronisasikan state tema menggunakan shared props dari Inertia, dan mutasikan cookie saat pengguna mengubah preferensi.

// resources/js/composables/useTheme.ts
import { ref } from 'vue';
import { usePage } from '@inertiajs/vue3';

type Theme = 'light' | 'dark';

export function useTheme() {
    const page = usePage<{ theme: Theme }>();
    const currentTheme = ref<Theme>(page.props.theme || 'light');

    const setTheme = (theme: Theme) => {
        currentTheme.value = theme;

        // Persistensi cookie 1 tahun, path root, SameSite Lax
        document.cookie = `app_theme=${theme};path=/;max-age=31536000;SameSite=Lax`;

        if (theme === 'dark') {
            document.documentElement.classList.add('dark');
        } else {
            document.documentElement.classList.remove('dark');
        }
    };

    const toggleTheme = () => {
        setTheme(currentTheme.value === 'dark' ? 'light' : 'dark');
    };

    return { currentTheme, setTheme, toggleTheme };
}

Edge Cases & Penanganan prefers-color-scheme

Ketika pengguna pertama kali mengunjungi aplikasi dan belum memiliki cookie app_theme, server akan menyajikan tema default (light). Jika pengguna tersebut menggunakan setelan sistem operasi dark mode, hidrasi tetap valid, namun visual akan melompat setelah deteksi media query.

Untuk mitigasi tanpa merusak hidrasi:

  1. HTTP Client Hints: Gunakan header Sec-CH-Prefers-Color-Scheme jika browser mendukungnya. Server dapat merespons header tersebut untuk menentukan tema default pada first visit.
  2. Inlined Early Script Fallback: Jika Client Hints tidak digunakan dan cookie bernilai kosong, eksekusi script minimalis di <head> sebelum tag DOM utama untuk membaca window.matchMedia('(prefers-color-scheme: dark)') dan segera set class tanpa melibatkan hidrasi VDOM.

Ringkasan

Hydration mismatch pada tema terjadi akibat perbedaan sumber kebenaran (source of truth) antara runtime server dan client. Dengan memigrasikan persistensi ke HTTP cookie, server SSR dan client hydrate menerima initial state yang identik secara deterministik, menghasilkan UI yang bebas kedipan (zero-FOUC) serta pohon hidrasi yang bersih tanpa error console.