background blurbackground mobile blur

1/1/1970

Hur jag implementerade SSG på 2 dagar

Hallå där! För ett år sedan trodde jag att det här var omöjligt. Men jag har precis blivit klar med att implementera Static Site Generation (SSG) för Foony på 2 dagar, och jag är rätt peppad över det. Det här är inte första gången jag försöker lösa SSG för Foony heller. Jag har kikat på NextJS, Vike, Astro, Gatsby och några andra lösningar tidigare. Jag gjorde till och med en falsk start med NextJS, men körde fast på grund av komplexiteten i Foonys SPA och tusentals filer. Migreringen hade varit en mardröm och tagit månader. Den hade dessutom gjort allt krångligare för alla andra som jobbar på sajten, eftersom de hade behövt lära sig NextJS och alla dess egenheter.

Jag ville ha något lätt och enkelt att implementera. Något som lät oss fortsätta skriva kod på samma sätt som vi redan gör utan att behöva tänka på SSG (med undantag för useMediaQuery – där finns det egentligen inget sätt runt). Nedan går jag igenom varför jag valde en skräddarsydd lösning, vilka specifika utmaningar jag stötte på (särskilt med Reacts Suspense-boundaries) och hur jag löste dem.

Varför inte standardlösningar?

När jag först funderade på att lägga till SSG i Foony tittade jag förstås på NextJS (branschstandard), Vike och Astro.

NextJS: För mycket migrering

NextJS är kraftfullt, men det hade krävt en enorm migrering av Foonys befintliga React-SPA. Vi har tusentals filer, komplex routerlogik och en massa egen infrastruktur. Att migrera till NextJS hade inneburit:

  • Att skriva om hela vårt routersystem
  • Göra om hur vi laddar spel och komponenter
  • Månader av jobb bara för att komma tillbaka till samma funktionsnivå som innan
  • Risk för att saker går sönder för användarna
  • Ändra hur vi hanterar bilder
  • Betydligt långsammare byggen (potentiellt 5-30 minuter. Jag har inga konkreta siffror på det här annat än den här 5 år gamla diskussionen på GitHub)
  • Hela teamet hade behövt lära sig något nytt (NextJS), och långsammare utvecklingstakt för alltid
  • Migrera koden varje gång NextJS bestämmer sig för att göra breaking changes.

Jag gjorde till och med en falsk start med NextJS, men insåg snabbt att migrationskostnaden var alldeles för hög. Komplexiteten var inte värd det.

Vike: Liknande komplexitet

Vike (tidigare vite-plugin-ssr) hade liknande problem. Även om det är mer flexibelt än NextJS hade det fortfarande krävt en rejäl omstrukturering av vår kodbas. Inlärningskurvan och allt migrationsjobb vägde inte upp för fördelarna.

Astro: Fel arkitektur

Astro är grymt för innehållstunga sajter, men Foony är en komplex multiplayer-spelplattform. Vi behöver uppdateringar i realtid, WebSocket-anslutningar och dynamiska React-komponenter. Astros arkitektur passar helt enkelt inte det vi bygger.

Lösningen: skräddarsydd SSG

Stärkt av min "fake SSG"-lösning som jag byggde för några dagar sedan efter i18n, landade jag i en liten, lättviktig, skräddarsydd lösning för Foonys SSG.

Min "fake SSG"-approach gick ut på att hämta blogginnehållet från sidor med blogginlägg (/posts-routes och spelsidor) och placera det exakt där klienten skulle rendera det, specifikt för att sökmotorer och LLMs lättare ska förstå Foony. Den lade också på ld+json-schema och lite smått SEO-godis.

Upplägget är enkelt:

  1. Bygg ovanpå den befintliga React-SPA:n: Ingen migrering behövs, bara lägg till SSG-generering vid build.
  2. Använd renderToReadableStream: React 18:s streaming-SSR-API hanterar Suspense direkt.
  3. Generera statiska HTML-filer: Förrendera routes vid build och serva dem som statiska filer, med vår SitemapGenerator för att få en lista över routes.
  4. Minimala ändringar i den befintliga kodbasen: De flesta komponenter funkar som de är.

Själva kärnimplementeringen bor i client/src/generators/GenerateShellSsgFromSitemap.ts. Den läser en sitemap, renderar varje route med Reacts renderToReadableStream och skriver HTML:en till statiska filer. Enkelt, precis som jag vill ha det!

Det visade sig bli rätt snabbt också. Ungefär 2 800 routes renderades på 10 sekunder. Najs. Det är betydligt snabbare än NextJS, Gatsby och Astro. <img alt="SSG-konsollogg som visar hur lång tid det tog" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />

