Hydration mismatch terjadi ketika representasi Virtual DOM yang dibuat di browser berbeda dari markup HTML hasil render server. Kasus ini sering muncul saat aplikasi Next.js merender output prompt batching dari Large Language Models (LLM). Streaming chunk yang dievaluasi secara asinkron sering kali tiba dalam urutan atau ukuran berbeda antara server prerender dan klien, memicu pergeseran indeks rendering.
Akar Masalah: Pergeseran Indeks pada Prompt Batching
Saat mengirim beberapa prompt sekaligus (batching) ke LLM, model atau middleware proxy mengalirkan data menggunakan teknik chunking. Pada Server-Side Rendering (SSR), server membekukan state HTML awal berdasarkan snapshot chunk yang tersedia saat proses render berjalan. Di browser, koneksi streaming dimulai ulang atau dilanjutkan melalui hook klien.
Masalah muncul dari dua pola berikut:
- Penggunaan Array Index sebagai React Key: Jika server baru menerima chunk item A dan B, tetapi klien langsung menerima chunk C mendahului update state, pemetaan berbasis indeks (
items[index]) memicu mismatch struktur DOM. - State Streaming Non-Deterministik: Server mengirim partial text token pada node tertentu, sementara klien me-render placeholder awal atau token yang sudah digabung (merged payload).
Solusi 1: Pemetaan Data Deterministik dengan ID Eksplisit
Hindari ketergantungan pada urutan chunk dan indeks array. Berikan pengenal unik (UUID/prompt ID) dari sisi produsen batching sejak prompt dikirim.
// types/llm.ts
export interface BatchResponseItem {
id: string;
prompt: string;
content: string;
status: 'pending' | 'streaming' | 'completed';
}
// components/BatchResultItem.tsx
export function BatchResultItem({ item }: { item: BatchResponseItem }) {
return (
<div id={`result-${item.id}`} className="p-4 border-b">
<h3 className="font-mono text-sm">Prompt: {item.prompt}</h3>
<p className="mt-2 whitespace-pre-wrap">{item.content || 'Generating...'}</p>
</div>
);
}Solusi 2: Isolasi Komponen Menggunakan Suspense dan Dynamic Import
Memaksa komponen yang menerima mutasi streaming non-deterministik untuk di-render oleh SSR adalah penyebab utama eror. Tangani data ini dengan membungkus antarmuka ke dalam Client Component yang dinamis tanpa prerendering SSR, atau letakkan di belakang Suspense boundary dengan fallback statis.
// components/BatchStreamContainer.tsx
'use client';
import { useState, useEffect } from 'react';
import { BatchResponseItem, BatchResultItem } from './BatchResultItem';
export default function BatchStreamContainer({ initialIds }: { initialIds: string[] }) {
const [items, setItems] = useState<Record<string, BatchResponseItem>>(() =>
initialIds.reduce((acc, id) => {
acc[id] = { id, prompt: '', content: '', status: 'pending' };
return acc;
}, {} as Record<string, BatchResponseItem>)
);
// Stream listener / EventSource dipasang di useEffect agar tidak dievaluasi di server
useEffect(() => {
// ponytail: Mock EventSource. Ganti dengan SSE endpoint aktual.
const eventSource = new EventSource('/api/llm/batch-stream');
eventSource.onmessage = (event) => {
const payload: BatchResponseItem = JSON.parse(event.data);
setItems((prev) => ({
...prev,
[payload.id]: payload,
}));
};
return () => eventSource.close();
}, []);
return (
<div className="space-y-4">
{initialIds.map((id) => (
<BatchResultItem key={id} item={items[id]} />
))}
</div>
);
}Impor komponen tersebut pada halaman Next.js menggunakan dynamic untuk mematikan SSR pada sub-tree ini secara bersih:
// app/page.tsx
import dynamic from 'next/dynamic';
const BatchStreamContainer = dynamic(
() => import('@/components/BatchStreamContainer'),
{
ssr: false,
loading: () => <div className="p-4 text-gray-500">Menyiapkan stream LLM...</div>,
}
);
export default function Page() {
const batchIds = ['task-1', 'task-2', 'task-3'];
return (
<main className="max-w-xl mx-auto py-8">
<h1 className="text-xl font-bold mb-4">Batch Prompt Runner</h1>
<BatchStreamContainer initialIds={batchIds} />
</main>
);
}Uji Validasi DOM State Deterministik
Gunakan pengujian assertion sederhana menggunakan API standard DOM untuk memvalidasi bahwa inisialisasi node sebelum dan sesudah runtime menghasilkan struktur child dan atribut yang konsisten.
// tests/hydration-check.js
import { strict as assert } from 'node:assert';
function renderInitialMarkup(ids) {
return ids.map(id => `<div id="result-${id}"><p>Pending</p></div>`).join('');
}
function reconcileClient(ids, incomingChunks) {
const state = ids.reduce((acc, id) => {
acc[id] = incomingChunks[id] || 'Pending';
return acc;
}, {});
return ids.map(id => `<div id="result-${id}"><p>${state[id]}</p></div>`).join('');
}
// Test 1: Skeleton server harus cocok dengan initial render client sebelum stream masuk
const ids = ['req-1', 'req-2'];
const serverHtml = renderInitialMarkup(ids);
const clientInitialHtml = reconcileClient(ids, {});
assert.equal(serverHtml, clientInitialHtml, 'Markup server dan client skeleton harus identik');
// Test 2: Chunk acak tidak boleh memindahkan indeks node ID
const outOfOrderChunks = { 'req-2': 'Done 2', 'req-1': 'Done 1' };
const clientUpdatedHtml = reconcileClient(ids, outOfOrderChunks);
assert.ok(clientUpdatedHtml.indexOf('result-req-1') < clientUpdatedHtml.indexOf('result-req-2'), 'Struktur node ID harus deterministik mengikuti skema inisialisasi');
console.log('Hydration DOM structure assertion: PASSED');Dua pendekatan di atas memastikan rendering SSR dan mounting awal pada browser selalu berbagi snapshot data yang sama, menghentikan hydration mismatch sebelum LLM streaming mulai memanipulasi state.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!