Hydration error pada aplikasi Server-Side Rendering (SSR) seperti Next.js terjadi ketika struktur Document Object Model (DOM) hasil render awal di server tidak identik dengan DOM yang dihasilkan oleh render pertama di browser client. Saat membangun antarmuka emulator terminal atau web serial console (misalnya menggunakan Web Serial API dan xterm.js), masalah ini hampir selalu muncul karena ketergantungan langsung pada Web API browser dan manipulasi DOM imperatif.

Penyebab Utama Hydration Mismatch pada Web Serial Console

Tiga faktor utama memicu ketidaksesuaian rendering pada serial emulator:

  • Pemeriksaan navigator.serial saat inisialisasi: Objek navigator.serial hanya ada pada browser berbasis Chromium di sisi client. Evaluasi kondisi ketersediaan API ini langsung di render tree menyebabkan server merender UI fallback (misal: tombol disabled), sementara client langsung merender UI aktif.
  • Inisialisasi state konfigurasi port/baud rate: Membaca port yang tersimpan atau konfigurasi serial dari localStorage langsung pada nilai awal useState menghasilkan DOM yang berbeda antara Node.js runtime dan client.
  • Mounting imperatif canvas/xterm.js buffer: Engine terminal memanipulasi elemen DOM (menambahkan elemen <canvas>, class internal, dan inline styles) segera setelah instansiasi. Jika proses ini terjadi sebelum atau selama proses hidrasi React, pohon virtual DOM React akan bentrok dengan DOM nyata.

Strategi Arsitektur: Shell Statis vs Client Hardware I/O

Solusi arsitektur yang benar adalah memisahkan render shell statis (layout, placeholder terminal, label) dengan siklus hidup hardware I/O dan render terminal. Server hanya bertanggung jawab atas kerangka layout, sedangkan terminal engine dan serial worker dimuat secara dinamis di client.

1. Isolasi Hardware via Dynamic Import (ssr: false)

Pendekatan paling bersih di Next.js adalah menonaktifkan SSR pada komponen terminal engine menggunakan next/dynamic. Langkah ini mencegah Node.js mengeksekusi library yang bergantung pada window atau navigator, sekaligus mencegah hydration mismatch.

// components/serial-console-wrapper.tsx
import dynamic from 'next/dynamic';
import React from 'react';

const TerminalEngine = dynamic(
  () => import('./terminal-engine'),
  {
    ssr: false,
    loading: () => (
      <div className="terminal-skeleton" style={{ height: '400px', backgroundColor: '#000' }}>
        <span style={{ color: '#666', padding: '12px', display: 'block' }}>
          Initializing hardware interface...
        </span>
      </div>
    ),
  }
);

export default function SerialConsoleWrapper() {
  return (
    <div className="console-container">
      <header className="console-header">
        <h3>Serial Monitor Output</h3>
      </header>
      <TerminalEngine />
    </div>
  );
}

2. Implementasi Terminal Client-Side Tanpa Layout Shift

Komponen client menangani deteksi API, instansiasi xterm.js, dan koneksi serial port. Pastikan container DOM untuk terminal memiliki dimensi tetap agar tidak menimbulkan Cumulative Layout Shift (CLS) saat terminal selesai di-mount.

// components/terminal-engine.tsx
'use client';

import React, { useEffect, useRef, useState } from 'react';
import { Terminal } from 'xterm';
import 'xterm/css/xterm.css';

export default function TerminalEngine() {
  const terminalRef = useRef<HTMLDivElement>(null);
  const xtermInstance = useRef<Terminal | null>(null);
  const [isSupported, setIsSupported] = useState<boolean | null>(null);
  const [connected, setConnected] = useState<boolean>(false);
  const portRef = useRef<SerialPort | null>(null);

  // Deteksi fitur browser setelah mount
  useEffect(() => {
    setIsSupported('serial' in navigator);
  }, []);

  // Inisialisasi terminal buffer imperatif
  useEffect(() => {
    if (!terminalRef.current) return;

    const term = new Terminal({
      cursorBlink: true,
      cols: 80,
      rows: 24,
      theme: { background: '#0c0d10', foreground: '#f0f0f0' },
    });

    term.open(terminalRef.current);
    term.writeln('\x1b[33mWeb Serial Console Ready.\x1b[0m');
    xtermInstance.current = term;

    return () => {
      term.dispose();
      xtermInstance.current = null;
    };
  }, []);

  const handleConnect = async () => {
    if (!navigator.serial) return;
    try {
      const port = await navigator.serial.requestPort();
      await port.open({ baudRate: 115200 });
      portRef.current = port;
      setConnected(true);
      xtermInstance.current?.writeln('\x1b[32mConnected at 115200 baud.\x1b[0m');
    } catch (err) {
      xtermInstance.current?.writeln(`\x1b[31mError: ${(err as Error).message}\x1b[0m`);
    }
  };

  return (
    <div className="engine-wrapper">
      <div className="toolbar" style={{ marginBottom: '8px' }}>
        <button 
          onClick={handleConnect} 
          disabled={!isSupported || connected}
        >
          {connected ? 'Connected' : 'Select Serial Port'}
        </button>
        {isSupported === false && (
          <span style={{ color: 'red', marginLeft: '8px' }}>
            Web Serial API not supported in this browser.
          </span>
        )}
      </div>
      {/* Container wajib memiliki styling stabil sebelum xterm menginjeksi canvas */}
      <div 
        ref={terminalRef} 
        style={{ width: '100%', height: '400px', backgroundColor: '#0c0d10' }} 
      />
    </div>
  );
}

Aturan Penanganan State Sinkronisasi

Untuk menghindari mismatch saat mengelola konfigurasi serial (seperti baud rate default atau konfigurasi paritas):

  • Jangan membaca window/localStorage saat deklarasi state: Gunakan pola useState(DEFAULT_VALUE), lalu perbarui nilainya di dalam hook useEffect.
  • Gunakan snapshot hydration aman: Jika nilai client harus digunakan langsung tanpa re-render visual, manfaatkan API React 18 useSyncExternalStore dengan argumen getServerSnapshot yang mengembalikan nilai fallback statis.
  • Hindari suppressHydrationWarning: Atribut ini hanya menyembunyikan log peringatan teks, bukan menyelesaikan konflik hierarki DOM yang disebabkan oleh injeksi elemen canvas emulator.

Catatan Keamanan: Web Serial API memerlukan user activation (klik tombol eksplisit) dan hanya dapat berjalan pada context HTTPS (atau localhost untuk development). Menjalankan inisialisasi koneksi port otomatis di dalam useEffect akan memicu SecurityError.