Penyebab Hydration Error pada Graph Derivasi Nix

Hydration error pada visualizer dependency graph derivasi Nix (.drv) terjadi ketika DOM tree hasil render SSR di server berbeda dengan hasil initial render React di client browser. Visualizer graph sering kali membaca output dari nix show-derivation atau file closure /nix/store/*.drv.

Perbedaan DOM ini umumnya dipicu oleh tiga faktor teknis:

  • Urutan Hash Key Non-Deterministik: JSON parsing atas atribut inputDrvs menghasilkan urutan key hash derivasi yang tidak bergaransi stabil antar-runtime V8 server dan client jika struktur object tidak diurutkan eksplisit sebelum serialisasi ke node graph.
  • Traversal Asinkron Tak Terkendali: Rekursi asinkron membaca closure graph yang diselesaikan via Promise.all menghasilkan urutan node berbeda tergantung latency I/O atau resolusi task queue.
  • Auto-Generated Node ID: Penggunaan Math.random() atau UUID incrementing runtime saat menghasilkan ID SVG tag (seperti <clipPath>, <g>, atau gradients) pada server dan client.

Analisis Komponen Bermasalah

Berikut adalah contoh implementasi komponen visualizer yang menyebabkan hydration mismatch karena ketiga masalah tersebut:

// components/FlawedGraphVisualizer.tsx
import React, { useEffect, useState } from 'react';

type DerivationNode = { id: string; name: string };

export function FlawedGraphVisualizer({ rawDerivation }: { rawDerivation: any }) {
  // MASALAH 1: Client-only state initialization menyebabkan server me-render fallback,
  // sedangkan client langsung mengeksekusi layouting yang berbeda.
  const [nodes, setNodes] = useState<DerivationNode[]>([]);

  useEffect(() => {
    // MASALAH 2: Object.keys() tanpa sort eksplisit; non-deterministik antar engine
    const parsedNodes = Object.keys(rawDerivation.inputDrvs).map((drvPath) => ({
      // MASALAH 3: Math.random() menghasilkan ID berbeda saat hydration
      id: `node-${Math.random().toString(36).substring(2, 9)}`,
      name: drvPath.replace(/^\/nix\/store\/[a-z0-9]+-/, ''),
    }));
    setNodes(parsedNodes);
  }, [rawDerivation]);

  return (
    <svg width="600" height="400">
      {nodes.map((node) => (
        <g key={node.id} id={node.id}>
          <circle cx={50} cy={50} r={20} fill="#38bdf8" />
          <text x={50} y={80}>{node.name}</text>
        </g>
      ))}
    </svg>
  );
}

Normalisasi Data Graph Derivasi Secara Deterministik

Untuk memastikan rendering identik, parser graph harus mengurutkan hash derivasi menggunakan strict byte-order sorting sebelum struktur data masuk ke rendering tree. Gunakan useId() dari React untuk ID berbasis scoped internal, atau turunkan ID murni dari hash store Nix yang unik dan deterministik.

// lib/nix-parser.ts
export interface NormalizedNode {
  drvPath: string;
  name: string;
  outputs: string[];
}

export function parseAndSortDerivations(inputDrvs: Record<string, { outputs: string[] }>): NormalizedNode[] {
  return Object.keys(inputDrvs)
    // Deterministic sort: urutkan string path store secara leksikografis
    .sort((a, b) => a.localeCompare(b))
    .map((drvPath) => {
      // Ekstrak nama paket setelah 32-karakter hash nix base32/SHA256
      const name = drvPath.replace(/^\/nix\/store\/[a-z0-9]{32}-/, '').replace(/\.drv$/, '');
      const outputs = inputDrvs[drvPath].outputs.slice().sort();

      return {
        drvPath,
        name,
        outputs,
      };
    });
}

Pola Snapshot Isolation dan Deterministic SVG

Komponen di bawah mengonsumsi data yang dinormalisasi langsung pada fase rendering (murni) tanpa modifikasi state asinkron di client. SVG element ID dibuat deterministik menggunakan useId atau hashing dari drvPath.

// components/DeterministicGraphVisualizer.tsx
import React, { useId } from 'react';
import { parseAndSortDerivations } from '../lib/nix-parser';

interface GraphProps {
  inputDrvs: Record<string, { outputs: string[] }>;
}

export function DeterministicGraphVisualizer({ inputDrvs }: GraphProps) {
  const componentScopeId = useId();
  const nodes = parseAndSortDerivations(inputDrvs);

  return (
    <svg width="800" height="500" className="border border-slate-700 bg-slate-900">
      <defs>
        <linearGradient id={`${componentScopeId}-nodeGradient`} x1="0%" y1="0%" x2="100%" y2="100%">
          <stop offset="0%" stopColor="#0284c7" />
          <stop offset="100%" stopColor="#0f172a" />
        </linearGradient>
      </defs>
      <g transform="translate(20, 20)">
        {nodes.map((node, index) => {
          // ID murni berasal dari path deterministik, bukan generator random
          const safeNodeId = `${componentScopeId}-${node.drvPath.replace(/[^a-zA-Z0-9_-]/g, '_')}`;
          const yOffset = index * 45;

          return (
            <g key={node.drvPath} id={safeNodeId} transform={`translate(0, ${yOffset})`}>
              <rect
                width="300"
                height="36"
                rx="6"
                fill={`url(#${componentScopeId}-nodeGradient)`}
                stroke="#38bdf8"
                strokeWidth="1"
              />
              <text x="12" y="22" fill="#f8fafc" fontSize="12" fontFamily="monospace">
                {node.name}
              </text>
            </g>
          );
        })}
      </g>
    </svg>
  );
}

Penggunaan suppressHydrationWarning Secara Terkontrol

Jika visualizer menggunakan dynamic force-directed layout (misalnya via D3.js atau WebCola) yang memerlukan dimensi browser (window.innerWidth) sebelum posisi node (cx, cy) dapat dihitung, isolasi visualizer ke dalam boundary mount client atau gunakan suppressHydrationWarning pada kontainer spesifik.

Gunakan isolasi berbasis state mounting (hasMounted) jika struktur internal graph sepenuhnya bergantung pada API Canvas/SVG browser, atau gunakan suppressHydrationWarning hanya pada atribut elemen text yang formatnya bergantung locale.
// components/ClientGraphContainer.tsx
import React, { useState, useEffect } from 'react';
import { DeterministicGraphVisualizer } from './DeterministicGraphVisualizer';

export function SafeGraphContainer({ inputDrvs }: { inputDrvs: Record<string, { outputs: string[] }> }) {
  const [hasMounted, setHasMounted] = useState(false);

  useEffect(() => {
    // ponytail: simplifikasi render gate, migrasi ke dynamic dynamic(..., { ssr: false }) jika graph bundle terlalu besar
    setHasMounted(true);
  }, []);

  if (!hasMounted) {
    // Render placeholder deterministik dengan dimensi identik saat SSR
    return <div style={{ width: 800, height: 500 }} className="bg-slate-900 animate-pulse" />;
  }

  return <DeterministicGraphVisualizer inputDrvs={inputDrvs} />;
}

Unit Test: Verifikasi Konsistensi SSR vs Client

Pengujian berikut memvalidasi bahwa HTML yang dihasilkan melalui renderToString (server-side) sama persis dengan output mounting browser (DOM traversal), mendeteksi mismatch sedini mungkin dalam pipeline testing.

// test/hydration.test.tsx
import assert from 'node:assert';
import test from 'node:test';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { JSDOM } from 'jsdom';
import { DeterministicGraphVisualizer } from '../components/DeterministicGraphVisualizer';

test('DeterministicGraphVisualizer menghasilkan output SSR dan Client yang identik', () => {
  const mockDrvs = {
    '/nix/store/z1a2b3c4-glibc-2.38.drv': { outputs: ['out', 'dev'] },
    '/nix/store/a1b2c3d4-gcc-13.2.0.drv': { outputs: ['out'] },
  };

  // 1. Render SSR (Server)
  const serverHtml = renderToString(<DeterministicGraphVisualizer inputDrvs={mockDrvs} />);

  // 2. Setup mock client DOM
  const dom = new JSDOM(`<!DOCTYPE html><div id="root">${serverHtml}</div>`);
  const clientRoot = dom.window.document.getElementById('root');
  
  assert.ok(clientRoot, 'Root element harus ada.');

  // 3. Verifikasi urutan node: a1b2c3d4 (gcc) harus muncul sebelum z1a2b3c4 (glibc)
  const textElements = clientRoot.querySelectorAll('text');
  assert.strictEqual(textElements.length, 2);
  assert.strictEqual(textElements[0].textContent, 'gcc-13.2.0');
  assert.strictEqual(textElements[1].textContent, 'glibc-2.38');

  // 4. Verifikasi determinisme atribut visual
  const rectElements = clientRoot.querySelectorAll('rect');
  assert.strictEqual(rectElements[0].getAttribute('stroke'), '#38bdf8');
});