D
P
0
← Semua artikel Read in English

Next.js & React di Produksi

`client:idle` di Astro Tetap Merender di Server? Padanan `ssr: false` Next adalah `client:only`

· · 8 menit baca
`client:idle` di Astro Tetap Merender di Server? Padanan `ssr: false` Next adalah `client:only`

Di atas dua baris komponen di layout Astro saya, pernah ada komentar yang kedengarannya masuk akal. Isinya bilang terminal dan pet dimuat setelah halaman menganggur, bukan saat muat. Di Next keduanya dibungkus next/dynamic dengan ssr: false supaya keluar dari bundel awal, dan client:idle adalah padanan terdekatnya di Astro. Kalimat terakhir itu yang salah, dan salahnya bisa dihitung: aria-label="Open terminal" tercetak di HTML mentah seluruh 500 halaman situs Astro, sementara di produksi Next jumlahnya nol.

Kejadiannya 8 September 2026, di tengah pembangunan ulang situs saya sendiri dari Next.js 16.2.10 ke Astro 7.3.1, dengan React 19.2.3 di kedua repo. Pekerjaannya saya bagi ke tahap bernomor. Widget terminal dan widget pet masuk di tahap M1 bersama cangkang situs, dan cacat ini baru ketahuan saat ceklis tahap M4 dijalankan. Ceklis M1 tidak pernah memeriksanya.

Yang dikirim Next untuk dua widget itu

Di repo Next, komponen cangkang memuat terminal dan pet lewat next/dynamic dengan ssr: false. Dua widget cangkang lain, salah satunya pemutar musik, dimuat dengan cara yang sama. Bentuknya kira-kira begini, dengan nama komponen dan path diganti:

// Komponen cangkang di repo Next, disederhanakan. Nama komponen dan path diganti.
// ... deferred (client-only, not needed for first paint or
// SEO). Keeps gsap/framer-motion-bound widgets out of the initial bundle so
// mobile hydration/INP stays light.
const TerminalWidget = dynamic(
  () => import("@/components/TerminalWidget").then((m) => m.TerminalWidget),
  { ssr: false }
);
const PetWidget = dynamic(
  () => import("@/components/PetWidget").then((m) => m.PetWidget),
  { ssr: false }
);

Komentar aslinya menyebut alasannya: widget-widget ini hanya untuk klien, tidak dibutuhkan untuk cat pertama maupun SEO, dan widget yang terikat gsap atau framer-motion dijaga keluar dari bundel awal supaya hidrasi dan INP di ponsel tetap ringan. Impor dinamis untuk terminal sudah ada di repo Next sejak 22 Juni 2026.

Yang menentukan isi HTML adalah ssr: false. Dengan opsi itu Next melewati komponennya saat render server, dan markup-nya baru ada setelah JavaScript jalan di peramban. Itu sebabnya HTML produksi Next nol menyebut Open terminal.

String itu sendiri asalnya jelas. Komponen terminal memulai state isOpen dengan false, dan selama !isOpen ia merender tombol ber-aria-label="Open terminal". Keadaan awal komponen ini adalah tombol itu, jadi begitu ia ikut dirender server, atributnya tercetak di HTML setiap halaman yang membawanya.

Yang sebenarnya ditunda client:idle

Commit tahap M1, tercatat pukul 10.46, memasang kedua komponen di layout cangkang Astro dengan client:idle. Nama direktif itu gampang dibaca sebagai "jangan sentuh komponen ini sampai peramban menganggur", padahal yang ditunda hanya sisi klien. Di runtime server Astro 7.3.1, cabang yang menentukan isi HTML sebuah pulau hanya membedakan only dari yang lain:

// astro 7.3.1, dist/runtime/server/render/component.js, dipotong
if (metadata.hydrate === "only") {
  html = await renderSlotToString(result, slots?.fallback);
} else {
  const componentRenderStartTime = performance.now();
  ({ html, attrs } = await renderer.ssr.renderToStaticMarkup.call(
    // ...

Kalau direktifnya only, HTML pulau hanya diisi dari slot fallback. Untuk direktif lain, termasuk idle, visible, dan load, komponennya dirender lewat renderToStaticMarkup milik renderer. idle baru berperan di sisi klien:

// astro 7.3.1, dist/runtime/client/idle.js, dipotong
if ("requestIdleCallback" in window) {
  window.requestIdleCallback(cb, idleOptions);
} else {
  setTimeout(cb, idleOptions.timeout || 200);
}
 
// only.js
const onlyDirective = async (load) => {
  const hydrate = await load();
  await hydrate();
};
 
// load.js
const loadDirective = async (load) => {
  const hydrate = await load();
  await hydrate();
};

client:idle menunda pemuatan dan hidrasi ke requestIdleCallback, atau ke setTimeout 200 ms kalau API itu tidak ada dan tidak ada timeout yang diberikan. Hanya itu yang ditunda. Markup komponennya sudah dikirim dari server, jadi tombol Open terminal sudah ada di HTML sejak awal dan baru punya perilaku setelah pulaunya terhidrasi.

Dua berkas terakhir juga menunjukkan bahwa di sisi klien, client:only dan client:load identik. Keduanya langsung memanggil load() lalu hydrate() tanpa menunggu apa pun. Bedanya ada di dua tempat lain: cabang server tadi, dan renderer React Astro, @astrojs/react 6.0.5, yang memasang pulau only dengan createRoot dan pulau lain dengan hydrateRoot. Pulau only dibuat dari nol di peramban, karena memang tidak ada markup server yang bisa dihidrasi.

Astro sendiri menunjuk ke arah yang sama. Pesan galat render server-nya, untuk komponen yang tidak bisa menghindari akses ke window atau document, menyarankan direktif client:only. Dari semua direktif tadi, hanya only yang melewati render server, jadi hanya only yang setara dengan ssr: false.

Dua baris di layout cangkang

Commit perbaikannya tercatat pukul 12.09 di hari yang sama. Bentuk sebelum dan sesudahnya:

---
// Layout cangkang di repo Astro, disederhanakan. Nama komponen dan nama persist diganti.
import TerminalWidget from '../components/react/TerminalWidget.tsx';
import PetWidget from '../components/react/PetWidget.tsx';
---
<!-- tahap M1 -->
<TerminalWidget client:idle transition:persist="widget-terminal" />
<PetWidget client:idle transition:persist="widget-pet" />
 
<!-- sesudah perbaikan -->
<TerminalWidget client:only="react" transition:persist="widget-terminal" />
<PetWidget client:only="react" transition:persist="widget-pet" />

client:only perlu diberi nama renderer-nya, di sini "react". Komponennya tidak pernah dirender server, jadi Astro harus diberi tahu renderer mana yang memasangnya di peramban. transition:persist tidak disentuh, karena kedua komponen memang dipersist supaya keadaan terminal dan posisi pet tidak hilang saat pindah halaman.

Commit itu hanya mengubah satu berkas, 11 baris tambah dan 5 baris hapus, dan selain dua baris direktif tadi, yang berubah adalah komentarnya. Komentar barunya menyatakan terminal dan pet dipasang hanya di klien dan tidak pernah dirender server, menyangkal client:idle sebagai padanan ssr: false, dan mencatat bahwa alasan "keluar dari bundel awal" di cat pertama salah pada satu hal yang terukur, yaitu render server tadi. Aturan yang sama tercatat di README repo, di bawah judul hal-hal yang gampang dirusak tanpa sadar, dan pemutar musik di cangkang yang sama juga memakai client:only, bukan client:load, dengan alasan yang sama.

Ada satu akibat sampingan yang bisa dibaca langsung dari berkas direktif tadi. Karena only.js isinya sama dengan load.js, terminal dan pet sekarang dimuat dan dipasang begitu halamannya dimuat, tidak lagi menunggu peramban menganggur. Catatan saya tidak memuat angka untuk selisih itu, jadi saya tidak akan menyebutnya murah atau mahal.

Sudah ditambal, dengan satu batas

Perbaikan itu termasuk dalam empat commit tahap M4. Tahap itu dinyatakan selesai 8 September 2026, kecuali satu butir yang memang milik tahap M8, dan ceklis-ceklisnya, dari struktur sampai navigasi klien, lulus di deployment dengan nol error React di konsol.

Batasnya perlu saya tulis terang. Catatan saya tidak memuat hitungan ulang aria-label="Open terminal" khusus sesudah perbaikan, jadi saya tidak akan menulis angka nol yang baru untuk situs hidup. Yang saya pegang adalah ceklis M4 yang lulus di deployment, dan jalur kode di runtime server Astro yang untuk direktif only tidak pernah sampai ke renderToStaticMarkup.

Cacat terminal ini satu dari tiga cacat lama yang baru ketahuan saat ceklis M4 dijalankan, dan ketiganya sudah ditambal. Ketiganya lolos ceklis tahap asalnya dan baru tertangkap oleh ceklis tahap sesudahnya yang memeriksa hal yang sama dari sudut lain. Catatan saya merangkumnya dalam satu kalimat: tahap yang dinyatakan selesai belum berarti bersih.

Dua arah lain di peta yang sama

Dua kasus lain dari situs yang sama melengkapi pemetaannya, dan hasilnya tiga baris:

Next.js                                    Astro
komponen server (tanpa "use client")       tanpa direktif client
impor biasa di satu pohon klien            client:load
next/dynamic dengan ssr: false             client:only

Baris pertama berarti komponen React tanpa direktif client dirender penuh di server dan mengirim nol JavaScript, padanan persis Server Component Next. Jebakannya datang dari arah sebaliknya. Di tahap M10 ada komponen ber-"use client" yang memasang IntersectionObserver lewat useEffect. Tanpa direktif, komponen itu ikut pohon yang dirender server, dan di pohon itu useEffect tidak pernah jalan. Keadaan tersembunyinya ditulis CSS di balik @media (scripting: enabled), jadi tanpa pengamat seluruh isinya tetap pending selamanya. Aturan umum yang saya ambil dari situ: komponen klien yang mencabut keadaan tersembunyi tidak boleh ikut dirender server tanpa penggantinya.

Baris kedua datang dari bug yang baru muncul sesudah domain dipindah ke Astro pada 8 September. Animasi seksi beranda diam di tempat dan baru mulai sesudah seksinya lewat setengah tinggi layar. Sebabnya, delapan dari sembilan seksi beranda dipasang client:visible di tahap M4, tahap yang ceklisnya justru menangkap cacat terminal. Pulaunya baru terhidrasi saat seksinya masuk layar, dan ScrollTrigger-nya baru dibuat sesudah titik pemicunya terlewat. Pada gulir nol, client:visible menghasilkan 6 dari 55 ScrollTrigger berdiri, sedangkan client:load 55 dari 55.

Di Next, kesembilan seksi itu impor biasa dalam satu pohon React yang mount bersamaan, jadi padanannya client:load. Commit perbaikan keesokan harinya, 9 September, mengganti delapan direktif itu, dan pesannya menyebut client:visible sebagai optimasi khas Astro yang ditambahkan di M4 tanpa disadari mengubah perilaku. Ongkos pindahnya diukur nyaris nol, 94,6 KB lawan 95,3 KB, karena pulau Astro dirujuk lewat component-url di HTML apa pun direktifnya, jadi yang berubah hanya kapan pulaunya diambil. Jalan tengah juga sudah dicoba dan tidak cukup. Memperlebar rootMargin ke 900px hanya menaikkan angkanya ke 37 dari 55, dan 1600px tidak menambah apa pun, karena sebagian pemicu dibuat oleh efek bersarang, bukan oleh hidrasi.

Dua pertanyaan yang berbeda

ssr: false menjawab satu pertanyaan: apakah komponen ini dirender di server sama sekali. Direktif Astro selain only menjawab pertanyaan lain, yaitu kapan JavaScript pulaunya jalan, dan di runtime server Astro 7.3.1 semuanya tetap merender komponennya. Di situs ini, client:idle saya pilih sebagai padanan terdekat dan client:visible saya tambahkan sebagai optimasi. Dua-duanya mengubah kapan pulau hidup, dan dua-duanya ternyata tidak setara dengan versi Next. Jadi saat memindahkan komponen dari Next, cocokkan dua hal itu secara terpisah: apakah komponennya muncul di HTML server, dan kapan ia mulai hidup.