background blurbackground mobile blur

1/1/1970

SSG'yi 2 Günde Nasıl Uyguladım

Selam! Bir yıl önce bunun imkansız olduğunu düşünüyordum. Ama Foony için Statik Site Oluşturma'yı (SSG) 2 günde bitirdim ve bundan baya heyecanlıyım. Bu, Foony için SSG'yi çözmeye çalıştığım ilk sefer de değil. Daha önce NextJS, Vike, Astro, Gatsby ve birkaç başka çözüme de baktım. Hatta NextJS ile bir deneme başlangıcım bile oldu, ama Foony'nin SPA yapısının karmaşıklığı ve binlerce dosya yüzünden zorlandım. Geçiş tam bir kabus olacaktı ve aylar sürecekti. Ayrıca sitede çalışan herkes için ek karmaşıklık yaratacaktı, çünkü NextJS ve onun tuhaflıklarını öğrenmeleri gerekecekti.

Ben hafif ve uygulaması kolay bir şey istiyordum. SSG'yi pek düşünmeden, kodu şu ana kadar yazdığımız şekilde yazmaya devam etmemize izin verecek bir şey (tabii useMediaQuery hariç, ondan kaçış yok). Aşağıda neden özel bir çözüme gittiğimi, karşılaştığım belirli zorlukları (özellikle React'in Suspense sınırlarıyla) ve bunları nasıl çözdüğümü anlatacağım.

Neden Standart Çözümler Değil?

Foony'ye SSG eklemeyi ilk düşündüğümde, doğal olarak NextJS'yi (sektör standardı), Vike'ı ve Astro'yu düşündüm.

NextJS: Aşırı Geçiş Maliyeti

NextJS güçlü, ama Foony'nin mevcut React SPA'sini dev bir geçişten geçirmemiz gerekecekti. Binlerce dosyamız, karmaşık yönlendirme (routing) mantığımız ve bir sürü özel altyapımız var. NextJS'ye geçmek şunları yapmak demek olacaktı:

  • Tüm yönlendirme sistemimizi yeniden yazmak
  • Oyunları ve bileşenleri nasıl yüklediğimizi yeniden yapılandırmak
  • Sadece mevcut özellik seviyesine geri dönmek için aylarca çalışmak
  • Kullanıcılar için potansiyel kırıcı değişiklikler
  • Görselleri ele alış şeklimizi değiştirmek
  • Çok daha yavaş build süreleri (muhtemelen 5-30 dakika. Bunu destekleyecek somut sayılarım yok, sadece şu 5 yıllık GitHub tartışması var)
  • Tüm ekibin yeni bir şey (NextJS) öğrenmek zorunda kalması ve geliştirici hızının kalıcı olarak düşmesi
  • NextJS kırıcı değişiklik yapmaya karar verdiğinde kodu tekrar tekrar göç ettirmek.

NextJS ile ufak bir başlangıç bile yaptım ama geçiş maliyetinin aşırı yüksek olduğunu çok hızlı fark ettim. Bu karmaşıklığa değmezdi.

Vike: Benzer Karmaşıklık

Vike (eski adıyla vite-plugin-ssr) benzer sorunlara sahipti. NextJS'den daha esnek olsa da, kod tabanımızda yine ciddi bir yeniden yapılandırma gerektiriyordu. Öğrenme eğrisi ve geçiş çabası, getireceği faydayı haklı çıkarmıyordu.

Astro: Yanlış Mimari

Astro içerik ağırlıklı siteler için harika, ama Foony karmaşık bir çok oyunculu oyun platformu. Gerçek zamanlı güncellemelere, WebSocket bağlantılarına ve dinamik React bileşenlerine ihtiyacımız var. Astro'nun mimarisi bizim inşa ettiğimiz şeye pek uymuyor.

Çözüm: Özel SSG

Birkaç gün önce i18n sonrası uyguladığım “sahte SSG” yaklaşımından cesaret alarak, Foony'nin SSG'si için küçük, hafif ve tamamen bize özel bir çözüme karar verdim.

“Sahte SSG” yaklaşımımda, blog yazısı içeren sayfalardan ( /posts rotaları ve oyun sayfaları) blog içeriğini çekip, tarayıcı tarafında tam olarak nereye render edileceklerse oraya yerleştiriyordum. Bunu özellikle arama motorları ve LLM'lerin Foony'yi daha iyi anlaması için yaptım. Ayrıca ld+json şeması ve ufak bazı SEO dokunuşları da ekliyordu.

