Pada aplikasi developer typing test berbasis SSR (seperti Next.js atau Nuxt), memuat kode sumber yang di-highlight di sisi server sering kali memicu hydration mismatch. Masalah ini biasanya berakar pada inkonsistensi representasi whitespace antara string HTML server dan DOM parser browser, serta penyisipan elemen interaktif (seperti caret) langsung ke dalam token AST statis.

Ketika hydration gagal, browser terpaksa membuang markup server dan membangun ulang subtree DOM di sisi client. Akibatnya, terjadi layout shift, desinkronisasi posisi koordinat visual caret (caret drift), serta kalkulasi Words Per Minute (WPM) yang meleset akibat inkonsistensi indeks karakter.

Akar Masalah: Normalisasi Whitespace dan Divergensi DOM

Dua faktor utama yang menyebabkan kegagalan hidrasi pada teks kode sumber:

  • CRLF vs LF dan Tab Expansion: Runtime server (khususnya jika membaca snippet dari disk atau DB antar-OS) dapat mempertahankan \r\n atau karakter tab literal (\t). Ketika diserialisasi menjadi HTML dan di-parse oleh browser, text node tertentu dapat mengalami normalisasi tidak sengaja jika parser atau highlighter memperlakukan whitespace secara parsial.
  • Mutasi Token AST oleh State Kursor: Pola implementasi yang keliru sering kali menyisipkan elemen penanda kursor (misal: <span class="caret" />) langsung ke dalam array token syntax highlighter di sisi client. Karena server tidak memiliki akses ke state kursor awal yang terhidrasi secara dinamis, pohon DOM server dan client menghasilkan struktur node yang berbeda.

Dampak Whitespace Drift terhadap Posisi Caret

Aplikasi typing test umumnya menghitung posisi caret menggunakan getBoundingClientRect() dari span karakter aktif. Jika CSS text formatting atau text node split berubah pasca-hidrasi, lebar spasi visual (terutama akibat collapsing space atau perbedaan font metrics pada tab stop) akan bergeser beberapa piksel. Hal ini menyebabkan caret menjauh dari karakter yang sedang diketik.

Strategi Perbaikan Komprehensif

1. Normalisasi Deterministik Raw Code String

Sebelum string kode dioper ke tokenizer atau syntax highlighter (seperti PrismJS atau Shiki), string harus dinormalisasi secara deterministik. Ganti carriage return dan konversi tab menjadi representasi spasi statis yang konsisten.

2. Penanganan Deterministic Whitespace di Level CSS

Gunakan aturan CSS white-space: pre pada kontainer kode. Jangan mengandalkan white-space: pre-wrap kecuali aplikasi mengharuskan line wrapping, karena perhitungan caret drift meningkat secara signifikan pada soft wrap titik pergantian baris.

3. Isolasi State Input dari Pre-rendered AST Token

Jangan pernah memecah atau menyisipkan elemen DOM kursor ke dalam token tree AST. Biarkan AST token yang di-render di server tetap statis dan sepenuhnya identik saat hidrasi client. Tempatkan caret dan overlay input di layer terpisah secara absolut menggunakan koordinat layout.

Perbandingan Implementasi Kode

Sebelum: Implementasi Naif Menyebabkan Mismatch

Komponen berikut menyisipkan elemen kursor ke dalam token secara kondisional dan tidak menormalisasi whitespace string, memicu hydration error pada Next.js/React SSR.

// VULNERABLE: Menyisipkan state dinamis ke markup statis SSR
export function NaiveTypingTest({ rawCode, currentIndex }) {
  // rawCode berpotensi mengandung \r\n atau \t
  const tokens = rawCode.split(""); // Tokenisasi naif tanpa normalisasi

  return (
    <pre className="font-mono">
      {tokens.map((char, index) => (
        <span key={index}>
          {index === currentIndex && <span className="cursor">|</span>}
          {char}
        </span>
      ))}
    </pre>
  );
}

Sesudah: Normalisasi Deterministik dan Caret Overlay

Pendekatan yang benar menormalisasi data string sebelum pemrosesan, menjaga struktur DOM statis identik antara server dan client, serta menempatkan caret pada layer absolute terpisah.

import React, { useMemo, useRef, useState, useEffect } from "react";

// 1. Normalisasi string kode secara deterministik
function normalizeCode(input) {
  return input
    .replace(/\r\n/g, "\n")
    .replace(/\t/g, "  "); // Konversi tab ke 2 spasi konsisten
}

