Mengintegrasikan sistem verifikasi usia berbasis Zero-Knowledge Proof (ZKP) pada framework Server-Side Rendering (SSR) seperti Next.js sering memicu hydration mismatch error (React Error #418/#425). Masalah ini timbul karena status verifikasi identitas pengguna bersumber dari state lokal browser, sedangkan server menghasilkan markup statis tanpa akses terhadap runtime klien.

Akar Masalah: Asimetri Status Server vs Klien

Komputasi dan penyimpanan bukti ZKP (misalnya zkWASM, snarkjs, atau semaphore proof) bersifat client-centric karena alasan privasi. Kredensial privat dan witness disimpan di IndexedDB atau hardware-backed storage lokal (seperti WebAuthn atau local secure storage), bukan di server cookie.

Ketika SSR dijalankan:

  • Sisi Server (Node.js/Edge): Mesin SSR tidak memiliki akses ke window, indexedDB, atau WebAssembly proviers lokal. Server merender status default, biasanya berupa formulir pemblokir (locked gate) atau state kosong.
  • Sisi Klien (Hydration Pass Pertama): Browser menerima HTML statis dari server, lalu memicu eksekusi JavaScript. Jika komponen langsung membaca status ZKP lokal saat kompilasi render pertama, Virtual DOM klien akan menghasilkan representasi konten dewasa yang terbuka (unlocked), sedangkan server mengirim status terkunci.

Ketidaksinkronan pohon DOM ini memicu pembatalan hidrasi parsial, membuang tree yang sudah di-SSR, memicu Flash of Unverified Content (FOUC), dan menimbulkan ancaman kebocoran data sensitif sebelum verifikasi kriptografi selesai dievaluasi.

Solusi Arsitektur: Two-Pass Rendering Deterministik

Untuk menghindari mismatch tanpa mengorbankan performa SSR, gunakan pola Two-Pass Rendering yang dipadukan dengan isolasi gerbang (Gate-Lock Pattern). Sisi server dan klien pada render pertama harus sepakat merender representasi yang identik secara struktural: fallback skeleton tertutup.

1. Isolasi Logika Proof Menggunakan Custom Hook

Pisahkan logika verifikasi ZKP ke dalam lifecycle yang hanya dieksekusi setelah hidrasi selesai (pasca-mount):

// hooks/useZkpAgeGate.ts
import { useState, useEffect } from 'react';

type ZkpStatus = 'HYDRATING' | 'UNVERIFIED' | 'VERIFYING' | 'VERIFIED' | 'FAILED';

export function useZkpAgeGate(requiredAge: number = 18) {
  const [status, setStatus] = useState<ZkpStatus>('HYDRATING');

  useEffect(() => {
    let isMounted = true;

    async function verifyStoredProof() {
      setStatus('VERIFYING');
      try {
        // ponytail: fallback dummy resolver; ganti dengan verifier WebAssembly aktual (misal: SnarkJS / Halo2)
        const storedProof = await window.indexedDB.databases();
        const hasValidToken = localStorage.getItem('zkp_age_proof_token');

        if (!hasValidToken) {
          if (isMounted) setStatus('UNVERIFIED');
          return;
        }

        // Asumsi validasi zk-proof off-chain di klien
        const isValid = await mockZkpProofVerification(hasValidToken, requiredAge);
        if (isMounted) setStatus(isValid ? 'VERIFIED' : 'FAILED');
      } catch (err) {
        if (isMounted) setStatus('FAILED');
      }
    }

    verifyStoredProof();
    return () => { isMounted = false; };
  }, [requiredAge]);

  return status;
}

async function mockZkpProofVerification(token: string, age: number): Promise<boolean> {
  // Verifikasi proof kriptografi lokal
  return Boolean(token && age >= 18);
}
Skipped: WebAssembly worker initialization, add when handling high-constraint groth16 proofs.

2. Komponen Age Gate-Lock

Komponen wrapper memastikan server tidak pernah membocorkan children yang dilindungi. Skeleton fallback diberikan dimensi eksplisit guna meniadakan Cumulative Layout Shift (CLS).

// components/ZkpAgeLock.tsx
'use client';

import React from 'react';
import { useZkpAgeGate } from '../hooks/useZkpAgeGate';

interface Props {
  children: React.ReactNode;
  fallbackGate?: React.ReactNode;
}

export function ZkpAgeLock({ children, fallbackGate }: Props) {
  const status = useZkpAgeGate(18);

  // Deterministic pass: Server dan Initial Client Render selalu menghasilkan fallback ini
  if (status === 'HYDRATING' || status === 'VERIFYING') {
    return (
      <div className="zkp-gate-container min-h-[400px] flex items-center justify-center bg-neutral-900 text-white">
        <div className="animate-pulse flex flex-col items-center gap-3">
          <div className="h-8 w-8 border-2 border-t-transparent border-white rounded-full animate-spin" />
          <p className="text-sm text-neutral-400">Memvalidasi Kredensial Nol-Pengetahuan...</p>
        </div>
      </div>
    );
  }

  if (status === 'VERIFIED') {
    return <>{children}</>;
  }

  return fallbackGate ? <>{fallbackGate}</> : (
    <div className="zkp-gate-locked min-h-[400px] flex flex-col items-center justify-center bg-neutral-950 p-6 rounded-lg text-center">
      <h3 className="text-lg font-bold text-red-500">Akses Dibatasi Usia (18+)</h3>
      <p className="text-neutral-300 text-sm mt-2">
        Konten ini memerlukan bukti usia kriptografis tanpa mengekspos identitas Anda.
      </p>
      <button 
        onClick={() => window.location.href = '/zkp-verify'}
        className="mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm font-medium transition-colors"
      >
        Generate Bukti ZKP
      </button>
    </div>
  );
}

Mitigasi FOUC dan Kebocoran State

Terdapat risiko celah keamanan antarmuka ketika menggunakan render sisi klien untuk konten yang dibatasi usia:

  1. CSS Obfuscation vs Structural Demount: Jangan gunakan display: none pada konten terproteksi untuk menyembunyikannya selama fase verifikasi. Data HTML sensitif yang dikirim lewat stream SSR tetap dapat dibaca melalui View Source. Konten sensitif harus ditahan di server dan hanya diambil secara dinamis atau dibungkus murni dalam komponen yang dirender kondisional via lazy import.
  2. Layout Stabilization: Tentukan min-height atau aspect-ratio tetap pada kontainer skeleton fallback. Jika konten anak memiliki tinggi rata-rata 600px, tetapkan batas minimum kontainer pembungkus agar transisi dari status VERIFYING ke VERIFIED tidak mendorong elemen di bawahnya secara mendadak (CLS < 0.1).

Validasi dan Pengujian DOM Konsistensi

Gunakan pengujian berikut untuk memastikan status hidrasi tidak pecah:

  • React Strict Mode: Pastikan hooks tidak memicu efek samping ganda yang mengubah DOM secara prematur selama re-mount pengujian di lingkungan lokal.
  • Bypass JS Testing: Matikan JavaScript di peramban. Halaman harus tetap menampilkan fallback skeleton atau fallback terkunci tanpa ada kebocoran dari elemen children.
  • Console Monitoring: Periksa console dari pesan peringatan: "Warning: Text content did not match. Server: ... Client: ...". Jika pesan ini muncul, pastikan kembali bahwa tidak ada pembacaan localStorage atau window di luar blok useEffect atau conditional check saat fase evaluasi pertama komponen.