Implementasi UI infinite canvas atau interactive world (seperti pada repositori grafis interaktif dan simulasi berbasis node) di framework SSR seperti Next.js sering menghadapi kendala hydration mismatch. Kesalahan ini terjadi ketika pohon DOM yang dihasilkan server berbeda dengan pohon DOM pada initial client render, memicu fallback render ulang seluruh subtree dan merusak state interaktif.

Akar Masalah Hydration Mismatch pada Infinite Canvas

Infinite canvas bergantung pada parameter lingkungan browser yang tidak tersedia di server runtime (Node.js/Edge). Tiga penyebab utama error ini meliputi:

  • Dynamic Viewport & Window Dimensions: Penggunaan window.innerWidth, rasio piksel layar (window.devicePixelRatio), atau posisi kursor untuk menentukan transform matriks awal (pan/zoom). Di server, nilai ini tidak ada (sering kali di-mock sebagai 0 atau nilai default statis), sedangkan di client nilainya mengikuti dimensi viewport pengguna.
  • Procedural Generation Non-deterministik: Penempatan node, chunk canvas, atau grid latar belakang yang menggunakan generator angka acak bawaan seperti Math.random() atau timestamp Date.now(). Nilai acak yang dihitung di server dipastikan berbeda saat dieksekusi ulang di client.
  • Immediate DOM Mutation via ResizeObserver: Observer yang langsung dipasang dan mengubah state React secara sinkron sebelum proses hidrasi awal selesai, memicu pembaruan layout sebelum rekonsiliasi DOM tuntas.

Solusi 1: Isolasi Komponen Canvas via Dynamic Import (Client-Only)

Pendekatan paling bersih untuk canvas controller yang sangat bergantung pada WebGL, Canvas API, atau manipulasi Web API intensif adalah menonaktifkan SSR pada layer interaktif tersebut.

// components/CanvasWrapper.tsx
import dynamic from 'next/dynamic';

// Lazy-load komponen controller tanpa SSR
const InfiniteCanvasController = dynamic(
  () => import('./InfiniteCanvasController'),
  {
    ssr: false,
    loading: () => <div className="canvas-skeleton bg-neutral-900 w-full h-screen" />
  }
);

export default function CanvasPage() {
  return (
    <main className="w-screen h-screen overflow-hidden">
      <InfiniteCanvasController />
    </main>
  );
}

Solusi 2: Two-Pass Rendering Pattern

Jika struktur dasar container canvas harus tetap di-render di server untuk optimasi SEO atau layout stability, terapkan pola two-pass rendering menggunakan sinkronisasi mount.

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

export function useIsMounted() {
  const [isMounted, setIsMounted] = useState(false);

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

  return isMounted;
}

Gunakan hook tersebut untuk menjaga konsistensi render pass pertama:

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

import { useIsMounted } from '../hooks/useIsMounted';
import { CanvasViewport } from './CanvasViewport';
import { StaticGridPlaceholder } from './StaticGridPlaceholder';

export function InfiniteCanvas() {
  const isMounted = useIsMounted();

  // First pass (Server & Client initial tick): render placeholder yang identik
  if (!isMounted) {
    return <StaticGridPlaceholder width={1920} height={1080} />;
  }

  // Second pass (Client post-mount): render controller dengan Web API aktual
  return <CanvasViewport />;
}

Solusi 3: Deterministic State Seed via Props

Elemen kanvas yang di-generate secara prosedural (seperti world chunk, penempatan obstacle, atau tile grid) harus menggunakan seed generator deterministik yang dieksekusi di server dan dikirimkan ke client sebagai initial state.

// lib/prng.ts
// Linear Congruential Generator sederhana untuk procedural rendering deterministik
export function createPRNG(seed: number) {
  let current = seed;
  return function next() {
    current = (current * 1664525 + 1013904223) % 4294967296;
    return current / 4294967296;
  };
}
// components/ProceduralGrid.tsx
interface GridProps {
  seed: number;
  chunkCount: number;
}

export function ProceduralGrid({ seed, chunkCount }: GridProps) {
  const prng = createPRNG(seed);
  
  // Urutan koordinat identik antara server render dan client render
  const nodes = Array.from({ length: chunkCount }, (_, id) => ({
    id,
    x: Math.floor(prng() * 2000) - 1000,
    y: Math.floor(prng() * 2000) - 1000,
  }));

  return (
    <g className="procedural-layer">
      {nodes.map(node => (
        <circle key={node.id} cx={node.x} cy={node.y} r={4} fill="#64748b" />
      ))}
    </g>
  );
}

Solusi 4: Integrasi ResizeObserver yang Aman dari Hydration Crash

Memanggil update state dimensi kanvas secara instan pada saat komponen dimuat dapat memicu race condition dengan proses hidrasi. Hubungkan ResizeObserver hanya di dalam lifecycle useEffect dan tangani mutasi dimensi secara asinkron atau terisolasi pada client ref.

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

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

export function ResponsiveCanvasWrapper({ children }: { children: React.ReactNode }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);

  useEffect(() => {
    const node = containerRef.current;
    if (!node) return;

    const observer = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const { width, height } = entry.contentRect;
        setDimensions({ width, height });
      }
    });

    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  return (
    <div ref={containerRef} className="w-full h-full relative overflow-hidden">
      {dimensions ? (
        <div style={{ width: dimensions.width, height: dimensions.height }}>
          {children}
        </div>
      ) : (
        <div className="fallback-canvas-frame w-full h-full" />
      )}
    </div>
  );
}

Perbandingan Pendekatan

  • Dynamic Import (No SSR): Gunakan jika canvas murni komponen interaktif berbasis imperative API (Canvas2D/WebGL/Three.js/Pixi.js) yang tidak memerlukan indexing mesin pencari. Menghilangkan hydration mismatch sepenuhnya tanpa boilerplate tambahan.
  • Two-Pass Render + PRNG: Gunakan jika node kanvas berbasis SVG/HTML DOM yang membutuhkan pre-rendering awal untuk stabilitas visual (Cumulative Layout Shift) atau SEO indexing.