Jag skulle kunna prata hur länge som helst om enkelhet. Även om det kanske inte ger dig en befordran på stora företag på grund av "brist på komplexitet" är enkel kod vacker, lätt att underhålla och i allmänhet mycket bättre för utvecklingstakten. Det här är något jag verkligen gillar med Zen-principerna.

Problemet med Suspense-boundaries

Så nu hade jag SSG, och innehållet dök upp i HTML... men mina sidor var tomma! Hur då?! <img alt="Tom SSG-sida" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blank_page.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

Det visade sig att renderToReadableStream fortfarande har Suspense-boundaries, även om du await stream.allReady. Min gissning är att det beror på att det är en "stream" och att den är gjord för att skickas till klienten i takt med att bytes kommer in.

Vad React spottar ur sig

När du använder renderToReadableStream med Suspense får du HTML som ser ut ungefär så här:

<!--$?-->
<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"> är en placeholder där innehållet ska hamna. <div hidden id="S:0"> innehåller det faktiska renderade innehållet. B:0 matchar S:0 via numret (0-baserat index).

Utan JavaScript ser sökmotorer (tittar på dig, Bing) och LLMs nästan bara en tom sida med en placeholder-template. Det skjuter hela poängen med SSG i sank.

Jag såg inget snyggt sätt att ta bort de här Suspense-boundaries, så min lösning var att skriva några tester och en resolveSuspenseBoundaries-funktion som byter ut dem. Det här var snabbare än att parsa HTML:en och köra scriptet med något som JSDOM. Och ännu viktigare: det var ett krav för det jag hade tänkt mig, nämligen en trevlig, läsbar sajt för sökmotorer / LLMs utan JavaScript, men med stöd för Suspense-boundaries och hydration i klienten.

Testa transformationen

Jag började med att skriva tester för transformationen genom att plocka exempel ur DOM:en från hur det såg ut då (JavaScript avstängt) och hur jag ville att det skulle se ut (JavaScript på). Jag matade in de här exemplen i en LLM och lät den sköta testgenereringen, något den faktiskt är rätt bra på. De här testerna ligger i client/src/generators/ssr/renderRoute.test.ts och ser till att transformationen funkar som den ska. Testerna täcker:

  • Enkel boundary-ersättning (blogglistning)
  • Komplexa boundaries med innehåll mellan template och avslutande kommentar
  • Flera boundaries
  • Boundaries utan kommentarmarkörer
  • Edge cases

Den här typen av "TDD" är faktiskt rätt användbar i just det här fallet där du har förväntade indata och utdata.

Det här ska inte blandas ihop med "TDD överallt bara för att Robert C. Martin sa det" (som bara sänker utvecklingstakten i ditt team). Du ska INTE använda TDD för UI eller delar av koden som hela tiden ändras!

Lösningen: resolveSuspenseBoundaries

När testerna väl var på plats lät jag LLM:en skriva funktionen för resolveSuspenseBoundaries. Jag valde cheerio för att slippa skörheten i RegEx, även om RegEx här skulle kapa SSG-tiden med ungefär 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}; }

Det här gör att sökmotorer och LLMs ser en fullt renderad sida i stället för en nästan tom.

Nu har vi SSG som funkar fint även utan JavaScript! <img alt="SSG för Foonys bloggar utan JavaScript" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.blog_ssg.webp" style={{ margin: "8px auto", height: 340, display: "block" }} />

På sikt är det möjligt att React ändrar sitt Suspense-format. Jag kanske tar bort koden som löser upp Suspense när jag har en bättre lösning för de sidor som är lazy-laddade (och som därför behöver Suspense-boundaries).

Hydration-strategi (uppdatering: det här tog 3 dagar + 1 extra dag)

Hydration är knepigt. Det visste jag. Men efter lite jobb fick jag det att funka!

Total tid för hydration: 3 dagar, plus 1 extra dag för att ersätta dehydration-upplägget.

Det knepigaste var bara att få till den allra första minimala, fungerande hydreringen. När jag väl lyckades rendera ett "Hello World" tillsammans med navbaren fick jag självförtroende nog att tänka att okej, det här kanske inte tar en hel månad ändå!

<img alt="Foonys Hello World som hydrerar lyckat tillsammans med navbaren" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_mvp.webp" style={{ margin: "8px auto", height: 205, display: "block" }} />

För den där första minimala, fungerande hydreringen hade jag en speciell utmaning: jag ville ha hydration, men jag ville också ha bra SEO för sökmotorer och LLMs utan att utvecklarna skulle behöva tänka på Suspense-boundaries.

Utmaningen

React-hydration är extremt bokstavlig: om DOM:en inte ser ut exakt som React förväntar sig vid den första renderingen får du ett fint men nästan värdelöst felmeddelande i konsolen, och React slänger allt och renderar om från scratch. Inte ens en diff som berättar vad som gick fel!

