Akar Masalah: User-Agent Sniffing vs Viewport Client
Pola umum rendering pada backend Go Fiber sering kali memeriksa header User-Agent secara langsung untuk menentukan apakah pengguna mengakses via perangkat mobile atau desktop:
// Anti-pattern: Branching struktur DOM berdasarkan User-Agent
app.Get("/", func(c *fiber.Ctx) error {
ua := c.Get("User-Agent")
isMobile := strings.Contains(strings.ToLower(ua), "mobile")
return c.Render("index", fiber.Map{
"IsMobile": isMobile,
})
})Jika variabel IsMobile digunakan untuk merender struktur HTML yang berbeda di server (misalnya menyembunyikan navigasi desktop atau mengubah layout grid), frontend framework seperti React, Vue, atau Svelte akan mengalami hydration mismatch saat proses mounting di browser.
Penyebab teknis utamanya meliputi:
- Header HTTP tidak mewakili ukuran viewport: Tablet layar besar atau desktop browser dengan window kecil tetap mengirimkan User-Agent desktop/mobile yang tidak sinkron dengan kondisi CSS breakpoint atau
window.innerWidth. - Perbedaan DOM Tree: Server menghasilkan HTML dengan node tertentu, namun hydration engine client mengeksekusi branch logika yang berbeda berdasarkan
window.matchMedia, memicu perbedaan VDOM tree dan Real DOM tree. - Interferensi Proxy/CDN Cache: Respons HTML di-cache oleh CDN untuk perangkat mobile dan disajikan ke desktop user jika header
Varytidak dikonfigurasi secara presisi.
Manifestasi Error Hydration di Browser
Pada React, kondisi ini memicu runtime error pada konsol developer:
Uncaught Error: Hydration failed because the initial UI does not match what was rendered on the server.
Warning: Expected server HTML to contain a matching <aside> in <div>.
See more info here: https://react.dev/link/hydration-mismatchPada Vue 3, log yang dihasilkan serupa:
[Vue warn]: Hydration node mismatch:
- Rendered on server: <div class="mobile-drawer">
- Expected on client: <aside class="desktop-sidebar">Ketika mismatch terjadi, client engine terpaksa menghancurkan (bail out) DOM yang dikirimkan server dan melakukan render ulang dari nol di sisi browser. Hal ini merusak metrik Core Web Vitals, khususnya Largest Contentful Paint (LCP) dan Cumulative Layout Shift (CLS).
Solusi 1: Refaktor ke CSS-First Responsive Layout (DOM Isomorfik)
Solusi paling kokoh dan murah komputasi adalah menjaga struktur DOM tetap identik (isomorfik) antara server dan client. Jangan melakukan branching tag HTML struktural di Go template.
Render elemen mobile dan desktop secara bersamaan ke dalam satu DOM tree, lalu kendalikan visibilitasnya menggunakan CSS media queries atau utility classes:
<!-- Template Go Fiber: Selalu render kedua komponen secara konsisten -->
<header class="site-header">
<nav class="mobile-nav hidden-on-desktop" aria-label="Mobile Navigation">
<button id="hamburger-btn">Menu</button>
</nav>
<nav class="desktop-nav hidden-on-mobile" aria-label="Desktop Navigation">
<ul>
<li><a href="/dashboard">Dashboard</a></li>
<li><a href="/settings">Settings</a></li>
</ul>
</nav>
</header>Terapkan kontrol display pada CSS stylesheet:
@media (min-width: 768px) {
.hidden-on-desktop {
display: none !important;
}
}
@media (max-width: 767px) {
.hidden-on-mobile {
display: none !important;
}
}Dengan pendekatan ini, DOM tree yang dihasilkan oleh Go Fiber persis sama dengan ekspektasi hydration engine frontend. Tidak ada mismatch, dan rendering transisi viewport berjalan mulus.
Solusi 2: Integrasi Client Hints untuk Kebutuhan Payload Kritis
Jika perbedaan antara varian mobile dan desktop melibatkan data payload besar yang tidak boleh diunduh di perangkat mobile, User-Agent sniffing biasa tetap tidak boleh dipakai. Gunakan standar User-Agent Client Hints (UA-CH) dengan header Sec-CH-UA-Mobile.
Di Go Fiber, tambahkan middleware untuk meminta browser mengirimkan Client Hints dan pastikan CDN menghormati variasi cache:
package main
import (
"github.com/gofiber/fiber/v2"
)
func ClientHintsMiddleware(c *fiber.Ctx) error {
// Minta browser mengirimkan petunjuk kapabilitas perangkat pada request berikutnya
c.Set("Accept-CH", "Sec-CH-UA-Mobile, Sec-CH-UA-Platform")
// Cegah cache poisoning di layer CDN/Reverse Proxy
c.Append("Vary", "Sec-CH-UA-Mobile")
return c.Next()
}
func SetupRoutes(app *fiber.App) {
app.Use(ClientHintsMiddleware)
app.Get("/", func(c *fiber.Ctx) error {
// Nilai "?1" berarti mobile, "?0" berarti non-mobile (desktop/laptop)
isMobileHint := c.Get("Sec-CH-UA-Mobile") == "?1"
return c.Render("index", fiber.Map{
"IsMobileDevice": isMobileHint,
})
})
}Peringatan Cache: Jika menggunakan reverse proxy seperti Nginx atau CDN seperti Cloudflare, pastikan header Vary: Sec-CH-UA-Mobile dimasukkan ke dalam konfigurasi cache key. Jika tidak, respons mobile dapat disajikan ke desktop user.Solusi 3: Passing State Tanpa Conditional Rendering Elemen Struktural
Ketika frontend tetap memerlukan flag perangkat untuk inisialisasi state awal (misalnya analitik atau konfigurasi lazy-loading script), jangan gunakan flag tersebut untuk mengatur percabangan HTML template Go. Inject nilai tersebut sebagai JSON state statis.
<!-- Injeksi state aman ke dalam Go HTML Template -->
<body>
<div id="app">{{ .InitialMarkup | safe }}</div>
<script id="__SERVER_STATE__" type="application/json">
{{ .StateJSON | safe }}
</script>
</body>Pada frontend JavaScript, ambil initial state dari tag script tersebut tanpa mengubah struktur awal VDOM:
// Client-side hydration script
function getInitialState() {
const raw = document.getElementById("__SERVER_STATE__");
if (!raw) return { isMobileDevice: false };
try {
return JSON.parse(raw.textContent);
} catch (err) {
console.error("Gagal parse server state:", err);
return { isMobileDevice: false };
}
}
const initialState = getInitialState();
// Jangan gunakan initialState.isMobileDevice untuk branching render pertama!
// Gunakan hanya untuk initial store/analytics.
createApp(App, { ...initialState }).mount("#app");Komponen Dua Fase (Two-Pass Rendering) di Client
Jika komponen tertentu harus tampil hanya di desktop dan berat untuk dimuat di mobile, tunda rendering komponen tersebut sampai hydration fase pertama selesai:
import { useState, useEffect } from 'react';
export function ResponsiveWidget() {
const [isClient, setIsClient] = useState(false);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
// Dieksekusi hanya setelah hydration selesai di browser
setIsClient(true);
const mediaQuery = window.matchMedia('(max-width: 768px)');
setIsMobile(mediaQuery.matches);
const handler = (e) => setIsMobile(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
if (!isClient) {
// Placeholder skeleton yang identik antara SSR dan Client Initial Render
return <div class="widget-placeholder" style={{ minHeight: '100px' }} />;
}
return isMobile ? <MobileSpecificComponent /> : <DesktopSpecificComponent />;
}Aturan Pengambilan Keputusan
- Gunakan CSS Media Queries secara default untuk semua kebutuhan layout dan visibilitas elemen navigasi.
- Gunakan Client Hints (Sec-CH-UA-Mobile) hanya jika payload data backend berbeda drastis antara versi mobile dan desktop (misalnya limitasi data JSON atau rendering dokumen khusus).
- Terapkan Two-Pass Rendering (useEffect/onMounted) pada framework frontend jika ada komponen interaktif kompleks yang tidak kompatibel dengan CSS-first approach.
- Hindari regex parsing pada header
User-Agentuntuk manipulasi struktur DOM di Go Fiber.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!