Hydration mismatch pada custom audio-visual (AV) player terjadi ketika tree DOM hasil render server (SSR) berbeda dengan initial render browser. Pada konteks streaming generatif AI (seperti JoyAI-Echo atau voice agent TTS), media direpresentasikan sebagai in-memory Blob atau dynamic stream. Server tidak memiliki akses ke runtime API window.URL.createObjectURL maupun metadata decoder media audio/video, sehingga atribut src, duration, dan visualizer state sering kali memicu React error #418 atau #423.

Akar Masalah Desinkronisasi Media di SSR

Penyebab utama desinkronisasi elemen media HTML5 antara server dan client meliputi:

  • Blob URL Generation: URL.createObjectURL hanya ada pada browser. Jika dievaluasi langsung di body komponen, server menghasilkan string kosong atau fallback, sedangkan browser langsung mengisinya dengan URI skema blob:http://... pada initial render pass.
  • Durasi Media Dinamis: Properti duration pada tag <audio> atau <video> bernilai NaN sebelum browser menyelesaikan decode header media via event loadedmetadata. Merender teks durasi (misal "00:00" vs "03:24") saat initial render merusak kesesuaian tree text node.
  • Media Preload & Cache: Browser dengan agresif mem-populate state media secara asynchronous sebelum hydration React selesai, memicu mutasi DOM di luar kendali reconciler React.

Anti-Pattern: Evaluasi Client State Sebelum Hydration

Kode di bawah menunjukkan kesalahan umum: membaca objek window atau langsung membuat blob URL di render phase.

// KODE SALAH: Memicu Hydration Mismatch
export function AudioPlayerBroken({ audioBlob }: { audioBlob?: Blob }) {
  // Error: SSR tidak memiliki window/URL, client langsung generate string unik
  const blobUrl = typeof window !== 'undefined' && audioBlob 
    ? URL.createObjectURL(audioBlob) 
    : '';

  // Error: window.innerWidth atau state media default client mengacaukan timeline
  const isReady = typeof window !== 'undefined';

  return (
    <div className="player-container">
      <audio src={blobUrl} controls />
      <span>{isReady ? 'Ready' : 'Buffering...'}</span>
    </div>
  );
}

Solusi Deterministik: Isolasi State dan Event-Driven Sync

Solusi deterministik membutuhkan tiga pilar:

  1. Satu Tampilan Server dan Initial Client: SSR dan client render pertama harus menghasilkan struktur HTML yang 100% identik.
  2. Client-Only Gate via useSyncExternalStore: Gunakan snapshot store browser untuk mendeteksi status mounting tanpa re-render cascade ganda.
  3. Metadata Handshake: State durasi dan seekbar hanya boleh diperbarui setelah event loadedmetadata ditembakkan oleh elemen media native.

1. Hook Deteksi Mounting Deterministik

Gunakan useSyncExternalStore bawaan React 18+ untuk membaca status hidrasi secara instan dan aman dari tearing.

// useHydrated.ts
import { useSyncExternalStore } from 'react';

const emptySubscribe = () => () => {};

export function useHydrated(): boolean {
  return useSyncExternalStore(
    emptySubscribe,
    () => true,  // Snapshot di client
    () => false // Snapshot di server (SSR)
  );
}

2. Komponen AV Player Terisolasi Bebas Layout Shift

Komponen di bawah mempertahankan footprint DOM yang konsisten via static placeholder timeline untuk mencegah Cumulative Layout Shift (CLS).

// SafeAVPlayer.tsx
import React, { useRef, useState, useEffect } from 'react';
import { useHydrated } from './useHydrated';

interface SafeAVPlayerProps {
  blobData: Blob | null;
  mimeType?: string;
}

export function SafeAVPlayer({ blobData, mimeType = 'audio/mpeg' }: SafeAVPlayerProps) {
  const isHydrated = useHydrated();
  const audioRef = useRef<HTMLAudioElement | null>(null);
  const [blobUrl, setBlobUrl] = useState<string | null>(null);
  const [duration, setDuration] = useState<number>(0);
  const [isLoaded, setIsLoaded] = useState<boolean>(false);

  // ponytail: manajemen lifecycle URL blob dasar, upgrade ke ring buffer untuk continuous stream
  useEffect(() => {
    if (!isHydrated || !blobData) return;

    const url = URL.createObjectURL(blobData);
    setBlobUrl(url);

    return () => {
      URL.revokeObjectURL(url);
    };
  }, [isHydrated, blobData]);

  const handleLoadedMetadata = () => {
    if (audioRef.current) {
      const audioDuration = audioRef.current.duration;
      if (!Number.isNaN(audioDuration)) {
        setDuration(audioDuration);
        setIsLoaded(true);
      }
    }
  };

  const formatTime = (seconds: number) => {
    if (!isHydrated || !isLoaded || Number.isNaN(seconds) || seconds === 0) {
      return '--:--';
    }
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
  };

  return (
    <div className="av-player-wrapper" style={{ minHeight: '80px' }}>
      {/* Audio tag selalu dirender statis tanpa src saat SSR */}
      <audio
        ref={audioRef}
        src={isHydrated && blobUrl ? blobUrl : undefined}
        preload="metadata"
        onLoadedMetadata={handleLoadedMetadata}
      />

      <div className="timeline-controls">
        <div className="time-display">{formatTime(duration)}</div>
        <div 
          className="timeline-track" 
          role="progressbar" 
          aria-valuenow={isLoaded ? duration : 0}
          style={{ width: '100%', height: '8px', background: '#e2e8f0' }}
        >
          <div 
            className="timeline-fill" 
            style={{ width: isLoaded ? '100%' : '0%', height: '100%', background: '#3b82f6' }} 
          />
        </div>
      </div>
    </div>
  );
}

Unit Test: Verifikasi SSR Markup vs Client Hydration

Gunakan script assert Node.js berikut untuk memastikan bahwa output string SSR identik dengan representasi server snapshot, memvalidasi nihilnya hydration mismatch.

// test-hydration.mjs
import assert from 'node:assert/strict';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { SafeAVPlayer } from './SafeAVPlayer.js';

// 1. Verifikasi render SSR: src kosong, duration placeholder terprediksi
const ssrOutput = renderToString(React.createElement(SafeAVPlayer, { blobData: null }));

assert.ok(
  ssrOutput.includes('<div class="time-display">--:--</div>'),
  'SSR harus menampilkan placeholder durasi statis: --:--'
);

assert.ok(
  !ssrOutput.includes('src='),
  'SSR tidak boleh menyertakan atribut src blob secara prematur'
);

console.log('PASS: SSR markup deterministik dan bebas mismatch.');

Analisis Trade-Off

  • Placeholder vs Layout Shift: Memasang reserved dimensions (seperti minHeight) dan label fallback statis (--:--) menghilangkan Cumulative Layout Shift (CLS) saat durasi riil masuk dari event listener media.
  • Memory Footprint: Pembuatan URL blob via URL.createObjectURL harus selalu dibersihkan di cleanup function useEffect untuk menghindari memory leak pada single-page application.