Membangun visualisasi peta propagasi DNS global secara realtime dengan Server-Side Rendering (SSR) sering memicu hydration mismatch error di React atau Next.js. Masalah ini terjadi ketika HTML awal yang dihasilkan server berbeda dengan struktur DOM yang dieksekusi client saat proses rekonsiliasi awal.

Pada aplikasi seperti DNS visualizer atau DNSGlobe, akar persoalan terletak pada state polling DNS di SSR yang menghasilkan data non-deterministik, format waktu lokal berbeda, serta timer yang berjalan sebelum runtime browser siap.

Penyebab Utama Hydration Mismatch pada Peta DNS

Server dan client harus menghasilkan tree rendering identik pada render pertama. Namun pada sistem monitoring propagasi DNS, tiga faktor berikut sering merusaknya:

  • Non-Deterministic State: Node DNS resolver (Google 8.8.8.8, Cloudflare 1.1.1.1, dsb.) diperiksa pada interval cepat. Nilai TTL dan status respon berubah milidetik setelah server selesai merender halaman.
  • Perbedaan Zona Waktu dan Locale: Server biasanya berjalan pada zona waktu UTC, sedangkan browser client membaca Intl atau Date() berdasarkan konfigurasi sistem operasi lokal pengguna. Menampilkan timestamp relatif langsung pada initial render memicu perbedaan teks secara langsung.
  • Premature Polling: Memanggil setInterval langsung pada initialization logic atau modul level menyebabkan client mencoba mengubah state sebelum fase hydration selesai.

Strategi Arsitektur: Isolasi Snapshot dan Lazy Polling

Solusi yang benar membutuhkan pemisahan siklus hidup data: server hanya merender initial static snapshot atau state kosong yang netral, dan background polling baru diaktifkan setelah browser selesai melakukan mounting.

1. Sanitasi Epoch Timestamp di SSR

Hindari memformat tanggal relatif seperti "2 seconds ago" di server. Simpan waktu resolver selalu dalam format standar ISO-8601 string atau epoch timestamp integer (ms). Konversi ke teks lokal hanya dilakukan pada komponen client setelah mount.

2. Implementasi Store dengan useSyncExternalStore

React 18 memperkenalkan hook useSyncExternalStore untuk mengonsumsi data dari sumber eksternal tanpa risiko tearing atau ketidakcocokan hydration. Hook ini menerima parameter getServerSnapshot yang terpisah dari getSnapshot client.

// lib/dnsStore.ts
export interface DnsNode {
  id: string;
  resolver: string;
  status: 'propagated' | 'pending' | 'failed';
  resolvedIp: string | null;
  lastCheckedEpoch: number;
}

type Listener = () => void;

let memoryState: DnsNode[] = [
  { id: '1', resolver: '1.1.1.1 (Cloudflare)', status: 'pending', resolvedIp: null, lastCheckedEpoch: 0 },
  { id: '2', resolver: '8.8.8.8 (Google)', status: 'pending', resolvedIp: null, lastCheckedEpoch: 0 },
];

const listeners = new Set<Listener>();

export const dnsStore = {
  subscribe(listener: Listener) {
    listeners.add(listener);
    return () => listeners.delete(listener);
  },
  getSnapshot() {
    return memoryState;
  },
  getServerSnapshot() {
    // Snapshot deterministik khusus SSR
    return memoryState;
  },
  updateNode(id: string, update: Partial<DnsNode>) {
    memoryState = memoryState.map(node => 
      node.id === id ? { ...node, ...update } : node
    );
    listeners.forEach(l => l());
  }
};

Komponen Client: Lazy Interval Polling

Jalankan interval polling hanya setelah event mount terjadi melalui hook useEffect. Kode di bawah memastikan initial markup dari server tetap valid tanpa error rekonsiliasi:

// components/DnsPropagationMap.tsx
'use client';

import { useEffect, useState, useSyncExternalStore } from 'react';
import { dnsStore, DnsNode } from '../lib/dnsStore';

export function DnsPropagationMap() {
  const nodes = useSyncExternalStore(
    dnsStore.subscribe,
    dnsStore.getSnapshot,
    dnsStore.getServerSnapshot
  );

  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);

    // Lazy start interval polling hanya setelah hydrate selesai
    const pollInterval = window.setInterval(async () => {
      for (const node of nodes) {
        try {
          const res = await fetch(`/api/resolve?target=example.com&dns=${node.resolver}`);
          const data = await res.json();
          dnsStore.updateNode(node.id, {
            status: data.match ? 'propagated' : 'failed',
            resolvedIp: data.ip,
            lastCheckedEpoch: Date.now()
          });
        } catch {
          dnsStore.updateNode(node.id, { status: 'failed' });
        }
      }
    }, 5000);

    return () => clearInterval(pollInterval);
  }, [nodes]);

  return (
    <div className="dns-grid">
      {nodes.map(node => (
        <div key={node.id} className={`node-card ${node.status}`}>
          <h4>{node.resolver}</h4>
          <p>Status: {node.status}</p>
          <p>Resolved IP: {node.resolvedIp ?? 'Checking...'}</p>
          <span>
            {/* Sanitasi: Format tanggal hanya dievaluasi setelah browser mount */}
            {isMounted && node.lastCheckedEpoch > 0
              ? new Date(node.lastCheckedEpoch).toLocaleTimeString()
              : 'Waiting for poll...'}
          </span>
        </div>
      ))}
    </div>
  );
}

Hydration Boundary Alternatif: suppressHydrationWarning

Jika visualisasi peta menggunakan elemen SVG atau kanvas koordinat WebGL/3D yang menghitung dimensi layar pengguna (misalnya radius bola dunia berdasarkan window.innerWidth), gunakan atribut suppressHydrationWarning secara terbatas pada node spesifik:

<div 
  className="node-timestamp" 
  suppressHydrationWarning
>
  {/* Hanya ignorir perbedaan string timestamp pada leaf node ini */}
  {new Date(node.lastCheckedEpoch).toLocaleTimeString()}
</div>

Peringatan: Gunakan suppressHydrationWarning hanya pada elemen leaf (teks/angka). Jangan terapkan pada parent wrapper layout karena React tidak akan memperbaiki hierarki tree yang hilang atau salah urutan.

Checklist Validasi

  • Verifikasi tidak ada pemanggilan window, document, atau browser API saat inisialisasi modul store.
  • Pastikan data SSR props menyediakan payload data resolver yang identik dengan output getServerSnapshot.
  • Pastikan pembersihan timer (clearInterval) dieksekusi pada return callback useEffect untuk mencegah memory leak saat navigasi route.