Mengapa Hydration Mismatch Terjadi pada Markup Email
Peringatan Hydration failed because the server-rendered HTML didn't match the client sering muncul saat memuat draft HTML email pada framework SSR seperti Next.js. Kode markup email sarat dengan tabel bersarang, inline CSS, komentar kondisional MSO, dan tag non-standar. Ketika markup ini diproses di dua lingkungan berbeda, DOM tree yang dihasilkan hampir tidak pernah identik.
Ada tiga sumber utama perbedaan (drift) render tersebut:
- Perbedaan Parser HTML Server vs Browser: Di server (Node.js), sanitasi menggunakan library seperti DOMPurify membutuhkan DOM tiruan seperti
jsdomataulinkedom. Parser ini mengimplementasikan spesifikasi HTML parsing dengan toleransi berbeda dibanding parser native browser (Blink/WebKit). Contohnya, penanganan tag void self-closing seperti<br/>vs<br>, serta konversi entitas karakter (misalnya menjadi Unicode\u00A0). - Drift Eksekusi Sanitizer: Menjalankan
DOMPurify(window).sanitize(html)di server menghasilkan urutan atribut, whitespace normalisasi, atau pembersihan tag yang berbeda beberapa karakter dibanding eksekusi di browser native. Satu perbedaan spasi atau urutan atributstylevsclasslangsung memicu kegagalan rekonsiliasi VDOM. - Mutasi DOM Eksternal: Ekstensi browser (Grammarly, password manager, penerjemah, atau Dark Reader) kerap memodifikasi atribut elemen atau menginjeksi
<span>ke dalam draft HTML yang dapat diedit sebelum hidrasi framework selesai berjalan.
Arsitektur Solusi: Boundary Rendering Deterministik
Mencoba menyelaraskan konfigurasi JSDOM di server agar 100% identik dengan seluruh engine browser adalah langkah sia-sia. Solusi arsitektural yang tepat adalah memisahkan tanggung jawab rendering ke dalam dua batas (boundary):
- Isolasi Preview dengan Iframe Sandbox: HTML email tidak boleh menjadi anak langsung dari VDOM aplikasi utama. Selain memicu hydration error, style global email merusak styling dashboard aplikasi. Gunakan
<iframe srcDoc={sanitizedHtml}>. Iframe memisahkan parsing email sepenuhnya ke parser browser tanpa melibatkan rekonsiliasi React. - Client-Only Editor Boundary: Editor rich-text (seperti TipTap, Lexical, atau Quill) bergantung pada API browser seperti
document.execCommandataucontentEditable. Komponen editor wajib di-mount murni di sisi client via dynamic import (ssr: false). - Mitigasi FOUC (Flash of Unstyled Content): Saat menunggu client-side mount, render kerangka visual (skeleton) atau container kosong dengan dimensi tetap di sisi server. Hal ini menjaga layout shift (CLS) tetap nol tanpa memasukkan string HTML mentah ke VDOM server.
Implementasi Kode: Next.js App Router
Berikut implementasi isolasi preview email dan editor tanpa memicu hydration mismatch:
// components/email-preview.tsx
'use client';
import { useEffect, useState } from 'react';
interface EmailPreviewProps {
initialHtml: string;
}
export function EmailPreview({ initialHtml }: EmailPreviewProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// ponytail: render skeleton di SSR untuk cegah FOUC dan hydration mismatch.
// upgrade path: tambahkan CSP sandbox jika email memuat link/script eksternal.
if (!mounted) {
return (
<div className="h-[500px] w-full animate-pulse rounded border bg-slate-100 p-4">
<div className="h-4 w-1/3 rounded bg-slate-200 mb-2" />
<div className="h-4 w-2/3 rounded bg-slate-200" />
</div>
);
}
return (
<iframe
title="Email Preview"
srcDoc={initialHtml}
sandbox="allow-same-origin"
className="h-[500px] w-full rounded border bg-white shadow-sm"
/>
);
}Untuk editor interaktif, gunakan dynamic import untuk memutus siklus SSR:
// components/email-editor-boundary.tsx
import dynamic from 'next/dynamic';
export const DynamicEmailEditor = dynamic(
() => import('./rich-editor').then((mod) => mod.RichEditor),
{
ssr: false,
loading: () => (
<div className="h-64 w-full animate-pulse rounded border bg-slate-50" />
),
}
);Aturan Sanitasi Deterministik
Jika HTML wajib dirender langsung tanpa iframe, sanitasi harus dilakukan hanya sekali di sisi server (misalnya pada backend API sebelum disimpan ke database), lalu client menerima hasil sanitasi tersebut apa adanya tanpa re-sanitasi ulang yang memicu drift.
Gunakan flag suppressHydrationWarning hanya pada elemen pembungkus teks statis leaf-node. Jangan pernah meletakkannya di root node container editor yang memiliki banyak nested children, karena React akan melewatkan patch atribut pada seluruh subtree tersebut jika terjadi mutasi eksternal.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!