export function RobustTypingTest({ rawCode, inputIndex = 0 }) {
  const containerRef = useRef(null);
  const [caretPos, setCaretPos] = useState({ top: 0, left: 0 });

  // Normalisasi string deterministik di server dan client
  const code = useMemo(() => normalizeCode(rawCode), [rawCode]);

  // ponytail: parsing karakter per span cukup untuk typing test kecil.
  // Ganti ke chunk-based AST jika memproses file > 5000 baris.
  const characters = useMemo(() => code.split(""), [code]);

  // Hitung posisi caret tanpa memutasi pohon token
  useEffect(() => {
    if (!containerRef.current) return;
    const activeSpan = containerRef.current.querySelector(
      `[data-char-idx="${inputIndex}"]`
    );

    if (activeSpan) {
      setCaretPos({
        top: activeSpan.offsetTop,
        left: activeSpan.offsetLeft,
      });
    }
  }, [inputIndex, code]);

  return (
    <div className="relative font-mono leading-relaxed select-none">
      {/* Caret Overlay Terisolasi */}
      <div
        className="absolute w-[2px] h-5 bg-blue-500 transition-all duration-75 pointer-events-none"
        style={{
          transform: `translate(${caretPos.left}px, ${caretPos.top}px)`,
        }}
        aria-hidden="true"
      />

      {/* Static Pre-rendered Node Tree */}
      <pre
        ref={containerRef}
        className="whitespace-pre m-0 p-4 bg-gray-900 text-gray-100 font-mono"
      >
        {characters.map((char, idx) => (
          <span
            key={idx}
            data-char-idx={idx}
            className={idx < inputIndex ? "text-green-400" : "text-gray-400"}
          >
            {char}
          </span>
        ))}
      </pre>
    </div>
  );
}

Verifikasi: Asersi Integritas Node DOM SSR vs Hydration

Gunakan script pengujian berbasis asersi standar Node.js untuk memvalidasi bahwa output HTML yang dihasilkan oleh server identik secara struktural dengan parsing DOM client tanpa terjadi pergeseran text node.

// verify-hydration.test.mjs
import assert from "node:assert/strict";
import React from "react";
import { renderToString } from "react-dom/server";
import { JSDOM } from "jsdom";
import { RobustTypingTest } from "./RobustTypingTest.js";

// Skenario: Kode sumber dengan whitespace campuran (CRLF & Tab)
const sourceCode = "function test() {\r\n\treturn true;\r\n}";

// 1. Simulasi Server Render (SSR)
const ssrHtml = renderToString(
  React.createElement(RobustTypingTest, { rawCode: sourceCode, inputIndex: 0 })
);

// 2. Simulasi DOM Client Environment
const dom = new JSDOM(ssrHtml);
const { document } = dom.window;

// Ambil node pre dari hasil SSR
const preElement = document.querySelector("pre");
const renderedSpans = preElement.querySelectorAll("span[data-char-idx]");

// 3. Asersi Validasi
// Pastikan tidak ada karakter \r atau \t yang lolos ke text node
renderedSpans.forEach((span) => {
  const text = span.textContent;
  assert.notEqual(text, "\r", "Karakter CR ditemukan dalam DOM tree");
  assert.notEqual(text, "\t", "Karakter Tab ditemukan tanpa normalisasi");
});

// Pastikan jumlah node karakter persis sama dengan panjang string yang dinormalisasi
const expectedLength = sourceCode.replace(/\r\n/g, "\n").replace(/\t/g, "  ").length;
assert.equal(
  renderedSpans.length,
  expectedLength,
  `Jumlah span DOM (${renderedSpans.length}) tidak sesuai ekspektasi (${expectedLength})`
);

console.log("Assertion Passed: Struktur node SSR dan client deterministik 100%.");

Kesimpulan Praktis

Untuk menghilangkan hydration mismatch dan caret drift pada typing test berbasis SSR:

  1. Jalankan normalisasi teks (replace(/\r\n/g, '\n') dan penggantian tab) sebelum rendering dan tokenisasi.
  2. Gunakan white-space: pre untuk menjamin kalkulasi lebar spasi yang konsisten di semua browser.
  3. Pisahkan state interaktif (kursor dan validasi live) dari pohon DOM token statis menggunakan overlay CSS absolut.