Yaklaşım basit:

  1. Mevcut React SPA'nin üzerine inşa et: Hiç geçiş gerekmiyor, sadece build sırasında SSG üretimi ekleniyor.
  2. renderToReadableStream kullan: React 18'in streaming SSR API'si Suspense'i doğal olarak hallediyor.
  3. Statik HTML dosyaları üret: Build sırasında rotaları önceden render et ve bunları statik dosya olarak sun; rotaların listesini almak için de kendi SitemapGenerator'ımızı kullan.
  4. Mevcut kod tabanına minimum dokunuş: Çoğu bileşen olduğu gibi çalışıyor.

Çekirdek implementasyon client/src/generators/GenerateShellSsgFromSitemap.ts içinde yaşıyor. Bir sitemap okuyor, her rotayı React'in renderToReadableStream fonksiyonuyla render ediyor ve HTML'i statik dosyalara yazıyor. Basit, tam sevdiğim gibi!

Bu arada oldukça hızlı da oldu. Yaklaşık 2.800 rota 10 saniyede render edildi. Güzel. NextJS, Gatsby ve Astro'dan belirgin şekilde daha hızlı. <img alt="SSG konsol çıktısında geçen süreyi gösteren log" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />

Basitlik hakkında saatlerce konuşabilirim. Büyük şirketlerde “yeterince karmaşık değil” diye terfi getirmese bile, basit kod güzeldir, bakımı kolaydır ve genel olarak geliştirici hızını çok daha iyi tutar. Zen ilkeleri hakkında en çok hayran olduğum şey de bu.

Suspense Boundary Sorunu

Artık SSG vardı ve içerik HTML'de görünüyordu... ama sayfalarım bomboştu! Nasıl yani?! <img alt="SSG ile boş sayfa" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

Meğerse renderToReadableStream, await stream.allReady kullansan bile hala Suspense boundary'lerine sahipmiş. Sanırım bunun nedeni gerçekten bir “stream” olması ve byte'lar gelir gelmez istemciye aktarılacak şekilde tasarlanmış olması.

React Ne Üretiyor

renderToReadableStream'i Suspense ile kullandığında React aşağıdaki gibi HTML üretiyor:

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

<template id="B:0"> içeriğin gitmesi gereken yere konan placeholder. <div hidden id="S:0"> ise gerçekte render edilmiş içeriği içeriyor. B:0 ile S:0 numara olarak eşleşiyor (0 tabanlı index).

JavaScript olmadan arama motorları (sana bakıyorum Bing) ve LLM'ler, sadece template placeholder'ı olan neredeyse boş bir sayfa görüyor. Bu da SSG'nin tüm amacını çöpe atıyor!

Bu Suspense boundary'lerini kaldırmanın temiz bir yolunu göremedim, o yüzden çözüm olarak birkaç test ve bunları değiştiren bir resolveSuspenseBoundaries fonksiyonu yazdım. Bu, HTML'i parse edip içindeki script'i JSDOM gibi bir şeyle çalıştırmaktan daha hızlıydı. Ve daha da önemlisi, kafamdaki plan için şarttı: JavaScript olmadan arama motorları / LLM'ler için okunabilir, düzgün bir site, ama istemci tarafında Suspense boundary'lerini ve hydration'ı destekleyen bir yapı.

Dönüşümü Test Etmek

Önce dönüşüm için testler yazarak başladım; elimde olan DOM örneklerini (JavaScript kapalıyken) ve istediğim hali (JavaScript açıkken) aldım. Bunları bir LLM'e verdim ve test üretimini onun yapmasını istedim, bu konuda gerçekten iyi. Bu testler client/src/generators/ssr/renderRoute.test.ts içinde ve dönüşümün doğru çalıştığından emin oluyorlar. Kapsadıkları senaryolar şunlar:

  • Basit boundary değiştirme (blog listeleme)
  • Template ile kapanış yorumunun arasında içerik olan karmaşık boundary'ler
  • Birden fazla boundary
  • Yorum işaretleri olmayan boundary'ler
  • Köşe durumlar (edge case'ler)

Bu tarz bir “TDD”, beklenen girdi ve çıktının net olduğu bu tür durumlar için aslında epey kullanışlı.

Bunu “Robert C. Martin öyle dedi diye her şeyi TDD yapalım” ile karıştırmamak lazım (bu, ekibinizin geliştirme hızını yavaşlatır). UI için ya da kodunuzun sürekli değişen bölgeleri için TDD kullanmamalısınız!

Çözüm: resolveSuspenseBoundaries

Artık testler hazır olduğuna göre, resolveSuspenseBoundaries fonksiyonunu da LLM'e yazdırdım. RegEx'in kırılganlığından kaçınmak için bu işte cheerio kullanmayı seçtim, gerçi burada RegEx kullansaydım SSG süresi yaklaşık yüzde 40 kısalacaktı.

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}; }

