Akar Masalah: Pembacaan Objek Platform Saat Render Pass
Komponen Command Palette atau Quick Switcher umumnya menampilkan indikator shortcut keyboard: simbol Command (⌘) untuk macOS dan Control (Ctrl) untuk Windows/Linux. Bug hidrasi terjadi saat pengembang mendeteksi sistem operasi langsung pada badan komponen (render pass) menggunakan navigator.userAgent atau navigator.platform.
Server runtime (Node.js/Edge) tidak memiliki objek window maupun navigator. Jika kode menyertakan fallback seperti typeof window !== 'undefined' ? navigator.platform : 'Win32', server akan me-render teks Ctrl+K. Ketika bundle JavaScript dieksekusi di browser macOS, client me-render teks ⌘K. Ketidakcocokan output HTML server dan DOM tree awal client memicu React Hydration Error.
Dampak: Text Flicker, Layout Shift, dan Listener Dead Zone
- Text Flicker & Layout Shift (CLS): Teks shortcut berganti dari 'Ctrl' ke '⌘' setelah hidrasi selesai. Perbedaan lebar glif font memicu Cumulative Layout Shift mikro yang merusak skor Core Web Vitals.
- Bailout Client-side Rendering: Ketidakcocokan struktur memaksa React membatalkan sinkronisasi atribut dan membangun ulang node DOM terkait, menurunkan performa parsing awal.
- Listener Dead Zone: Upaya user menekan shortcut sebelum tahap hidrasi selesai gagal dieksekusi karena event listener keyboard belum terpasang ke objek
window.
Solusi 1: CSS Platform Selector (Tanpa JavaScript Re-render)
Pendekatan zero-runtime menghilangkan komputasi JavaScript pada komponen dengan mendelegasikan visibilitas glif sepenuhnya ke CSS. Render kedua elemen glif sekaligus ke dalam HTML, lalu tampilkan salah satu berdasarkan data attribute pada elemen <html> yang disuntikkan sebelum hidrasi dimulai.
/* styles/shortcut.css */
.shortcut-mac,
.shortcut-win {
display: none;
}
[data-platform="mac"] .shortcut-mac {
display: inline;
}
[data-platform="win"] .shortcut-win {
display: inline;
}// Injeksi minimal di root layout (sebelum hidrasi React)
<script
dangerouslySetInnerHTML={{
__html: `document.documentElement.dataset.platform = /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent) ? 'mac' : 'win';`,
}}
/>Solusi 2: Client-Only State dengan useSyncExternalStore
Jika label shortcut harus berupa string murni JavaScript, gunakan hook resmi React 18 useSyncExternalStore. Hook ini mencegah layout shift ganda akibat useEffect dan menyediakan mekanisme hydration snapshot yang deterministik.
import { useSyncExternalStore } from 'react';
function subscribe(callback: () => void) {
// Snapshot platform tidak berubah selama runtime, kembalikan noop cleanup
return () => {};
}
function getClientSnapshot(): 'mac' | 'other' {
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent) ? 'mac' : 'other';
}
function getServerSnapshot(): 'mac' | 'other' {
// Snapshot default server harus stabil dan identik untuk setiap request
return 'other';
}
export function usePlatform() {
return useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot);
}Registrasi Global Keyboard Listener yang Idempotent
Event listener shortcut harus mendukung kedua modifier key (metaKey untuk Mac, ctrlKey untuk platform lain), serta menerapkan cleanup listener untuk mencegah memory leak.
import { useEffect } from 'react';
export function useQuickSwitcherShortcut(onOpen: () => void) {
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
onOpen();
}
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onOpen]);
}Perbandingan Kode: Sebelum vs Sesudah Optimasi
Sebelum Optimasi (Rentan Hydration Error)
// ❌ Buruk: Membaca window/navigator saat render pass
export function BadQuickSwitcherButton() {
const isMac = typeof window !== 'undefined' && /Mac/.test(navigator.platform);
return (
<button>
Search <kbd>{isMac ? '⌘' : 'Ctrl'}</kbd> + <kbd>K</kbd>
</button>
);
}Sesudah Optimasi (Aman SSR & Idempotent)
// ✅ Benar: Snapshot terisolasi dan deterministik
'use client';
import { useSyncExternalStore, useState, useCallback } from 'react';
import { useQuickSwitcherShortcut } from './useQuickSwitcherShortcut';
function subscribe() {
return () => {};
}
export function QuickSwitcher() {
const [isOpen, setIsOpen] = useState(false);
const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
useQuickSwitcherShortcut(toggle);
const platform = useSyncExternalStore(
subscribe,
() => (/(Mac|iPhone|iPad)/i.test(navigator.userAgent) ? '⌘' : 'Ctrl'),
() => 'Ctrl' // Konsisten dengan HTML server
);
return (
<>
<button onClick={toggle} aria-label="Buka Quick Switcher">
<span>Search</span>
<kbd>{platform}</kbd>
<kbd>K</kbd>
</button>
{isOpen && <div role="dialog">{/* Modal Command Palette */}</div>}
</>
);
}Panduan Debugging Hydration
Lakukan verifikasi integritas DOM melalui command line atau DevTools:
- Disable JavaScript di DevTools: Periksa tampilan awal HTML dari server. Pastikan glif fallback muncul tanpa layout displacement.
- Evaluasi React Warning: Jika pesan "Text content did not match. Server: 'Ctrl' Client: '⌘'" muncul, periksa komponen yang membaca variabel global browser sebelum fase mount selesai.
- Gunakan atribut suppressHydrationWarning secara selektif: Jika perbedaan teks tak terhindarkan dan pendekatan CSS tidak memungkinkan, pasang atribut
suppressHydrationWarning={true}hanya pada elemen teks target, bukan pada wrapper container.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!