Pada aplikasi web image generation dan editor seperti Boogu-Image, fitur inpainting membutuhkan canvas interaktif untuk menggambar masking biner sebelum dikirim ke pipeline model inferensi. Masalah umum terjadi saat editor canvas ini dirender menggunakan kerangka kerja Server-Side Rendering (SSR) seperti Next.js atau Nuxt: hydration mismatch dan race condition sinkronisasi buffer mask antara DOM server dan canvas client-side.

Akar Masalah Hydration pada Canvas Masking

Elemen HTML5 <canvas> bersifat imperative dan berbasis pixel buffer pada memori browser. Server tidak memiliki akses ke konteks rendering 2D/WebGL, objek window, maupun pointer events perangkat.

Hydration error dan race condition umumnya dipicu oleh tiga faktor teknis:

  • Non-deterministic Element Attributes: Dimensi canvas (width dan height) yang dihitung berbasis window.innerWidth atau resolusi native gambar menimbulkan perbedaan struktur atribut HTML antara SSR dan client DOM.
  • DOM Tree Mismatch: Pemuatan dynamic layer untuk stroke, path, atau cursor masking memicu modifikasi DOM sebelum proses rekonsiliasi hidrasi kerangka kerja selesai.
  • State vs Buffer Disconnect: Server hanya dapat merender representasi metadata (misal: JSON path atau koordinat), sedangkan canvas membutuhkan decoding base64 atau byte stream Uint8ClampedArray untuk merestorasi ImageData.

Strategi Arsitektur: Isolasi Two-Pass Rendering

Solusi yang benar untuk mencegah mismatch hidrasi adalah memisahkan siklus rendering ke dalam dua fase:

  1. Pass 1 (SSR & Initial Mount): Render representasi statis atau placeholder kontainer dengan dimensi aspek rasio terprediksi tanpa menginisialisasi konteks 2D canvas.
  2. Pass 2 (Client Hydrated): Pasang elemen <canvas>, pasang event listener interaksi pointer, dan sinkronkan buffer masking dari cache/state.

Implementasi Kode: Komponen Masking Canvas

Contoh implementasi React/Next.js berikut menunjukkan isolasi hidrasi menggunakan guard useEffect, fallback kontainer terukur, dan sinkronisasi mask buffer aman tanpa race condition.

import React, { useEffect, useRef, useState, useCallback } from 'react';

interface MaskCanvasProps {
  baseImageUrl: string;
  maskDataUrl?: string;
  width: number;
  height: number;
  onMaskChange: (maskBase64: string) => void;
}

export const MaskCanvasEditor: React.FC<MaskCanvasProps> = ({
  baseImageUrl,
  maskDataUrl,
  width,
  height,
  onMaskChange,
}) => {
  const [isMounted, setIsMounted] = useState(false);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const isDrawing = useRef(false);

  // Pass 1: Pastikan canvas imperative hanya aktif di client setelah hidrasi selesai
  useEffect(() => {
    setIsMounted(true);
  }, []);

  // Pass 2: Sinkronisasi buffer masking setelah mount
  const syncMaskBuffer = useCallback((targetCanvas: HTMLCanvasElement, sourceUrl?: string) => {
    const ctx = targetCanvas.getContext('2d');
    if (!ctx) return;

    if (!sourceUrl) {
      ctx.fillStyle = 'black';
      ctx.fillRect(0, 0, targetCanvas.width, targetCanvas.height);
      return;
    }

    const img = new Image();
    img.crossOrigin = 'anonymous';
    img.onload = () => {
      ctx.drawImage(img, 0, 0, targetCanvas.width, targetCanvas.height);
    };
    img.src = sourceUrl;
  }, []);

  useEffect(() => {
    if (isMounted && canvasRef.current) {
      syncMaskBuffer(canvasRef.current, maskDataUrl);
    }
  }, [isMounted, maskDataUrl, syncMaskBuffer]);

  const startDrawing = (e: React.PointerEvent<HTMLCanvasElement>) => {
    isDrawing.current = true;
    draw(e);
  };

  const stopDrawing = () => {
    if (!isDrawing.current) return;
    isDrawing.current = false;
    if (canvasRef.current) {
      onMaskChange(canvasRef.current.toDataURL('image/png'));
    }
  };

  const draw = (e: React.PointerEvent<HTMLCanvasElement>) => {
    if (!isDrawing.current || !canvasRef.current) return;
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    const rect = canvas.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    ctx.fillStyle = 'white'; // Mask putih untuk area inpainting
    ctx.beginPath();
    ctx.arc(x, y, 16, 0, Math.PI * 2);
    ctx.fill();
  };

  // SSR Fallback Placeholder: Menjaga stabilitas layout shifts (CLS) tanpa canvas mismatch
  if (!isMounted) {
    return (
      <div
        style={{ width, height, backgroundColor: '#111' }}
        aria-label="Memuat editor mask..."
        className="canvas-placeholder"
      />
    );
  }

  return (
    <div style={{ position: 'relative', width, height }}>
      <img
        src={baseImageUrl}
        alt="Base Reference"
        width={width}
        height={height}
        style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}
      />
      <canvas
        ref={canvasRef}
        width={width}
        height={height}
        onPointerDown={startDrawing}
        onPointerUp={stopDrawing}
        onPointerLeave={stopDrawing}
        onPointerMove={draw}
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          opacity: 0.6,
          cursor: 'crosshair',
          touchAction: 'none',
        }}
      />
    </div>
  );
};

Menangani Race Condition pada Dynamic Import

Jika menggunakan lazy loading (misal: next/dynamic dengan { ssr: false }), perhatikan siklus loading gambar referensi versus loading mask editor. Race condition terjadi saat:

  • Script modul canvas selesai diunduh lebih lambat daripada respons API data masking.
  • State masking terupdate ketika elemen canvas DOM belum selesai dikaitkan ke referensi internal (ref.current === null).

Solusi: Pisahkan state serialisasi (koordinat vektor atau array aksi stroke) dari layer visual. Simpan data mask sebagai state primitif di React, dan gunakan ResizeObserver atau callback refs untuk memicu transfer buffer hanya saat target node valid.

Debugging Tips

  • Cek Mutasi Atribut: Buka console browser. Hydration mismatch pada atribut style atau dimensi numerik sering terjadi akibat konversi desimal piksel dari server ke client. Pastikan dimensi width dan height di-cast menggunakan integer baku (Math.floor).
  • Hindari CSS Media Queries untuk Ukuran Canvas: Mengubah resolusi canvas via CSS memicu peregangan bitmap. Atur properti atribut width dan height elemen canvas secara eksplisit sesuai rasio aspek target inferensi AI (misal: 512x512, 1024x1024).