Bu sayede arama motorları ve LLM'ler neredeyse boş bir sayfa görmek yerine, tamamen render edilmiş bir sayfa görüyor.

Artık JavaScript olmasa bile SSG gayet güzel çalışıyor! <img alt="Foony'nin blogları için JavaScript olmadan SSG" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />

Uzun vadede React, Suspense formatını değiştirebilir. Lazy-load edilen (dolayısıyla Suspense boundary'lerine ihtiyaç duyan) sayfalar için daha iyi bir çözüme ulaştığımda, bu Suspense çözme kodunu tamamen kaldırabilirim.

Hydration Stratejisi (Güncelleme: 3 Gün + 1 Ek Gün Sürdü)

Hydration zor bir iş, bunun farkındaydım. Ama biraz uğraştıktan sonra çalışır hale getirmeyi başardım!

Hydration için harcanan toplam süre: 3 gün, artı dehydration yaklaşımını değiştirmek için 1 ek gün.

En zor kısım, o ilk, minimal, çalışan hydrate'i elde etmekti. Navbar ile birlikte bir “Hello World” render etmeyi başardıktan sonra, evet, bunun tüm bir ayımı almayacağına dair güvenim geldi!

<img alt="Foony'de navbar ile birlikte başarıyla hydrate edilen Hello World" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

O ilk minimal, çalışan hydrate için özel bir zorluk vardı: Hydration istiyordum ama aynı zamanda geliştiricilerin Suspense boundary'lerini düşünmesine gerek kalmadan, arama motorları ve LLM'ler için iyi SEO da istiyordum.

Zorluk

React'in hydration'ı aşırı derecede literal çalışıyor: DOM, o ilk render'da React'in beklediği gibi görünmüyorsa, konsolunda gayet hoş ama neredeyse işe yaramayan bir hata mesajı alıyorsun ve React her şeyi çöpe atıp baştan render ediyor. Ne yanlış gittiğini görebileceğin bir diff bile yok!

Bizim durumumuzda SSG bunu birkaç açıdan daha da kötüleştirdi:

  1. React 18'in streaming Suspense artifaktlarını kaldırmak/çözmek için HTML'i sonradan işliyorduk (botlar için harika).
  2. İstemcinin, zaman (t = 0)'da sunucu render'ının sahip olduğu verilerin tamamı elinde olmuyordu (SSG verisi, blog meta verileri vs.).
  3. i18n sistemimiz varsayılan olarak “lazy”, yani SSG sırasında hangi çevirilerin kullanıldığını kaydedip React render etmeden önce bunları enjekte etmezsen, ilk render'da çeviriler eksik olabiliyor.

Ne İşe Yaradı (İlk Yaklaşım: Dehydration)

İlk başta zeki ve sevimli bir şey denedim: HTML'deki Suspense boundary'lerini çözmek için kullandığım komutları bir command pattern ile kaydettim ve HTML'i React'in hydration için ihtiyaç duyduğu hale geri getirebilmek için ters dönüşüm komutlarını döndürdüm. Umutluydum, bu komut yöntemiyle index.html içinde çok daha az byte gönderebileceğimi düşündüm. Ama çoğu “zeki” çözümde olduğu gibi bu da başarısız oldu, çünkü tarayıcılar HTML'i ufak tefek şekillerde değiştiriyor; örneğin bir ; ya da / ekleyip çıkarmak gibi, bu da değiştirme index'lerini bozuyordu. Teorik olarak bu küçük tarayıcı değişikliklerini de hesaba katabilirsin ama ben bu kadar kırılgan bir şeyi göndermeye hiç yanaşmadım. Suspense-boundary dönüşümünü React'in streaming markup'ına geri çevirmeye uğraşmak yerine, süper basit bir şey yaptım:

Orijinal, çözülmemiş HTML'i bir <script type="text"> içinde paketlemek.

Bu “dehydration” yaklaşımı işe yaradı, ama bunu daha iyi bir çözümle değiştirmek için ekstra bir gün daha harcadım.

Daha İyi Yaklaşım: Kritik Yol Suspense Boundary Değiştirme

İlk implementasyondan sonra bile Suspense boundary'leriyle ilgili bazı sorunlar yaşıyordum. O sırada fark ettim ki aslında daha temiz, daha iyi ve daha basit bir çözüm var. Dehydration yaklaşımını kritik yol Suspense boundary değiştirme ile değiştirdim, bu yaklaşım şunları yapıyor:

  • Hydration'dan önce kritik yolu yüklüyor: SSR sırasında preload edilen bileşenler belirleniyor ve hydrateRoot çağrılmadan önce istemci tarafında preload ediliyor
  • Bakımı daha basit: React'in iç detaylarıyla ya da AST parse etmekle uğraşmak gerekmiyor (dehydration yaklaşımı HTML'i parse edip geri yüklemeyi gerektiriyordu)
  • Daha az byte gönderiyor: Artık React'ten gelen orijinal SSR çıktısını bir script etiketi içinde paketlemiyoruz
  • Olası bir flaşı engelliyor: HTML'i dehydrate/re-hydrate etmeye gerek kalmıyor, olası görsel “yanıp sönme” sorununu ortadan kaldırıyor