I vårt fall gjorde SSG det här värre på ett par sätt:

  1. Vi post-processade HTML:en för att ta bort/lösa upp React 18:s streaming-Suspense-artefakter (vilket är toppen för bottar).
  2. Klienten hade inte alltid exakt samma data tillgänglig vid tidpunkt (t = 0) som server-renderingen hade (SSG-data, blogg-metadata osv).
  3. Vår i18n är "lazy" som standard, vilket betyder att översättningar kan saknas vid första renderingen om du inte spelar in vilka översättningar som användes vid SSG och stoppar in dem innan React renderar.

Vad som funkade (första upplägget: dehydration)

Först testade jag något lite smart och gulligt: jag använde ett command pattern för att spela in kommandona som användes för att lösa upp HTML:ens Suspense-boundaries, och skickade tillbaka omvända transformationskommandon så att jag kunde återställa HTML:en till det React behöver för hydration. Min förhoppning var att jag skulle kunna skeppa mycket färre bytes i index.html med den här kommando-metoden. Men som med de flesta smarta lösningar sprack det, eftersom webbläsare ändrar HTML:en på små sätt, som att ta bort eller lägga till ett ; eller /, vilket sabbade alla ersättningsindex. Tekniskt sett skulle man säkert kunna kompensera för de här små webbläsarförändringarna, men jag tänkte inte skeppa något som var så skört. I stället för att försöka "backa" Suspense-boundary-transformationen tillbaka till Reacts streaming-markup gjorde jag något superenkelt:

Bunta ihop den ursprungliga, olösta HTML:en i ett <script type="text">.

Den här "dehydration"-approachen funkade, men jag lade en extra dag på att ersätta den med en bättre lösning.

Den bättre lösningen: ersättning av Suspense-boundaries på kritiska vägen

Efter den första implementationen stötte jag fortfarande på problem med Suspense-boundaries. Då insåg jag att det fanns en renare, bättre och enklare lösning. Jag ersatte dehydration-upplägget med ersättning av Suspense-boundaries på kritiska vägen, som:

  • Laddar den kritiska vägen före hydration: Komponenter som förladdades under SSR identifieras och förladdas på klienten innan hydrateRoot anropas
  • Är enklare att underhålla: Inga React-internals eller AST-parsning krävs (dehydration-upplägget behövde parsa och återställa HTML)
  • Skeppar färre bytes: Vi buntar inte längre med det ursprungliga SSR-svaret från React i ett script-tag
  • Förhindrar en möjlig flash: Vi behöver inte dehydrera/rehydrera HTML, vilket tar bort en potentiell visuell flash

Implementationen håller koll på vilka lazy-komponenter som förladdades under SSR (via SSRLazyComponentTracker), stoppar in deras import-sökvägar i hydration-datat och förladdar dem synkront före hydration. Komponenter på den kritiska vägen renderas direkt utan Suspense-boundaries, så de matchar SSR-utdata exakt.

För allt annat låter vi den första klient-renderingen bete sig som SSR/SSG. Det betyder att vi använder samma indata och gör dem tillgängliga synkront innan hydrateRoot. Det här görs genom att vi buntar ihop det via vår "ssg-data".

Mer konkret såg justeringarna ut så här:

  1. Bunta SSR-indatan i ett enda text-script

    • Under SSG injicerar vi ett <script type="text/foony-ssg" id="foony-ssg-data">...</script> precis före Vite-modulens entrypoint.
    • Det scriptet innehåller:
      • html: den upplösta HTML:en som vi faktiskt skeppade i den statiska filen
      • ssgData: den serialiserade SSGData som används av SSR-wrappen. Jag funderar på att uppdatera det här till en Proxy eller liknande så att bara åtkomna data följer med.
      • translationData: de översättningsblobbar (nyckel-värde) vi rörde vid under SSR
  2. Injicera den där indatan precis före hydration

    • I main.tsx gör vi synkront:
      • sätter #root.innerHTML till den serialiserade upplösta HTML:en (så att DOM:en är exakt det hydration ser)
      • wrappar appen i SSGDataProvider så att komponenterna har samma SSGData vid första renderingen
  3. Gör i18n direkt genom att injicera översättningsvärden

    • Vi spelar in de faktiska översättningsobjekten som användes under SSR och skeppar dem i SSG-scriptet.
    • På klienten stoppar vi in dem direkt i LocaleQueryer-cachen via en dedikerad LocaleQueryer.inject()-metod, så att översättningarna finns där direkt.

Och med det har första renderingen samma data som SSR hade!

useIsSSRMode()-hooken är redan implementerad i 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;
}

