Penggunaan fitur <Teleport> (Vue 3) atau createPortal (React) pada aplikasi berbasis Inertia.js dengan Server-Side Rendering (SSR) sering menimbulkan dua masalah struktural: Hydration Mismatch pada initial load dan DOM Ghosting (orphaned nodes) saat navigasi antarhalaman. Masalah ini berakar pada inkonsistensi rendering node di luar kontainer root aplikasi antara server runtime dan browser DOM.

Akar Masalah Teknis

Saat Inertia SSR mengeksekusi aplikasi di server via Node.js, engine SSR (seperti @vue/server-renderer) merender representasi string dari pohon komponen yang dibatasi pada target root aplikasi (umumnya elemen #app). Masalah muncul ketika komponen mencoba memindahkan children ke elemen global seperti document.body.

1. Hydration Mismatch

Server merender markup komponen teleportasi ke buffer terpisah atau mengabaikan target luar jika target DOM belum terbentuk di lingkungan server. Ketika browser menerima HTML statis dan memulai proses hidrasi, virtual DOM client mendeteksi ketidakcocokan antara snapshot HTML server dengan pohon VNode lokal. Browser kemudian memunculkan peringatan hidrasi:

[Vue warn]: Hydration node mismatch:
- Target container: <div id="app">...</div>
- Expected server HTML to match client rendered tree.

2. DOM Ghosting Saat Navigasi Client-Side

Inertia.js menangani perpindahan halaman via AJAX tanpa full page reload. Jika komponen halaman menggunakan Teleport langsung ke document.body, node tersebut disuntikkan di luar hierarki VDOM root yang dipantau oleh Inertia. Ketika pengguna berpindah rute, teardown lifecycle komponen sering kali gagal membersihkan elemen yang dipindahkan secara bersih sebelum rute baru di-mount. Akibatnya, elemen sisa (seperti overlay modal atau backdrop dialog) tertinggal di DOM (orphaned nodes) dan menumpuk secara visual.

Solusi 1: Pola Client-Only Conditional Mounting

Pendekatan paling deterministik untuk mencegah hydration mismatch adalah menunda perenderan Teleport hingga proses hidrasi client selesai sepenuhnya. Pola ini memastikan server tidak pernah mengeksekusi instruksi Teleport ke target eksternal.

Implementasi di Vue 3

Buat komponen wrapper ClientOnly.vue sederhana:

<script setup>
import { ref, onMounted } from 'vue';

const isMounted = ref(false);

onMounted(() => {
  isMounted.value = true;
});
</script>

<template>
  <slot v-if="isMounted" />
</template>

Gunakan wrapper ini untuk membungkus komponen modal atau tooltip yang menggunakan <Teleport>:

<script setup>
import ClientOnly from '@/Components/ClientOnly.vue';
import ModalContent from '@/Components/ModalContent.vue';

defineProps({ show: Boolean });
</script>

<template>
  <ClientOnly>
    <Teleport to="body" v-if="show">
      <ModalContent @close="$emit('close')" />
    </Teleport>
  </ClientOnly>
</template>

Implementasi di React (Inertia React)

Pada React, hindari eksekusi createPortal langsung sebelum komponen menyelesaikan siklus mount awal:

import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';

export default function SafePortal({ children, targetId = 'portal-root' }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
    return () => setMounted(false);
  }, []);

  if (!mounted) return null;

  const target = document.getElementById(targetId) || document.body;
  return createPortal(children, target);
}

Solusi 2: Kontainer Target Terisolasi di Dalam Layout

Menjadikan document.body langsung sebagai target teleportasi memperbesar risiko benturan DOM dengan library pihak ketiga atau script analitik. Pendekatan yang lebih aman adalah mendefinisikan kontainer terisolasi di dalam template utama aplikasi (misalnya di app.blade.php pada Laravel):

<!-- resources/views/app.blade.php -->
<body>
    @inertia
    <div id="teleport-target"></div>
</body>

Dengan mengarahkan to="#teleport-target", pembersihan manual dapat diotomatisasi jika terjadi unmount yang tidak bersih saat navigasi halaman.

Pembersihan Manual pada Event Navigasi Inertia

Untuk mengeliminasi risiko DOM ghosting yang disebabkan oleh timing unmount yang tertunda (misalnya akibat animasi keluar atau page transition), dengarkan event lifecycle dari router Inertia:

import { router } from '@inertiajs/vue3';

router.on('navigate', () => {
  const portalContainer = document.getElementById('teleport-target');
  if (portalContainer) {
    // Bersihkan sisa node orphaned yang tertinggal
    while (portalContainer.firstChild) {
      portalContainer.removeChild(portalContainer.firstChild);
    }
  }
});

Trade-off dan Analisis Performa

  • Cumulative Layout Shift (CLS): Penggunaan ClientOnly menunda perenderan elemen hingga hidrasi selesai. Jangan gunakan pola ini untuk konten struktural utama di atas lipatan layar (above-the-fold) karena akan memicu pergeseran tata letak. Batasi hanya untuk dialog, modal, drawer, dan dropdown.
  • SEO: Elemen di dalam ClientOnly tidak akan masuk ke dalam output HTML SSR. Komponen overlay interaktif umumnya tidak memerlukan indeksasi mesin pencari, sehingga trade-off ini dapat diterima demi kestabilan hidrasi.
  • Bundle Size: Memanfaatkan hook sederhana (onMounted / useEffect) jauh lebih efisien dibanding memasang library pihak ketiga khusus portal.

Verifikasi Debugging

Untuk memverifikasi bahwa solusi bekerja tanpa regresi:

  1. Jalankan build SSR (npm run build && php artisan inertia:start-ssr).
  2. Buka halaman pada browser dengan DevTools Console aktif. Pastikan tidak ada peringatan bertuliskan "Hydration completed but contains mismatches".
  3. Buka modal/dialog, lalu klik tautan navigasi Inertia tanpa menutup modal terlebih dahulu.
  4. Periksa tab Elements pada DevTools untuk memastikan elemen modal di dalam target teleportasi terhapus sepenuhnya dari DOM tanpa meninggalkan orphaned node.