Root Cause: Desinkronisasi AST pada Token Stream Parsial

Saat membangun antarmuka generative AI dengan Server-Side Rendering (SSR) di Next.js atau React Server Components (RSC), teks hasil inferensi Large Language Model (LLM) dikirim ke klien menggunakan format chunk streaming (Server-Sent Events atau Transfer-Encoding: chunked). Masalah muncul ketika respons parsial ini memuat sintaksis Markdown yang terpotong di tengah-tengah token pengubah konteks, seperti unclosed code fence (```), penanda format teks tebal (**), atau link reference ([text](url).

Ketika server merender snapshot markup pada titik potong tertentu, parser Markdown (seperti Remark, Marked, atau Markdown-it) berusaha memperbaiki atau menafsirkan token yang tidak lengkap tersebut ke dalam struktur Abstract Syntax Tree (AST). Jika snapshot server memuat ```typescript const a = 1; tanpa penutup fence, parser sisi server mungkin akan menghasilkan tag <pre><code class="language-typescript">const a = 1; dengan penutupan tag otomatis oleh parser HTML. Namun, jika runtime klien mengeksekusi proses hidrasi setelah chunk baru tiba atau mengevaluasi string dengan konteks berbeda, AST klien menghasilkan nodus DOM yang berlainan. Hasilnya adalah pesan error fatal pada dev console:

Error: Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Expected server HTML to contain a matching <pre> in <div>.

Mekanisme Kegagalan Parser Markdown Konvensional

Parser Markdown standar dirancang untuk memproses dokumen statis yang sudah lengkap secara utuh. Parser beroperasi dengan algoritma multi-pass untuk menentukan blok syntax tree:

  1. State Machine Trap: Ketika token parser menemui pembuka code block, parser berpindah ke state code-block-mode. Jika EOF (End of File) tercapai sebelum penutup ditemukan, sebagian parser membatalkan state tersebut dan memperlakukannya sebagai inline text, sementara parser lain secara otomatis menambahkan node penutup virtual di tingkat AST.
  2. Perbedaan Environment SSR vs Client: Server snapshot membekukan HTML pada byte ke-N, sedangkan klien menerima stream secara asinkron. Jika hidrasi React dijalankan pada instance di mana state klien diinisialisasi dari payload awal tetapi parser klien berjalan secara deterministik terhadap teks mentah, perbedaan microtask timing menyebabkan perbedaan parsing pohon elemen.
  3. DOM Tree Mutation: Selama proses hidrasi, React membandingkan virtual DOM yang dihasilkan klien pada first-render pass dengan DOM aktual yang dihasilkan SSR. Perbedaan sekecil node teks berbanding tag <span> atau <code> akan memicu hydration mismatch dan memaksa React melakukan deoptimasi (bailing out ke client-side re-render penuh), menyebabkan layout shift yang agresif.

Implementasi Token Buffer Deterministik

Untuk memastikan AST yang dihasilkan identik di server dan klien, kita harus mencegah parser menerima sintaksis Markdown yang setengah terbuka. Solusinya adalah memproses chunk stream mentah melalui buffer sanitizer deterministik sebelum teks dioper ke parser Markdown.

Fungsi buffer ini mendeteksi token pembuka yang belum memiliki pasangan penutup, lalu mensintesis token penutup sementara untuk keperluan visualisasi tanpa memodifikasi payload streaming aslinya:

// lib/markdown-buffer.ts

interface SanitizedStreamResult {
  content: string;
  isIncompleteCodeBlock: boolean;
  activeLanguage: string | null;
}

export function stabilizeMarkdownStream(rawText: string): SanitizedStreamResult {
  let content = rawText;
  let isIncompleteCodeBlock = false;
  let activeLanguage: string | null = null;

  // Deteksi unclosed code fences (```)
  const fenceMatches = [...content.matchAll(/^```([a-zA-Z0-9_-]*)/gm)];
  const isCodeFenceOpen = fenceMatches.length % 2 !== 0;

  if (isCodeFenceOpen) {
    isIncompleteCodeBlock = true;
    const lastFence = fenceMatches[fenceMatches.length - 1];
    activeLanguage = lastFence[1] || null;
    
    // Sintesis penutup fence untuk parser AST
    content = content + "\n```";
  }

  // Deteksi token inline formatting yang terpotong di akhir stream
  // Format: bold (**), italic (*), inline code (`)
  content = stabilizeInlineTokens(content);

  return { content, isIncompleteCodeBlock, activeLanguage };
}

function stabilizeInlineTokens(text: string): string {
  // Regex untuk memeriksa dangling formatting di ujung string
  // Jika trailing karakter adalah tanda format ganjil tanpa penutup, potong sementara
  const unclosedBold = (text.match(/\*\*/g) || []).length % 2 !== 0;
  if (unclosedBold) {
    return text + "**";
  }

  const unclosedInlineCode = (text.match(/(?<!\w)`(?!\w)/g) || []).length % 2 !== 0;
  if (unclosedInlineCode) {
    return text + "`";
  }

  return text;
}

Implementasi Safe Hydration Boundary pada React / Next.js

Buffer deterministik harus diisolasi dalam komponen yang menjamin sinkronisasi rendering antara server output awal dan initial client hydrate. Pendekatan terbaik adalah membatasi evaluasi streaming hanya setelah hydration reconciliation selesai, atau memastikan initial snapshot diserialisasi bersamaan dengan SSR payload.

// components/DeterministicMarkdownStream.tsx
'use client';

import React, { useSyncExternalStore, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import { stabilizeMarkdownStream } from '@/lib/markdown-buffer';

interface StreamProps {
  initialContent: string;
  streamSubscriber?: {
    subscribe: (callback: () => void) => () => void;
    getSnapshot: () => string;
    getServerSnapshot: () => string;
  };
}

export function DeterministicMarkdownStream({ 
  initialContent, 
  streamSubscriber 
}: StreamProps) {
  // Gunakan useSyncExternalStore untuk mencegah re-render tearing
  const rawStream = useSyncExternalStore(
    streamSubscriber?.subscribe ?? (() => () => {}),
    streamSubscriber?.getSnapshot ?? (() => initialContent),
    streamSubscriber?.getServerSnapshot ?? (() => initialContent)
  );

  const { content, isIncompleteCodeBlock } = useMemo(() => {
    return stabilizeMarkdownStream(rawStream);
  }, [rawStream]);

  return (
    <div className="stream-container relative">
      <ReactMarkdown>{content}</ReactMarkdown>
      {isIncompleteCodeBlock && (
        <span 
          aria-hidden="true" 
          className="inline-block w-2 h-4 ml-1 bg-blue-500 animate-pulse align-middle" 
        />
      )}
    </div>
  );
}
Catatan Teknis: Penggunaan useSyncExternalStore memastikan bahwa nilai yang dibaca pada fasa rendering pertama di klien selalu identik dengan getServerSnapshot yang di-bake ke dalam server document. Pembacaan stream progresif baru berjalan segera setelah mounting pass selesai tanpa memicu reaktivitas hidrasi.

Mitigasi Layout Shift (CLS) dan Truncation Artefak

Masalah sekunder dari pemrosesan token parsing parsial adalah layout shift (Cumulative Layout Shift). Kode blok yang belum tertutup sering kali menyebabkan tinggi kontainer berfluktuasi secara drastis:

  • Min-Height Reservation: Tetapkan nilai min-height pada elemen wrapper code block via styling Tailwind atau CSS vanilla untuk mencegah collapse saat parser beralih dari inline mode ke block mode.
  • CSS Containment: Terapkan properti CSS contain: layout style; pada boundary pembungkus pesan. Hal ini mengisolasi re-flow browser hanya pada subtree markdown tersebut, menjaga performa rendering tetap 60fps meskipun token tiba dengan frekuensi tinggi (di atas 50 token/detik).
  • Deterministic Font Sizing: Pastikan font monospace untuk code fence memiliki fallbacks yang setara (misalnya font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace) agar metrik layout teks tidak meloncat saat parser merender token blok baru.

Verifikasi Dev Server dan Troubleshooting

Untuk memvalidasi bahwa implementasi buffer telah menghapus hydration mismatch secara total, jalankan verifikasi berikut di lingkungan development Next.js:

  1. Aktifkan flag strict hydration pada React 18/19. Pastikan tidak ada peringatan dengan header Warning: Text content did not match.
  2. Simulasikan slow-network chunking dengan menyuntikkan artificial delay via transfer stream API pada route handler lokal:
// app/api/chat/route.ts
export async function POST() {
  const encoder = new TextEncoder();
  const chunks = [
    "Halo, ini contoh implementasi:",
    "\n\n```typescript\nconst data",
    " = { id: 1, name: 'AI' };\n",
    "console.log(data);\n```\nSelesai."
  ];

  const stream = new ReadableStream({
    async start(controller) {
      for (const chunk of chunks) {
        controller.enqueue(encoder.encode(chunk));
        await new Promise((res) => setTimeout(res, 300));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
  });
}

Jika halaman direfresh saat stream berada pada chunk kedua (```typescript\nconst data), server HTML dan hydrate tree klien akan tetap menghasilkan node <pre><code> yang valid tanpa melempar hydration exception. Buffer deterministik menjaga stabilitas parsing pada level token stream sebelum sintaksis diserahkan ke compiler Markdown.