Penyebab Utama Hydration Mismatch pada Pipeline Markdown Hybrid
Hydration mismatch terjadi ketika representasi Virtual DOM (VDOM) yang dihasilkan oleh framework JavaScript pada runtime klien tidak cocok secara byte-per-byte dengan pohon DOM yang dirender oleh server (SSR). Saat merender Markdown menggunakan native backend binary seperti Rust (misalnya menggunakan pulldown-cmark atau comrak) demi performa, lalu mencoba mem-parse ulang atau merehidrasi string Markdown di browser menggunakan library JavaScript (seperti marked atau remark), perbedaan spesifikasi implementasi menyebabkan kegagalan hidrasi.
Framework modern seperti React, Vue, atau Svelte akan memicu peringatan konsol atau membuang seluruh DOM SSR dan melakukan render ulang dari nol (busting tree). Dampaknya adalah hilangnya performa SSR, layout shift (CLS), dan putusnya event listener.
1. Normalisasi Whitespace dan Karakter Garis Baru
Spesifikasi CommonMark memberikan ruang toleransi dalam penanganan karakter escape, spasi trailing, dan konversi \r\n ke \n. Parser Rust sering kali melakukan striping terhadap empty text node di dalam elemen blockquote atau tag list, sedangkan parser JS mempertahankan whitespace sebagai standalone text node. Satu whitespace node yang hilang menghasilkan ketidakcocokan child index pada VDOM reconciliation.
2. Penanganan Self-Closing Tag
Parser HTML di server yang dieksekusi via Rust sering kali menghasilkan tag HTML valid XHTML seperti <img src="..." /> atau <br />. Namun, browser DOM parser memperlakukan tag void HTML5 secara implisit tanpa slash (<img src="...">). Jika engine SSR VDOM mengantisipasi self-closing token sementara DOM aktual membuangnya, rehidrasi gagal.
3. Duplikasi Sanitasi HTML yang Tidak Sinkron
Jika server menggunakan crate sanitasi seperti ammonia di Rust, sementara browser menjalankan DOMPurify di sisi klien, kedua pustaka ini memiliki aturan filtering atribut default yang berbeda (misalnya penanganan rel="noopener" otomatis pada tag anchor atau urutan atribut XML). Perbedaan urutan atribut pada node HTML mentah yang diinjeksi via dangerouslySetInnerHTML memicu rekonsiliasi VDOM ulang.
Contoh Mismatch: Rust Backend vs JS Client Parsing
Pola anti-pattern berikut menunjukkan pengiriman raw Markdown dari server ke browser, di mana klien mencoba mem-parse ulang string tersebut:
// Anti-pattern: Markdown diparse dua kali di platform berbeda
// Rust Backend:
let markdown_input = "* Item 1\n* Item 2\n\n";
let html_output = pulldown_cmark_to_html(markdown_input);
// Menghasilkan: <ul><li>Item 1</li><li>Item 2</li></ul>
// JavaScript Frontend (Hydration):
import { marked } from 'marked';
const ClientArticle = ({ rawMarkdown }) => {
// marked menghasilkan whitespace node berbeda:
// <ul>\n<li>Item 1</li>\n<li>Item 2</li>\n</ul>
const html = marked.parse(rawMarkdown);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
};
// Error: Text content does not match server-rendered HTML.Arsitektur Rendering Deterministik
Untuk menghilangkan mismatch secara absolut, hilangkan ambiguitas parsing ganda. Terdapat dua arsitektur deterministik yang dapat diterapkan:
Solusi 1: Serialisasi AST Terpadu dari Server
Server Rust bertindak sebagai sumber kebenaran tunggal (Single Source of Truth). Rust mem-parse Markdown, melakukan sanitasi, lalu mengompilasi Abstract Syntax Tree (AST) ke format JSON terstruktur. Browser hanya bertindak sebagai view renderer yang mengiterasi AST JSON tanpa pernah mem-parse string Markdown mentah.
// Rust: Ekspor AST terstruktur menggunakan serde
use serde::Serialize;
#[derive(Serialize)]
#[serde(tag = "type", content = "value")]
pub enum AstNode {
Heading { level: u8, children: Vec<AstNode> },
Paragraph { children: Vec<AstNode> },
Text(String),
}
pub fn parse_to_ast(markdown: &str) -> Vec<AstNode> {
// Pipeline pulldown-cmark mapping ke struct AstNode
// Sanitasi dijalankan pada level node text di sini
vec![]
}Komponen klien kemudian merender AST tersebut secara deklaratif:
// Frontend Component: Render AST murni tanpa parser eksternal
type AstNode =
| { type: 'Heading'; value: { level: number; children: AstNode[] } }
| { type: 'Paragraph'; value: { children: AstNode[] } }
| { type: 'Text'; value: string };
function AstRenderer({ nodes }: { nodes: AstNode[] }) {
return (
<>
{nodes.map((node, i) => {
switch (node.type) {
case 'Heading': {
const Tag = `h${node.value.level}` as keyof JSX.IntrinsicElements;
return <Tag key={i}><AstRenderer nodes={node.value.children} /></Tag>;
}
case 'Paragraph':
return <p key={i}><AstRenderer nodes={node.value.children} /></p>;
case 'Text':
return node.value;
}
})}
</>
);
}Solusi 2: Engine Parser Identik Menggunakan WebAssembly (Wasm)
Jika parsing Markdown harus tetap dilakukan secara interaktif di browser (misalnya live editor), kompilasi pipeline Rust yang sama ke target wasm32-unknown-unknown. Gunakan binary Wasm ini baik di server (via Node.js/Wasm runtime) maupun di client browser.
// Cargo.toml
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
pulldown-cmark = "0.10"
ammonia = "4.0"
// src/lib.rs
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn render_markdown_deterministic(input: &str) -> String {
let mut html_output = String::new();
let parser = pulldown_cmark::Parser::new(input);
pulldown_cmark::html::push_html(&mut html_output, parser);
ammonia::clean(&html_output)
}Panduan Debugging Mismatch
- Bandingkan Snapshot DOM Mentah: Gunakan opsi
curlpada endpoint SSR untuk melihat byte HTML murni, lalu bandingkan dengandocument.getElementById('root').innerHTMLtepat sebelum proses rehidrasi JavaScript dimulai. - Periksa White-space collapsing: Pastikan pipeline Rust tidak menggunakan flag pretty-print HTML yang menyisipkan identasi baru ke dalam node teks inline seperti
<span>atau<code>. - Bungkus Container Statis: Jika markup Markdown adalah HTML statis yang tidak membutuhkan reaktivitas DOM pada child node-nya, tandai pembungkus menggunakan atribut isolasi framework, seperti
suppressHydrationWarningpada React. Namun, ini adalah solusi sementara, bukan pengganti struktur pipeline deterministik.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!