Menampilkan hasil pemindaian dependensi dari uv audit pada antarmuka Server-Side Rendering (SSR) sering memicu hydration mismatch. Masalah ini terjadi ketika pohon DOM yang dihasilkan server (HTML statis) berbeda dari pohon virtual DOM yang dihitung pertama kali oleh React di sisi klien.

Pada dashboard keamanan, perbedaan tersebut umumnya dipicu oleh tiga faktor: formatting timestamp advisory CVE yang bergantung pada zona waktu mesin, urutan array paket rentan yang non-deterministik, dan status dinamis pengecekan malware yang dimuat secara asinkron. Artikel ini membahas langkah mitigasi konkret untuk memastikan rendering deterministik dari hulu ke hilir.

Akar Masalah Hydration Mismatch pada uv Audit

Perintah uv audit --format json menghasilkan daftar kerentanan paket Python secara cepat. Namun, mengonsumsi data mentah tersebut secara langsung ke dalam komponen SSR (seperti Next.js App Router atau Remix) rentan menimbulkan error:

  • Timezone Drift: Server berjalan di UTC, sedangkan browser klien berada di zona lokal (misal: UTC+7 / WIB). Pemanggilan fungsi seperti new Date(advisory.published).toLocaleString() secara langsung pada JSX akan merender teks berbeda antara server dan klien.
  • Ketidakpastian Urutan Koleksi (Array Order): Jika parser backend tidak menjamin urutan deterministik saat memproses dependensi, urutan elemen <tr> atau <div> CVE dapat tertukar saat hidrasi.
  • Flicker State Real-time: Status analisis tambahan (seperti malware scanning realtime via socket atau streaming) yang belum selesai di server sering kali dipaksa merender status loading yang tidak sinkron saat hidrasi browser dimulai.

1. Normalisasi Payload dan Sorting Deterministik di Server Loader

Langkah preventif paling efektif adalah menormalisasi struktur data uv audit sebelum data dikirimkan ke layer presentasi klien. Lakukan transformasi pada data loader backend atau server component.

// types/security.ts
export interface NormalizedVulnerability {
  id: string;
  packageName: string;
  installedVersion: string;
  advisoryId: string;
  severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
  publishedAtIso: string;
}

// lib/uv-audit-normalizer.ts
export function normalizeUvAuditOutput(rawOutput: any[]): NormalizedVulnerability[] {
  const normalized: NormalizedVulnerability[] = [];

  for (const pkg of rawOutput) {
    for (const advisory of pkg.advisories || []) {
      normalized.push({
        id: `${pkg.package.name}@${pkg.package.version}-${advisory.id}`,
        packageName: pkg.package.name,
        installedVersion: pkg.package.version,
        advisoryId: advisory.id,
        severity: advisory.severity || 'MEDIUM',
        publishedAtIso: new Date(advisory.published || Date.now()).toISOString(),
      });
    }
  }

  // Deterministik sort: urutkan berdasarkan severity score, package name, lalu advisory ID
  const severityWeight: Record<string, number> = {
    CRITICAL: 4,
    HIGH: 3,
    MEDIUM: 2,
    LOW: 1,
  };

  return normalized.sort((a, b) => {
    const weightDiff = severityWeight[b.severity] - severityWeight[a.severity];
    if (weightDiff !== 0) return weightDiff;
    
    const nameDiff = a.packageName.localeCompare(b.packageName);
    if (nameDiff !== 0) return nameDiff;
    
    return a.advisoryId.localeCompare(b.advisoryId);
  });
}

Normalisasi ini menjamin dua hal: payload selalu memiliki format tanggal ISO 8601 yang stabil dan urutan array selalu identik tanpa bergantung pada implementasi hash-map mesin runtime.

2. Isolasi Rendering Timestamp Advisory

Untuk menghindari kesalahan sinkronisasi tanggal antara UTC server dan waktu lokal pengguna, hindari eksekusi toLocaleString() langsung di root template SSR.

