background blurbackground mobile blur

1/1/1970

Bagaimana Saya Laksanakan SSG Dalam 2 Hari

Hai! Setahun lepas, saya rasa benda ni mustahil. Tapi saya baru sahaja siap laksanakan Static Site Generation (SSG) untuk Foony dalam 2 hari, dan saya memang agak teruja dengannya. Ini bukan kali pertama saya cuba selesaikan SSG untuk Foony juga. Saya pernah tengok NextJS, Vike, Astro, Gatsby, dan beberapa solusi lain sebelum ni. Saya siap pernah cuba mulakan dengan NextJS, tapi tersangkut sebab kompleksiti SPA Foony dan beribu-ribu fail. Proses migrasi tu memang mimpi ngeri dan mungkin ambil masa berbulan-bulan. Ia juga akan tambah lagi kompleksiti untuk semua orang lain yang kerja pada laman ni sebab mereka kena belajar NextJS dan segala ragamnya.

Saya nak sesuatu yang ringan dan senang nak implement. Sesuatu yang benarkan kami terus tulis kod macam biasa tanpa perlu fikir pasal SSG (kecuali useMediaQuery - yang tu memang tak boleh lari). Di bawah ni saya akan pecahkan kenapa saya pilih solusi tempahan khas, cabaran spesifik yang saya hadapi (terutamanya dengan React's Suspense boundaries), dan macam mana saya selesaikannya.

Kenapa Bukan Solusi Standard?

Masa pertama kali saya fikir nak tambah SSG pada Foony, secara semula jadinya saya tengok NextJS (standard industri), Vike, dan Astro.

NextJS: Terlalu Banyak Migrasi

NextJS memang power, tapi ia perlukan migrasi besar-besaran daripada React SPA Foony yang sedia ada. Kami ada beribu-ribu fail, logik routing yang kompleks, dan banyak infrastruktur custom. Migrasi ke NextJS akan bermaksud:

  • Tulis semula seluruh sistem routing kami
  • Susun semula cara kami memuatkan game dan komponen
  • Berbulan-bulan kerja hanya untuk capai semula tahap fungsi yang sama macam sekarang
  • Risiko perubahan besar yang mungkin rosakkan pengalaman pengguna
  • Kena ubah cara kami uruskan imej
  • Masa build yang jauh lebih perlahan (mungkin 5-30 minit. Saya tak ada nombor tepat untuk sokong ni selain perbincangan di GitHub yang dah 5 tahun)
  • Satu team kena belajar benda baru (NextJS), dan kelajuan pembangunan jadi perlahan selama-lamanya
  • Kena migrasi kod setiap kali NextJS buat perubahan besar yang patahkan keserasian.

Saya siap dah cuba mulakan dengan NextJS, tapi cepat sedar kos migrasi terlalu tinggi. Kompleksitinya tak berbaloi.

Vike: Kompleksiti Lebih Kurang Sama

Vike (dulu dipanggil vite-plugin-ssr) ada masalah yang lebih kurang sama. Walaupun ia lebih fleksibel daripada NextJS, ia tetap perlukan penyusunan semula besar pada codebase kami. Usaha nak belajar dan migrasi tu tak sepadan dengan manfaatnya.

Astro: Seni Bina Yang Tak Kena

Astro memang best untuk laman yang berat pada kandungan, tapi Foony ialah platform game multiplayer yang kompleks. Kami perlukan kemas kini masa nyata, sambungan WebSocket, dan komponen React yang dinamik. Seni bina Astro memang tak kena langsung dengan apa yang kami bina.

Solusinya: SSG Tempahan Khas

Dengan semangat daripada pendekatan "SSG palsu" yang saya buat beberapa hari lepas selepas i18n, saya pilih satu solusi SSG khas untuk Foony yang kecil, ringan, dan buatan sendiri.

Pendekatan "SSG palsu" saya ialah tarik kandungan blog post daripada halaman yang ada blog post (laluan /posts dan halaman game), dan letak kandungan tu tepat di tempat yang client akan render, khas untuk enjin carian dan LLM supaya lebih faham Foony. Ia juga tambah skema ld+json dan sedikit benda kecil untuk SEO.

Pendekatan ni ringkas:

  1. Bina di atas React SPA sedia ada: Tak perlu migrasi, cuma tambah proses penjanaan SSG masa build.
  2. Guna renderToReadableStream: API streaming SSR React 18 uruskan Suspense secara native.
  3. Jana fail HTML statik: Pre-render laluan masa build dan hidangkan sebagai fail statik, gunakan SitemapGenerator kami untuk dapatkan senarai laluan.
  4. Perubahan minimum pada codebase sedia ada: Kebanyakan komponen terus berfungsi macam biasa.

Implementasi teras duduk dalam client/src/generators/GenerateShellSsgFromSitemap.ts. Ia baca sitemap, render setiap laluan guna renderToReadableStream React, dan tulis HTML tu ke fail statik. Ringkas, tepat macam yang saya suka!

Hasilnya juga agak laju. Lebih kurang 2,800 laluan siap di‑render dalam 10 saat. Padu. Itu jauh lebih pantas daripada NextJS, Gatsby, dan Astro. <img alt="Log konsol SSG menunjukkan masa yang diambil" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />

Saya boleh bercerita panjang lebar pasal kesederhanaan. Walaupun ia mungkin tak buat anda naik pangkat di syarikat besar sebab "tak cukup kompleks", kod yang ringkas itu cantik, senang diselenggara, dan secara umum jauh lebih baik untuk kelajuan pembangunan. Ini antara perkara yang saya betul-betul kagumi dalam prinsip Zen.

Masalah Suspense Boundary

Jadi sekarang saya dah ada SSG, dan kandungan muncul dalam HTML... tapi halaman saya kosong! Macam mana boleh jadi?! <img alt="Halaman kosong dengan SSG" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

Rupanya renderToReadableStream masih ada Suspense boundary, walaupun anda await stream.allReady. Agakan saya, ini sebab ia ialah satu "stream", dan memang direka untuk dihantar ke client sambil bait diterima.

Apa Yang React Hasilkan

Bila anda guna renderToReadableStream dengan Suspense, React hasilkan HTML lebih kurang macam ni:

<!--$?-->
<template id="B:0"></template>
<!--/$-->
<div hidden id="S:0">
  <!-- Actual content here -->
</div>
...
<script>/*Script that replaces the suspense boundaries*/</script>

Elemen <template id="B:0"> ialah placeholder tempat kandungan sepatutnya berada. <div hidden id="S:0"> pula mengandungi kandungan sebenar yang telah di‑render. B:0 dipadankan dengan S:0 ikut nombor (index bermula dari 0).

Tanpa JavaScript, enjin carian (ya, termasuk awak, Bing) dan LLM akan nampak halaman yang hampir kosong dengan hanya placeholder template. Itu terus menewaskan tujuan utama SSG!

Saya tak nampak cara yang betul-betul bersih untuk buang Suspense boundary ni, jadi solusi saya ialah tulis beberapa test dan satu fungsi resolveSuspenseBoundaries untuk tukar bahagian-bahagian ni. Ini lebih laju daripada parse HTML dan jalankan skrip dengan sesuatu seperti JSDOM. Dan yang lebih penting, ia memang perlu untuk apa yang saya rancang: laman yang sedap dibaca oleh enjin carian / LLM tanpa JavaScript, tapi masih sokong Suspense boundary dan hydration di client.

Menguji Transformasi

Saya mulakan dengan tulis test untuk transformasi ni dengan ambil beberapa contoh dalam DOM daripada apa yang saya ada (JavaScript dimatikan), dan apa yang saya nak (JavaScript dihidupkan). Saya suap contoh-contoh ni ke dalam LLM dan biar ia uruskan penjanaan test, benda yang ia memang agak bagus.

Test-test ni duduk dalam client/src/generators/ssr/renderRoute.test.ts dan pastikan transformasi berjalan dengan betul. Ia meliputi:

  • Penggantian boundary ringkas (senarai blog)
  • Boundary kompleks dengan kandungan di antara template dan komen penutup
  • Banyak boundary sekali gus
  • Boundary tanpa penanda komen
  • Kes hujung (edge cases)

Jenis "TDD" macam ni sebenarnya sangat berguna untuk kes penggunaan di mana anda dah tahu input dan output yang dijangka.

Jangan pula keliru dengan "TDD semua benda sebab Robert C. Martin cakap macam tu" (yang akan perlahan kan kelajuan pembangunan team anda). Anda sepatutnya TIDAK guna TDD untuk UI atau bahagian kod yang sentiasa berubah!

Solusinya: resolveSuspenseBoundaries

Bila test semua dah siap, saya minta LLM tulis fungsi resolveSuspenseBoundaries. Saya pilih guna cheerio untuk elakkan kerapuhan RegEx, walaupun guna RegEx di sini boleh jimatkan masa SSG kira-kira 40%.

export function resolveSuspenseBoundaries(html: string): {html: string; didResolveSuspense: boolean} {
  const originalHtml = html;
  const $ = cheerio.load(originalHtml, {xml: false, isDocument: false, sourceCodeLocationInfo: true});
  const operations: Array<{index: number; removeLength: number; insertText?: string}> = [];

  // Collect hidden divs with their content and positions.
  const hiddenDivs = new Map<string, {content: string; divStartIndex: number; divEndIndex: number}>();
  $('div[hidden][id^="S:"]').each((_, el) => {
    const id = $(el).attr('id');
    if (!id) {
      return;
    }
    const boundaryId = id.substring(2);
    const content = $(el).html() || '';
    const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
    if (typeof startOffset === 'number' && typeof endOffset === 'number') {
      hiddenDivs.set(boundaryId, {content, divStartIndex: startOffset, divEndIndex: endOffset});
    }
  });

  if (hiddenDivs.size === 0) {
    return {html: originalHtml, didResolveSuspense: false};
  }

  // Find templates (B:0) and replace them with the matching hidden content (S:0),
  // following React’s internal $RV behavior.
  $('template[id^="B:"]').each((_, el) => {
    const id = $(el).attr('id');
    if (!id) {
      return;
    }
    const boundaryId = id.substring(2);
    const divInfo = hiddenDivs.get(boundaryId);
    if (!divInfo) {
      return;
    }
    const {startOffset, endOffset} = el.sourceCodeLocation ?? {};
    if (typeof startOffset !== 'number' || typeof endOffset !== 'number') {
      return;
    }

    const templateIndex = startOffset;
    const templateLength = endOffset - startOffset;
    const afterTemplate = originalHtml.substring(templateIndex + templateLength);
    const closingCommentMatch = afterTemplate.match(/<!--\/[
amp;]-->/); const removeEndIndex = closingCommentMatch ? templateIndex + templateLength + closingCommentMatch.index! : templateIndex + templateLength; const divContentStartIndex = originalHtml.indexOf('>', divInfo.divStartIndex) + 1; const divContentEndIndex = originalHtml.lastIndexOf('</', divInfo.divEndIndex); const divContent = originalHtml.substring(divContentStartIndex, divContentEndIndex); operations.push({index: templateIndex, removeLength: removeEndIndex - templateIndex}); operations.push({index: templateIndex, removeLength: 0, insertText: divContent}); operations.push({index: divContentStartIndex, removeLength: divContentEndIndex - divContentStartIndex}); operations.push({index: divInfo.divStartIndex, removeLength: divContentStartIndex - divInfo.divStartIndex}); operations.push({index: divContentEndIndex, removeLength: divInfo.divEndIndex - divContentEndIndex}); }); operations.sort((a, b) => (a.index !== b.index ? b.index - a.index : b.removeLength - a.removeLength)); let resultHtml = originalHtml; for (const operation of operations) { resultHtml = resultHtml.slice(0, operation.index) + (operation.insertText ?? '') + resultHtml.slice(operation.index + operation.removeLength); } return {html: resultHtml, didResolveSuspense: true}; }

Ini memastikan, dan bukannya nampak halaman hampir kosong, enjin carian dan LLM akan nampak halaman yang sudah di‑render sepenuhnya.

Sekarang kami dah ada SSG yang berfungsi elok walaupun tanpa JavaScript! <img alt="SSG tanpa JavaScript untuk blog Foony" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />

Untuk jangka panjang, tak mustahil React akan ubah format Suspense mereka. Mungkin saya akan buang kod resolusi Suspense ni bila saya dah ada solusi yang lebih baik untuk halaman yang dimuat secara lazy (dan memang perlukan Suspense boundary).

Strategi Hydration (Kemas Kini: Ini Ambil 3 Hari + 1 Hari Tambahan)

Hydration memang mencabar. Yang tu saya dah tahu. Tapi lepas beberapa hari kerja, akhirnya saya berjaya buat ia berfungsi!

Jumlah masa diambil untuk hydration: 3 hari, tambah 1 hari ekstra untuk ganti pendekatan dehydration.

Bahagian paling susah ialah nak dapatkan hydrate yang pertama, sekecil mungkin tapi berfungsi. Bila saya berjaya render "Hello World" dengan navbar sekali, saya terus yakin yang ya, projek ni mungkin tak ambil masa sebulan pun!

<img alt="Hello World Foony berjaya di-hydrate bersama navbar" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

Untuk hydrate pertama yang minimal tu, saya ada cabaran unik: saya nak hydration, tapi dalam masa yang sama saya nak SEO yang bagus untuk enjin carian dan LLM tanpa developer perlu fikir pasal Suspense boundary.

Cabarannya

Hydration dalam React memang sangat literal: kalau DOM tak nampak sama dengan apa yang React jangka untuk render pertama, anda akan dapat mesej ralat yang "cantik" tapi hampir tak berguna dalam console, dan React akan buang semua dan render semula dari kosong. Diff pun tak ada untuk beritahu apa yang silap!

Dalam kes kami, SSG buat keadaan ni lagi teruk dengan beberapa cara:

  1. Kami post-process HTML untuk buang/selesaikan artifak Suspense streaming React 18 (yang sangat bagus untuk bot).
  2. Client tak sentiasa ada data yang tepat sama pada masa (t = 0) macam semasa render di server (data SSG, metadata blog, dan lain-lain).
  3. i18n kami secara default adalah "lazy", yang bermaksud terjemahan boleh hilang untuk render pertama kecuali anda rekod terjemahan mana yang digunakan semasa SSG dan suntik ia sebelum React render.

Apa Yang Menjadi (Pendekatan Awal: Dehydration)

Mula-mula, saya cuba satu benda yang nampak bijak dan comel: saya guna corak command untuk rekod arahan yang digunakan bagi menyelesaikan Suspense boundary dalam HTML, dan kemudian pulangkan arahan transformasi terbalik supaya saya boleh pulihkan HTML kepada bentuk yang React perlukan untuk hydration. Harapan saya ialah saya boleh hantar jauh lebih sedikit bait dalam index.html dengan kaedah command ni. Tapi, macam kebanyakan solusi yang terlalu "pintar", ia gagal sebab browser ubah HTML dengan cara halus, contohnya buang atau tambah ; atau /, yang terus merosakkan index penggantian. Secara teknikal, mungkin anda boleh ambil kira perubahan halus browser ni, tapi saya memang tak sanggup nak hantar sesuatu yang rapuh macam tu. Daripada cuba "undur balik" transformasi Suspense boundary ke markup streaming React, saya buat sesuatu yang super ringkas:

Bundel HTML asal yang belum diselesaikan ke dalam <script type="text">.

Pendekatan "dehydration" ni berfungsi, tapi saya habiskan satu hari ekstra untuk ganti ia dengan solusi yang lebih baik.

Pendekatan Yang Lebih Baik: Critical Path Suspense Boundary Replacement

Selepas implementasi awal, saya masih jumpa beberapa isu dengan Suspense boundary. Masa tu saya sedar ada satu solusi yang lebih bersih, lebih baik, dan lebih ringkas. Saya ganti pendekatan dehydration dengan critical path Suspense boundary replacement, yang:

  • Memuatkan critical path sebelum hydration: Komponen yang telah dipreload semasa SSR dikenal pasti dan dipreload di client sebelum hydrateRoot dipanggil
  • Lebih senang diselenggara: Tak perlu sentuh dalaman React atau parse AST (pendekatan dehydration perlukan parse dan pulihkan HTML)
  • Menghantar bait yang lebih sedikit: Kami tak lagi bundel respons SSR asal daripada React dalam tag skrip
  • Cegah kemungkinan flash: Tak perlu dehydrate/rehydrate HTML, jadi tak ada risiko flash visual

Implementasi ni menjejak komponen lazy mana yang dipreload semasa SSR (melalui SSRLazyComponentTracker), masukkan laluan import mereka dalam data hydration, dan preload komponen tu secara segerak sebelum hydration. Komponen critical path di‑render terus tanpa Suspense boundary, sepadan dengan output SSR dengan tepat.

Untuk yang lain, kami jadikan render pertama di client berperanan sebagai SSR/SSG. Maksudnya guna input yang sama, dan pastikan input tu tersedia secara segerak sebelum hydrateRoot. Ini dibuat dengan membundel semuanya melalui "ssg-data" kami.

Secara konkrit, pelarasannya ialah:

  1. Bundel input SSR ke dalam satu skrip teks

    • Semasa SSG, kami suntik <script type="text/foony-ssg" id="foony-ssg-data">...</script> betul-betul sebelum entrypoint modul Vite.
    • Skrip tu mengandungi:
      • html: HTML yang telah diselesaikan yang kami hantar dalam fail statik
      • ssgData: SSGData yang telah disirikan dan digunakan oleh pembalut SSR. Saya bercadang nak kemas kini ini kepada Proxy atau seumpamanya supaya hanya data yang diakses sahaja dimasukkan.
      • translationData: ketulan data kunci-nilai terjemahan yang kami guna semasa SSR
  2. Suntik input tu betul-betul sebelum hydration

    • Dalam main.tsx, kami secara segerak:
      • tetapkan #root.innerHTML kepada HTML yang telah disirikan dan diselesaikan (supaya DOM betul-betul sama dengan apa yang hydration nampak)
      • balut app dalam SSGDataProvider supaya komponen ada SSGData yang sama pada render pertama
  3. Jadikan i18n segera dengan menyuntik nilai terjemahan

    • Kami rekod objek terjemahan sebenar yang diakses semasa SSR dan hantar ia dalam skrip SSG.
    • Di client, kami suntik terus ke cache LocaleQueryer melalui kaedah khas LocaleQueryer.inject(), jadi terjemahan tersedia serta-merta.

Dengan itu, render pertama ada data yang sama macam SSR!

useIsSSRMode() hook sudah pun diimplement dalam client/src/generators/ssr/isSSRMode.ts:

export function useIsSSRMode(): boolean {
  const [isSSRMode, setIsSSRMode] = React.useState(true);
  
  React.useEffect(() => {
    // After mount (hydration complete), switch to client mode
    setIsSSRMode(false);
  }, []);
  
  return isSSRMode;
}

Hook ni pulangkan true semasa SSR dan pada render pertama di client (hydration), kemudian tukar kepada false selepas mount. Komponen seperti UserBanner, Navbar, dan Dialog dah pun guna hook ni untuk elakkan mismatch hydration.

  1. Patch React untuk diff yang lebih jelas

Saya berharap boleh terus guna hydration-overlay. Tapi projek tu tak lagi aktif diselenggara, hanya sokong sampai React 18, dan belum betul-betul sedia produksi. Jadi saya minta LLM klon repo tu untuk dijadikan inspirasi, dan ia hasilkan satu hydration overlay ringkas dalam beberapa minit. Saya tak perlukan apa-apa yang canggih, cuma sesuatu yang muncul masa pembangunan supaya saya boleh nampak di mana benda jadi salah.

Overlay baru ni sangat asas, jadi diff dia tak betul-betul sempurna. React buang komen, tambah ; selepas atribut style, ubah whitespace, dan beberapa perkara kecil lain yang overlay kami belum ambil kira (lagi). Overlay kami juga termasuk komen HTML yang React abaikan untuk hydrationnya.

<img alt="Hydration overlay baru kami" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_overlay.webp" style={{ margin: "8px auto", height: 315, display: "block" }} />

Tapi ia sudah cukup bagus untuk saya nampak apa yang perlu dibaiki.

<img alt="perbandingan diff antara SSG kami dan render halaman pertama di client untuk hydration React" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />

Mengikut Nombor

Untuk beri gambaran tentang apa yang terlibat dalam implementasi ni:

  • 2 hari kerja (daripada mula sampai SSG berfungsi). Sebenarnya lebih sikit dari 24 jam, masa tengah bercuti.
  • 4 hari kerja untuk jadikan hydration berkelakuan elok tanpa perlumbaan async terjemahan atau useMediaQuery merosakkan keadaan.
  • 1 hari ekstra untuk ganti pendekatan dehydration dengan critical path Suspense boundary replacement (lebih ringkas, kurang bait, tiada kemungkinan flash).
  • ~200 baris kod teras penjanaan SSG (GenerateShellSsgFromSitemap.ts)
  • ~120 baris kod resolusi Suspense boundary (resolveSuspenseBoundaries dalam renderRoute.tsx) - Nota: Ini kemudian diganti oleh pendekatan critical path
  • ~50 baris utiliti SSR (isSSRMode.ts)
  • ~100 baris test (renderRoute.test.ts)
  • ~150 baris polyfill untuk SSR (setupSSREnvironment)
  • Perubahan minimum pada komponen sedia ada (kebanyakannya tambah semakan useIsSSRMode())

Solusi ni ringan dan senang diselenggara. Tak perlukan migrasi framework, dan ia berfungsi terus dengan React SPA kami yang sedia ada.

Perkara Utama Yang Saya Belajar

Kadang-kadang Solusi Tempahan Khas Lebih Baik

Tak semua masalah perlukan framework. Untuk Foony, solusi SSG tempahan khas yang kecil ni memang pilihan yang tepat. Ia:

  • Ringan: Tak ada kebergantungan berat atau overhead framework
  • Mudah diselenggara: Kod yang ringkas dan kami faham
  • Fleksibel: Senang diubah dan dikembangkan bila perlu
  • Serasi: Berfungsi dengan React SPA sedia ada tanpa perlu migrasi

Streaming SSR React Ada Ragamnya Tersendiri

Fungsi renderToReadableStream React memang bagus untuk uruskan Suspense, tapi ia ada ragamnya sendiri. Walaupun dengan await stream.allReady, anda tetap akan dapat Suspense boundary dalam output. Ini bukan pepijat, ia memang direka begitu untuk streaming. Tapi untuk SSG, kita perlukan HTML yang sudah diselesaikan sepenuhnya. Rasa macam satu kegagalan kecil oleh pasukan React sebab tak sediakan cara yang lebih kemas untuk senario ni.

Solusi saya ialah post-process HTML dan selesaikan boundary tu. Bukanlah cantik sangat, tapi cukup laju dan cukup fleksibel untuk kes penggunaan saya.

TDD Boleh Berguna Untuk LLM

Transformasi HTML mudah sangat terdedah kepada ralat. Satu pepijat kecil pun boleh rosakkan seluruh output SSG dan pengalaman pengguna akhir. Saya minta LLM tulis test yang menyeluruh (dengan input daripada saya) untuk pastikan transformasi ni berfungsi dengan betul.

Kesimpulan

SSG sekarang dah berfungsi untuk Foony. Halaman di‑render penuh untuk enjin carian dan LLM, dan solusinya mudah diselenggara serta ringan. Hydration untuk laluan SSG ambil masa lebih lama daripada yang saya jangka (3 hari), dan saya habiskan satu hari ekstra untuk ganti pendekatan dehydration awal dengan critical path Suspense boundary replacement. Pendekatan baru ni lebih mudah diselenggara, hantar lebih sedikit bait, dan cegah sebarang flash visual yang mungkin berlaku bila dehydrate/rehydrate HTML.

Saya masih terkejut yang hanya ambil 2 hari untuk implement satu solusi SSG tempahan khas. Tapi kadang-kadang, solusi yang paling tepat ialah yang paling ringkas.

Kerja masa depan termasuk sempurnakan lagi padanan hydration dan mungkin patch React untuk debugging yang lebih baik. Tapi buat masa sekarang, Foony dah ada SSG yang berfungsi. Saya akan perhatikan Google Search Console dan Bing Webmaster Tools dalam minggu-minggu akan datang untuk tengok kesannya pada SEO kami.

8 Ball Pool online multiplayer billiards icon