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
IntlatauDate()berdasarkan konfigurasi sistem operasi lokal pengguna. Menampilkan timestamp relatif langsung pada initial render memicu perbedaan teks secara langsung. - Premature Polling: Memanggil
setIntervallangsung 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
suppressHydrationWarninghanya 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 callbackuseEffectuntuk mencegah memory leak saat navigasi route.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!