Akar Masalah: Parser Drift antara Node.js dan Browser

Hydration mismatch terjadi ketika representasi Virtual DOM (VDOM) yang dihasilkan server berbeda dengan DOM tree yang dikonstruksi oleh browser saat proses hidrasi awal. Pada aplikasi yang merender arsip snapshot web atau konten legacy mentah (raw HTML), masalah ini hampir selalu berakar pada parser drift.

Perbedaan mendasar terletak pada cara kedua runtime membaca HTML:

  • Server-side (Node.js/React SSR): String HTML dirender secara linier atau melalui parser sederhana berbasis XML/JSX tanpa algoritma koreksi kesalahan lengkap.
  • Browser: Mengimplementasikan spesifikasi HTML5 Tree Construction Algorithm yang secara otomatis membetulkan sintaks cacat.

Ketidaksesuaian tipikal mencakup:

  1. Auto-insertion <tbody>: Browser selalu menyisipkan tag <tbody> di dalam <table> jika elemen <tr> didefinisikan secara langsung. Server-side string renderer tidak menambahkan tag ini kecuali ditulis eksplisit.
  2. Illegal Nesting: Standar HTML5 melarang tag blok di dalam elemen inline atau paragraf. Contohnya: <p><div>...</div></p>. Browser otomatis menutup tag <p> pertama sebelum membuka <div>, menghasilkan tiga node terpisah (<p></p>, <div>, <p></p>), sementara VDOM server menganggapnya hierarki bertingkat satu.
  3. Unclosed Tags: Tag seperti <li>, <dt>, atau <dd> yang tidak ditutup menyebabkan browser mereka ulang kedalaman DOM tree berdasarkan context rules spesifikasi HTML5.

Dampak: DOM Replacement dan State Loss

Saat reconciler React menemukan struktur DOM browser tidak cocok dengan tree hasil eksekusi VDOM di client, React mendeteksi mismatch. Pada React 18+, strategi penanganannya adalah membuang subtree yang tidak cocok dan melakukan client-side render ulang (DOM replacement) pada node tersebut.

Dampaknya:

  • Hilangnya State Lokal: Jika snapshot memuat elemen input form legacy, state seleksi teks, atau kanvas interaktif, penggantian DOM secara paksa mereset seluruh state ke kondisi awal.
  • Penurunan Metrik Web Vitals: Rekonstruksi subtree DOM memicu Layout Shift (CLS) dan memblokir main thread, meningkatkan Interaction to Next Paint (INP).
  • Event Listener Orphaning: Event handler yang di-attach ke node lama terputus, menyebabkan UI tidak responsif hingga hidrasi ulang selesai secara menyeluruh.

Solusi 1: Normalisasi AST Deterministik pada Pre-render Pipeline

Solusi paling stabil adalah menyeragamkan pohon HTML sebelum string dikirim ke VDOM atau client. Gunakan pipeline parser berbasis spesifikasi HTML5 seperti parse5 atau ekosistem unified (rehype) pada fase build atau pre-render data fetching.

Pipeline ini memproses HTML malformed menggunakan algoritma koreksi yang identik dengan browser, menyisipkan <tbody> yang hilang, dan menutup tag yang menggantung secara deterministik.

import { unified } from 'unified';
import rehypeParse from 'rehype-parse';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import rehypeStringify from 'rehype-stringify';

export async function normalizeLegacyHtml(rawHtml: string): Promise<string> {
  // ponytail: parsing fragment memadai untuk cuplikan konten; gunakan dokumen penuh jika parsing snapshot halaman utuh
  const file = await unified()
    .use(rehypeParse, { fragment: true })
    .use(rehypeSanitize, {
      ...defaultSchema,
      attributes: {
        ...defaultSchema.attributes,
        '*': ['className', 'style', 'id']
      }
    })
    .use(rehypeStringify)
    .process(rawHtml);

  return String(file);
}

Catatan: Jalankan fungsi ini pada getStaticProps, Server Component, atau backend service sebelum menyimpan snapshot ke cache. Hindari menjalankan parser AST di setiap render cycle client karena beban CPU tinggi.