Implementasyon, SSR sırasında hangi lazy bileşenlerin preload edildiğini (SSRLazyComponentTracker aracılığıyla) takip ediyor, import path'lerini hydration verisinin içine ekliyor ve bunları hydration'dan önce senkron şekilde preload ediyor. Kritik yoldaki bileşenler Suspense boundary olmadan direkt render ediliyor, böylece SSR çıktısıyla bire bir uyuşuyor.

Geri kalan her şey için ise ilk istemci render'ını SSR/SSG gibi davranacak şekilde ayarlıyoruz. Yani aynı girdileri kullanıyor ve bu girdileri hydrateRoot çağrılmadan senkron olarak hazır hale getiriyoruz. Bunu da “ssg-data” üzerinden paketleyerek yapıyoruz.

Somut olarak yaptığım ayarlamalar şunlardı:

  1. SSR girdilerini tek bir text script içinde paketle

    • SSG sırasında, Vite modül giriş noktasından hemen önce bir <script type="text/foony-ssg" id="foony-ssg-data">...</script> enjekte ediyoruz.
    • Bu script'in içinde şunlar var:
      • html: statik dosyada gerçekten gönderdiğimiz, çözülmüş HTML
      • ssgData: SSR wrapper'ının kullandığı, serileştirilmiş SSGData. Bunu, sadece erişilen verilerin dahil olması için Proxy benzeri bir yapıya çevirmeyi planlıyorum.
      • translationData: SSR sırasında dokunduğumuz çeviri anahtar-değer blokları
  2. Bu girdileri hydration'dan hemen önce enjekte et

    • main.tsx içinde senkron olarak şunları yapıyoruz:
      • #root.innerHTML'i serileştirilmiş, çözülmüş HTML'e ayarlıyoruz (böylece DOM, hydration'ın gördüğü şeyle bire bir aynı oluyor)
      • uygulamayı SSGDataProvider ile sarıyoruz ki bileşenler ilk render'da aynı SSGData'ya sahip olsun
  3. Çeviri değerlerini enjekte ederek i18n'i anında kullanılabilir hale getir

    • SSR sırasında erişilen gerçek çeviri objelerini kaydediyoruz ve bunları SSG script'iyle birlikte gönderiyoruz.
    • İstemci tarafında, bunları özel bir LocaleQueryer.inject() metodu ile direkt LocaleQueryer'ın cache'ine enjekte ediyoruz, böylece çeviriler anında hazır oluyor.

Ve böylece ilk render, SSR'in sahip olduğu verilerin aynısına sahip oluyor!

useIsSSRMode() hook'u zaten client/src/generators/ssr/isSSRMode.ts içinde implement edilmiş durumda:

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

Bu hook, SSR sırasında ve ilk istemci render'ında (hydration) true döndürüyor, mount olduktan sonra ise false'a geçiyor. UserBanner, Navbar ve Dialog gibi bileşenler, hydration uyuşmazlıklarını önlemek için bunu zaten kullanıyor.

  1. Daha iyi diff'ler için React'i yamala

Keşke doğrudan hydration-overlay kullanabilseydim diye umuyordum. Ama aktif olarak bakım görmüyor, sadece React 18'e kadar destekli ve production'a hazır değildi. Ben de bir LLM'e repoyu klonlatıp ilham aldırdım ve birkaç dakika içinde minimal bir hydration overlay çıkardı. Zaten süslü bir şeye ihtiyacım yoktu, sadece geliştirme sırasında ortaya çıkıp nerede neyin bozulduğunu göstermesi yeterliydi.

Bu yeni overlay aşırı basit, o yüzden diff'ler tam mükemmel değil. React yorum satırlarını kaldırıyor, style attribute'larından sonra ; ekliyor, boşlukları değiştiriyor ve daha birkaç ufak şey yapıyor, overlay'imiz bunların hepsini (henüz) hesaba katmıyor. Bizim overlay aynı zamanda React'in hydration sırasında yok saydığı HTML yorumlarını da içeriyor.

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