Den här hooken returnerar true under SSR och vid första klient-renderingen (hydration), och slår sedan om till false efter mount. Komponenter som UserBanner, Navbar och Dialog använder redan det här för att undvika hydration-mismatchar.

  1. Patcha React för bättre diffar

Jag hoppades att jag bara skulle kunna använda hydration-overlay. Men det underhålls inte aktivt, har bara stöd upp till React 18 och var inte produktionsredo. Så jag lät en LLM klona repot som inspiration, och den skapade sedan ett minimalt hydration-overlay på några minuter. Jag behövde inget avancerat, bara något som dyker upp under utveckling så att jag kan se var saker går fel.

Det här nya overlayet är superenkelt, så diffarna är inte helt perfekta. React tar bort kommentarer, lägger till ; efter style-attribut, ändrar whitespace och gör några andra småsaker som vårt overlay inte tar höjd för (än). Vårt overlay inkluderar också HTML-kommentarer som React ignorerar vid hydration.

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

Men det är tillräckligt bra för att se vad som behöver fixas.

<img alt="diff mellan vår SSG och klientens första sid-rendering för React-hydration" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.hydration_diff.webp" style={{ margin: "8px auto", height: 85, display: "block" }} />

I siffror

För att ge en känsla för vad den här implementationen innebar:

  • 2 dagar jobb (från start till fungerande SSG). Det här var strax över 24 timmar medan jag var på semester.
  • 4 dagar jobb för att få hydration att bete sig snyggt utan async-översättningsrace eller att useMediaQuery ställer till det.
  • 1 extra dag för att ersätta dehydration-upplägget med Suspense-boundaries på kritiska vägen (enklare, färre bytes, ingen potentiell flash).
  • ~200 rader kärn-SSG-genereringskod (GenerateShellSsgFromSitemap.ts)
  • ~120 rader Suspense-boundary-lösning (resolveSuspenseBoundaries i renderRoute.tsx) - Obs: Det här ersattes senare av kritiska-vägen-upplägget
  • ~50 rader SSR-hjälpfunktioner (isSSRMode.ts)
  • ~100 rader tester (renderRoute.test.ts)
  • ~150 rader polyfills för SSR (setupSSREnvironment)
  • Minimala ändringar i befintliga komponenter (mest att lägga till useIsSSRMode()-checks)

Lösningen är lättviktig och lätt att underhålla. Den kräver ingen ramverksmigrering och funkar ihop med vår befintliga React-SPA.

Viktiga lärdomar

Ibland är en skräddarsydd lösning bättre

Alla problem behöver inte ett ramverk. För Foony var en liten, skräddarsydd SSG-lösning rätt val. Den är:

  • Lättviktig: Inga tunga beroenden eller ramverks-overhead
  • Lätt att underhålla: Enkel kod som vi förstår
  • Flexibel: Lätt att ändra och bygga vidare på vid behov
  • Kompatibel: Funkar med vår befintliga React-SPA utan migrering

Reacts streaming-SSR har sina egenheter

Reacts renderToReadableStream är trevlig för att hantera Suspense, men har sina egenheter. Även med await stream.allReady får du fortfarande Suspense-boundaries i utdata. Det här är inte en bugg, det är medvetet för streaming. Men för SSG behöver vi helt upplösta HTML-sidor. Det känns som ett misslyckande från React-teamet att inte hantera det här scenariot på ett snyggt sätt.

Min lösning var att post-processa HTML:en och lösa upp boundaries. Det är inte vackert, men det är snabbt och tillräckligt flexibelt för mitt use case.

TDD kan vara användbart tillsammans med LLMs

HTML-transformation är lätt att göra fel på. En liten bugg och du kan sabba hela SSG-utdata och därmed användarupplevelsen. Jag lät en LLM skriva täckande tester (med min input) för att se till att transformationen fungerar som den ska.

Slutsats

SSG funkar nu för Foony. Sidorna är fullt renderade för sökmotorer och LLMs, och lösningen är lätt att underhålla och lättviktig. Hydration för SSG-routes tog längre tid än jag trodde (3 dagar), och jag lade en extra dag på att ersätta den första dehydration-approachen med Suspense-boundaries på kritiska vägen. Den nya metoden är enklare att underhålla, skeppar färre bytes och förhindrar potentiella visuella flashes från att dehydrera/rehydrera HTML.

Jag är fortfarande lite chockad över att det bara tog 2 dagar att implementera en skräddarsydd lösning för SSG. Men ibland är den rätta lösningen helt enkelt den enklaste.

Framöver tänker jag bland annat färdigställa hydration-matchningen och kanske patcha React för bättre debugging. Men just nu har Foony fungerande SSG. Jag kommer att hålla ett öga på Google Search Console och Bing Webmaster Tools de kommande veckorna för att se vilken effekt det här får på vår SEO.

8 Ball Pool online multiplayer billiards icon