Solusi 2: Isolasi Konten via Shadow DOM Boundary

Jika snapshot web berisi styling usang, tag kustom yang tidak valid, atau struktur skrip masa lalu yang tidak boleh disentuh reconciler React, isolasi konten menggunakan Web Components Shadow DOM. Reconciler React hanya melihat satu container node kosong, sementara browser menangani parsing dokumen legacy di dalam root terpisah.

'use client';

import { useEffect, useRef } from 'react';

interface SnapshotViewerProps {
  htmlContent: string;
}

export function SnapshotViewer({ htmlContent }: SnapshotViewerProps) {
  const hostRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!hostRef.current) return;

    const shadowRoot =
      hostRef.current.shadowRoot ||
      hostRef.current.attachShadow({ mode: 'open' });

    shadowRoot.innerHTML = htmlContent;
  }, [htmlContent]);

  return <div ref={hostRef} data-testid="snapshot-host" />;
}

Pendekatan ini memisahkan siklus hidup rendering framework dari parsing HTML mentah. Hydration mismatch tereliminasi karena tidak ada VDOM children yang dicocokkan pada container tersebut.

Solusi 3: suppressHydrationWarning dan Batasannya

Atribut suppressHydrationWarning sering disalahartikan sebagai solusi universal. Atribut ini hanya mengabaikan perbedaan pada atribut dan teks satu tingkat di bawah elemen target.

// Hanya bekerja untuk atribut atau textContent tingkat pertama:
<span suppressHydrationWarning>
  {snapshot.timestamp}
</span>

// TIDAK BERFUNGSI untuk struktur tag yang berbeda:
// Browser akan mengubah ini menjadi <table><tbody><tr>...</tbody></tr></table>
// Mismatch tetap terjadi dan memicu DOM replacement.
<div suppressHydrationWarning dangerouslySetInnerHTML={{ __html: '<table><tr><td>Data</td></tr></table>' }} />

Gunakan suppressHydrationWarning hanya untuk token dinamis seperti timestamp lokal, bukan untuk memperbaiki markup HTML struktural yang cacat.

Solusi 4: Client-Only Island (Lazy Evaluation)

Alternatif lebih hemat kode jika snapshot tidak memerlukan SEO indexation adalah menonaktifkan SSR sepenuhnya untuk komponen penampil arsip menggunakan dynamic import.

import dynamic from 'next/dynamic';

const LegacyArchiveView = dynamic(
  () => import('@/components/LegacyArchiveView'),
  { ssr: false }
);

Tanpa output HTML dari server, browser melakukan initial parse langsung di sisi client. Konsekuensinya adalah hilangnya visibilitas konten oleh web crawler mesin pencari.

Pengujian Deterministik: DOM Snapshot Verification

Untuk memastikan pipeline normalisasi server menghasilkan tree yang selaras dengan browser HTML5 engine, implementasikan automated test menggunakan JSDOM atau Playwright.

import { describe, it, expect } from 'vitest';
import { JSDOM } from 'jsdom';
import { normalizeLegacyHtml } from './normalizer';

describe('Snapshot Normalization', () => {
  it('menyelaraskan struktur tabel dengan HTML5 spec', async () => {
    const malformedHtml = '<table><tr><td>Cell</td></tr></table>';
    const normalized = await normalizeLegacyHtml(malformedHtml);

    const dom = new JSDOM(normalized);
    const table = dom.window.document.querySelector('table');

    // Memastikan tbody disisipkan secara deterministik sebelum SSR hydration
    expect(table?.firstElementChild?.tagName.toLowerCase()).toBe('tbody');
  });
});

Panduan Pemilihan Strategi

  • Butuh SEO dan struktur relatif standar: Gunakan Solusi 1 (Normalisasi AST via rehype/parse5 pada fase ingestion data).
  • Dokumen sangat rusak / styling legacy bentrok: Gunakan Solusi 2 (Shadow DOM Boundary).
  • Konten internal di balik dashboard/auth: Gunakan Solusi 4 (Client-Only Island). Alternatif paling minim kode: gunakan <iframe srcdoc={htmlContent} sandbox="allow-same-origin" /> tanpa library tambahan.