Ama neyin düzeltilmesi gerektiğini anlamak için fazlasıyla yeterli.

<img alt="React hydration için SSG çıktımız ile istemcinin ilk sayfa render'ı arasındaki diff" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />

Rakamlarla

Bu implementasyonun neleri içerdiğine dair bir fikir vermek için:

  • 2 gün çalışma (baştan çalışan bir SSG'ye kadar). Tatildeyken toplamda 24 saati biraz geçen bir süre.
  • Async çeviri yarışları ya da useMediaQuery ortalığı karıştırmadan hydration'ı düzgün hale getirmek için 4 gün çalışma.
  • Dehydration yaklaşımını kritik yol Suspense boundary değiştirme ile değiştirmek için 1 ekstra gün (daha basit, daha az byte, olası flash yok).
  • Çekirdek SSG üretim kodu için ~200 satır (GenerateShellSsgFromSitemap.ts)
  • Suspense boundary çözümü için ~120 satır (renderRoute.tsx içindeki resolveSuspenseBoundaries) - Not: Bu daha sonra kritik yol yaklaşımıyla değiştirildi
  • SSR yardımcıları için ~50 satır (isSSRMode.ts)
  • Testler için ~100 satır (renderRoute.test.ts)
  • SSR için polyfill'ler adına ~150 satır (setupSSREnvironment)
  • Mevcut bileşenlere minimum değişiklik (çoğunlukla useIsSSRMode() kontrolleri eklemek)

Çözüm hafif ve bakımı kolay. Herhangi bir framework geçişi gerektirmiyor ve mevcut React SPA'mızla uyumlu şekilde çalışıyor.

Öne Çıkan Noktalar

Bazen Özel Bir Çözüm Daha İyidir

Her sorun için bir framework'e ihtiyacın yok. Foony için küçük, özel bir SSG çözümü doğru tercihti. Çünkü:

  • Hafif: Ağır bağımlılıklar ya da framework yükü yok
  • Bakımı kolay: Anladığımız, basit kod
  • Esnek: İhtiyaç oldukça değiştirmesi ve genişletmesi kolay
  • Uyumlu: Mevcut React SPA'mızla, geçiş gerektirmeden çalışıyor

React'in Streaming SSR'ının Tuhaflıkları Var

React'in renderToReadableStream fonksiyonu Suspense ile uğraşmak için gayet hoş, ama tuhaflıkları var. await stream.allReady kullansan bile çıktıda hâlâ Suspense boundary'leri geliyor. Bu bir bug değil, streaming için tasarlanmış olmasının bir sonucu. Ama SSG için tam çözülmüş HTML'e ihtiyacımız var. React ekibinin bu senaryoyu temiz bir şekilde ele almamış olması bence biraz hayal kırıklığı.

Benim çözümüm, HTML'i sonradan işleyip boundary'leri çözmek oldu. Estetik değil ama benim kullanım senaryom için yeterince hızlı ve esnek.

TDD, LLM'ler İçin Faydalı Olabiliyor

HTML dönüşümü hata yapmaya çok açık. Küçük bir bug, tüm SSG çıktısını bozup son kullanıcı deneyimini mahvedebilir. Dönüşümün düzgün çalıştığından emin olmak için (benim yönlendirmemle) kapsamlı testleri bir LLM'e yazdırdım.

Sonuç

Artık Foony için SSG çalışıyor. Sayfalar arama motorları ve LLM'ler için tamamen render edilmiş halde ve çözüm hem bakımı kolay hem de hafif. SSG rotaları için hydration beklediğimden uzun sürdü (3 gün) ve ilk dehydration yaklaşımını kritik yol Suspense boundary değiştirme ile değiştirmek için de ekstra bir gün harcadım. Yeni yaklaşımın bakımı daha kolay, daha az byte gönderiyor ve HTML'i dehydrate/re-hydrate etmekten kaynaklanabilecek görsel flaşları engelliyor.

Hâlâ, SSG için özel bir çözümü sadece 2 günde hayata geçirmiş olmama biraz şaşkınım. Ama bazen en doğru çözüm, en basit olanı oluyor.

İleride yapılacak işler arasında hydration eşleşmesini tamamen oturtmak ve belki daha iyi debug için React'i yamamak var. Ama şimdilik Foony'nin çalışan bir SSG'si var. Önümüzdeki haftalarda bunun SEO'muza ne etkisi olacağını görmek için Google Search Console ve Bing Webmaster Tools'u yakından izleyeceğim.

8 Ball Pool online multiplayer billiards icon