Gunakan elemen semantik <time> dengan atribut dateTime statis berbasis ISO string, lalu tunda konversi waktu lokal ke tahap pasca-hidrasi menggunakan two-pass rendering terisolasi.

'use client';

import { useState, useEffect } from 'react';

interface AdvisoryDateProps {
  isoDate: string;
}

export function AdvisoryDate({ isoDate }: AdvisoryDateProps) {
  const [formatted, setFormatted] = useState<string>(isoDate);
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
    setFormatted(new Date(isoDate).toLocaleDateString(undefined, {
      year: 'numeric',
      month: 'short',
      day: '2-digit',
    }));
  }, [isoDate]);

  return (
    <time dateTime={isoDate} title={isoDate}>
      {isMounted ? formatted : isoDate}
    </time>
  );
}

Alternatif tanpa JavaScript klien: render waktu UTC secara konsisten di server menggunakan format baku (misalnya YYYY-MM-DD UTC). Pendekatan ini menghilangkan kebutuhan hidrasi dinamis sepenuhnya untuk data temporal.

3. Boundary Render Dua Tahap untuk Status Realtime Malware Check

Jika dashboard mengintegrasikan streaming status malware check dari external threat database, perbedaan initial state antara SSR (yang merender status idle/checking) dan klien (yang mungkin sudah menerima state via cache/indexedDB) akan mematahkan hidrasi.

Isolasi komponen badge tersebut menggunakan hydration boundary berbasis hook:

'use client';

import { useState, useEffect, ReactNode } from 'react';

interface ClientOnlyProps {
  children: ReactNode;
  fallback: ReactNode;
}

export function ClientOnly({ children, fallback }: ClientOnlyProps) {
  const [hasMounted, setHasMounted] = useState(false);

  useEffect(() => {
    setHasMounted(true);
  }, []);

  if (!hasMounted) {
    return <>{fallback}</>;
  }

  return <>{children}</>;
}

Terapkan pada tabel kerentanan dashboard:

// components/AuditRow.tsx
import { NormalizedVulnerability } from '@/types/security';
import { AdvisoryDate } from './AdvisoryDate';
import { ClientOnly } from './ClientOnly';
import { MalwareStatusBadge } from './MalwareStatusBadge';

export function AuditRow({ item }: { item: NormalizedVulnerability }) {
  return (
    <tr key={item.id} className="border-b">
      <td className="p-2 font-mono">{item.packageName}</td>
      <td className="p-2">{item.installedVersion}</td>
      <td className="p-2 font-bold">{item.advisoryId}</td>
      <td className="p-2">
        <span className={`badge badge-${item.severity.toLowerCase()}`}>
          {item.severity}
        </span>
      </td>
      <td className="p-2">
        <AdvisoryDate isoDate={item.publishedAtIso} />
      </td>
      <td className="p-2">
        <ClientOnly fallback={<span className="text-gray-400">Menunggu analisis...</span>}>
          <MalwareStatusBadge packageName={item.packageName} />
        </ClientOnly>
      </td>
    </tr>
  );
}

Trade-off dan Karakteristik Performa

Pola two-pass render menjamin tidak adanya hydration mismatch error di console log, namun memiliki trade-off kecil berupa layout shift jika dimensi fallback badge tidak identik dengan dimensi elemen akhir. Untuk meminimalisasi cumulative layout shift (CLS), berikan dimensi minimum atau skeleton loading dengan tinggi dan lebar tetap pada elemen fallback.

Langkah Pengujian

Jalankan verifikasi hidrasi secara lokal dengan mengubah zona waktu runtime Node.js dan browser untuk menguji ketahanan parsing tanggal:

  1. Jalankan server SSR menggunakan timezone UTC: TZ=UTC npm run start.
  2. Buka aplikasi menggunakan browser dengan timezone lokal yang berbeda (misal Asia/Jakarta).
  3. Periksa tab Console di browser Developer Tools; pastikan tidak ada peringatan bertuliskan "Text content did not match. Server: ... Client: ...".