Pada sistem keamanan siber, analisis payload atau URL sering kali tidak dapat menghasilkan keputusan biner instan (allow/block). Analis keamanan menerapkan tri-state verdict: VERIFIED, MALICIOUS, dan INDETERMINATE (atau Unknown). Kondisi indeterminate merefleksikan prinsip keamanan penting: sistem diizinkan menyatakan "saya belum tahu" ketika pemindaian asinkron sedang berlangsung.

Masalah muncul ketika arsitektur beralih ke Server-Side Rendering (SSR). Server merender HTML dengan status INDETERMINATE. Namun di browser, client-side script mencoba membaca cache lokal (IndexedDB atau Web Storage) atau melakukan kalkulasi optimis sebelum proses hydration selesai. Akibatnya, runtime React atau Vue mendeteksi ketidaksesuaian DOM tree (hydration mismatch), memicu error diffing pada badge UI, layout thrashing, dan tampilan status keamanan yang berkedip.

Akar Masalah: Asimetri Evaluasi Server vs Client

Hydration mismatch terjadi ketika representasi virtual DOM pada initial client render tidak identik dengan HTML hasil kompilasi server. Pada tri-state verdict, asimetri ini umumnya dipicu oleh dua pola anti-pattern:

  • Akses Storage Terlalu Dini: Komponen membaca data cache lokal langsung di root body komponen: const [verdict] = useState(localStorage.getItem(id) || initialVerdict). Saat SSR, localStorage bernilai undefined sehingga server merender fallback INDETERMINATE. Di client, nilai terisi VERIFIED pada render pertama, menghasilkan atribut elemen dan teks yang berbeda.
  • Bailout dengan Client-Only Flag yang Terlambat: Menunda render badge sampai useEffect berjalan tanpa fallback statis menyebabkan pergeseran layout kumulatif (Cumulative Layout Shift/CLS).

Solusi yang benar membutuhkan determinisme: render pertama di client harus selalu mencerminkan state yang sama persis dengan yang dihasilkan server.

Kontrak Payload SSR Deterministik

Langkah pertama adalah menetapkan tipe data eksplisit dan mengunci data SSR dalam format yang dapat diserialisasi tanpa ambiguitas.

// types/verdict.ts
export type SecurityVerdict = 'VERIFIED' | 'MALICIOUS' | 'INDETERMINATE';

export interface VerdictPayload {
  resourceId: string;
  verdict: SecurityVerdict;
  evaluatedAt: string; // ISO 8601 string, hindari instance Date mentah
  scanJobId?: string;
}

Hindari passing nilai dinamis seperti timestamp epoch numerik saat kalkulasi komponen berlangsung di server. Gunakan payload yang telah dipersiapkan backend controller secara statis sebelum proses render HTML dimulai.

Sinkronisasi State Menggunakan useSyncExternalStore

Untuk mengizinkan client membaca cache lokal tanpa merusak initial hydration, manfaatkan API standar React 18+: useSyncExternalStore. Hook ini memisahkan snapshot client dengan snapshot server.

// stores/verdictStore.ts
import { useSyncExternalStore } from 'react';
import { SecurityVerdict } from '../types/verdict';

const memoryCache = new Map<string, SecurityVerdict>();
const listeners = new Set<() => void>();

export const verdictStore = {
  get(resourceId: string): SecurityVerdict | undefined {
    return memoryCache.get(resourceId);
  },
  set(resourceId: string, verdict: SecurityVerdict): void {
    memoryCache.set(resourceId, verdict);
    listeners.forEach((listener) => listener());
  },
  subscribe(listener: () => void): () => void {
    listeners.add(listener);
    return () => listeners.delete(listener);
  }
};

export function useVerdict(resourceId: string, ssrVerdict: SecurityVerdict): SecurityVerdict {
  return useSyncExternalStore(
    verdictStore.subscribe,
    // Client snapshot: gunakan cache lokal jika ada, jika tidak gunakan ssrVerdict
    () => verdictStore.get(resourceId) ?? ssrVerdict,
    // Server snapshot: WAJIB mengembalikan ssrVerdict deterministik
    () => ssrVerdict
  );
}

Implementasi Komponen UI Verdict Badge

Komponen UI badge mengonsumsi hook tersebut. Komponen ini tidak membutuhkan dynamic import dengan ssr: false, sehingga elemen tetap terindeks oleh mesin pencari dan tidak menyebabkan layout jump.

// components/VerdictBadge.tsx
import React from 'react';
import { SecurityVerdict } from '../types/verdict';
import { useVerdict } from '../stores/verdictStore';

interface VerdictBadgeProps {
  resourceId: string;
  initialVerdict: SecurityVerdict;
}

const BADGE_CONFIG: Record<SecurityVerdict, { label: string; cssClass: string }> = {
  VERIFIED: {
    label: 'Verified Safe',
    cssClass: 'badge-verified'
  },
  MALICIOUS: {
    label: 'Malicious Threat',
    cssClass: 'badge-malicious'
  },
  INDETERMINATE: {
    label: 'Analysis Pending',
    cssClass: 'badge-indeterminate'
  }
};

export function VerdictBadge({ resourceId, initialVerdict }: VerdictBadgeProps) {
  const verdict = useVerdict(resourceId, initialVerdict);
  const { label, cssClass } = BADGE_CONFIG[verdict];

  return (
    <span
      data-testid="verdict-badge"
      data-verdict={verdict}
      className={`verdict-badge ${cssClass}`}
    >
      {label}
    </span>
  );
}

Skrip Verifikasi Pengetesan Hydration

Gunakan tes berbasis Node.js untuk memvalidasi bahwa output string dari server identik dengan fase hydration awal di client, sekalipun cache lokal di client sudah memegang status berbeda.

// tests/verdictHydration.test.tsx
import { describe, it, expect } from 'vitest';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { VerdictBadge } from '../components/VerdictBadge';
import { verdictStore } from '../stores/verdictStore';

describe('Tri-State Verdict SSR Hydration Safety', () => {
  it('menjamin kesamaan render server meskipun cache client telah terisi', () => {
    const resourceId = 'domain-scan-abc-123';

    // 1. Simulasi state client yang berbeda (misal hasil fetch background sebelumnya)
    verdictStore.set(resourceId, 'VERIFIED');

    // 2. Server merender status INDETERMINATE dari initial server payload
    const ssrHtml = renderToString(
      <VerdictBadge resourceId={resourceId} initialVerdict="INDETERMINATE" />
    );

    // 3. Validasi DOM string server tidak bocor ke state client
    expect(ssrHtml).toContain('data-verdict="INDETERMINATE"');
    expect(ssrHtml).toContain('Analysis Pending');
    expect(ssrHtml).not.toContain('Verified Safe');
  });
});

Trade-offs dan Catatan Produksi

  • Two-Pass Render vs useSyncExternalStore: Pola lama menggunakan flag const [mounted, setMounted] = useState(false) di useEffect memicu 2 kali render penuh pada semua komponen pohon bawah. useSyncExternalStore menjamin sinkronisasi dilakukan hanya pada komponen penampil badge yang terdaftar di listener.
  • Flicker Handling: Jika transisi dari INDETERMINATE ke VERIFIED terasa mengganggu mata setelah client aktif, gunakan transisi CSS opacity alih-alih me-replace elemen DOM secara langsung.
  • Security Boundary: Jangan pernah mempercayai verdict client untuk otorisasi kritis. Tri-state pada UI hanya representasi visual; server API tetap memvalidasi akses payload secara independen.