background blurbackground mobile blur

1/1/1970

Come ho implementato l’SSG in 2 giorni

Ehilà! Un anno fa pensavo che fosse impossibile. Ma ho appena finito di implementare la Static Site Generation (SSG) per Foony in 2 giorni e ne sono molto contento. Non è nemmeno la prima volta che provo a risolvere l’SSG per Foony. In passato ho dato un’occhiata a NextJS, Vike, Astro, Gatsby e qualche altra soluzione. Avevo perfino iniziato una migrazione con NextJS, ma mi sono scontrato con la complessità della SPA di Foony e con le migliaia di file. La migrazione sarebbe stata un incubo e avrebbe richiesto mesi. Avrebbe anche aggiunto complessità per chiunque lavori al sito, perché tutti avrebbero dovuto imparare NextJS e le sue stranezze.

Volevo qualcosa di leggero e facile da implementare. Qualcosa che ci permettesse di continuare a scrivere il codice esattamente come abbiamo sempre fatto, senza dover pensare all’SSG (a parte useMediaQuery--lì non c’è davvero modo di evitarlo). Qui sotto spiego perché ho scelto una soluzione su misura, quali problemi specifici ho incontrato (soprattutto con le Suspense boundaries di React) e come li ho risolti.

Perché non usare le soluzioni standard?

Quando ho iniziato a pensare di aggiungere l’SSG a Foony, ho ovviamente preso in considerazione NextJS (lo standard del settore), Vike e Astro.

NextJS: troppa migrazione

NextJS è potente, ma avrebbe richiesto una migrazione enorme dell’attuale SPA React di Foony. Abbiamo migliaia di file, logica di routing complessa e un sacco di infrastruttura personalizzata. Migrare a NextJS avrebbe significato:

  • Riscrivere completamente il nostro sistema di routing
  • Ristrutturare il modo in cui carichiamo giochi e componenti
  • Mesi di lavoro solo per tornare allo stesso livello di funzionalità
  • Potenziali cambiamenti distruttivi per gli utenti
  • Cambiare il modo in cui gestiamo le immagini
  • Tempi di build decisamente più lenti (potenzialmente dai 5 ai 30 minuti. Non ho numeri concreti per dimostrarlo, a parte questa discussione di 5 anni fa su GitHub)
  • Tutto il team costretto a imparare qualcosa di nuovo (NextJS) e velocità di sviluppo più lenta per sempre
  • Migrare il codice ogni volta che NextJS decide di introdurre breaking changes.

Ho anche provato a partire con NextJS, ma mi sono reso conto in fretta che il costo della migrazione era troppo alto. La complessità non ne valeva la pena.

Vike: complessità simile

Vike (prima vite-plugin-ssr) aveva problemi simili. Anche se è più flessibile di NextJS, avrebbe comunque richiesto una ristrutturazione importante del nostro codebase. La curva di apprendimento e lo sforzo di migrazione non giustificavano i benefici.

Astro: architettura sbagliata

Astro è fantastico per i siti pieni di contenuti, ma Foony è una piattaforma complessa di giochi multiplayer. Abbiamo bisogno di aggiornamenti in tempo reale, connessioni WebSocket e componenti React dinamici. L’architettura di Astro semplicemente non si adatta a quello che stiamo costruendo.

La soluzione: SSG su misura

Fortificato dall’esperimento di “fake SSG” che avevo implementato pochi giorni prima dopo l’i18n, ho deciso di puntare su una soluzione SSG piccola, leggera e su misura per Foony.

Il mio approccio di “fake SSG” consisteva nel prendere il contenuto degli articoli del blog dalle pagine che li contengono (route /posts e pagine dei giochi) e posizionarlo esattamente dove il client lo avrebbe renderizzato, specificamente per permettere ai motori di ricerca e agli LLM di capire meglio Foony. Applicava anche lo schema ld+json e qualche piccola cosa di SEO.

L’approccio è semplice:

  1. Costruire sopra la SPA React esistente: nessuna migrazione necessaria, basta aggiungere la generazione SSG in fase di build.
  2. Usare renderToReadableStream: la API di streaming SSR di React 18 gestisce nativamente le Suspense.
  3. Generare file HTML statici: fare il prerender delle route in fase di build e servirle come file statici, usando il nostro SitemapGenerator per ottenere la lista delle route.
  4. Cambiamenti minimi al codebase esistente: la maggior parte dei componenti funziona così com’è.

L’implementazione principale vive in client/src/generators/GenerateShellSsgFromSitemap.ts. Legge una sitemap, renderizza ogni route usando il renderToReadableStream di React e scrive l’HTML in file statici. Semplice, proprio come piace a me!

Alla fine è venuto fuori che è anche piuttosto veloce. Circa 2.800 route renderizzate in 10 secondi. Niente male. È significativamente più rapido di NextJS, Gatsby e Astro. <img alt="Log della console dell’SSG che mostra il tempo impiegato" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />

Potrei parlare all’infinito della semplicità. Anche se non ti farà ottenere una promozione nelle grandi aziende per via della “mancanza di complessità”, il codice semplice è bello, facile da mantenere e in generale molto migliore per la velocità di sviluppo. È una cosa che apprezzo davvero tanto dei principi Zen.

Il problema delle Suspense boundary

Quindi ora avevo l’SSG e il contenuto compariva nell’HTML... ma le mie pagine erano vuote! Come è possibile?! <img alt="Pagina vuota prodotta dall’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" }} />

Si è scoperto che renderToReadableStream ancora ha le Suspense boundary, anche se fai await stream.allReady. La mia ipotesi è che sia perché è uno “stream” ed è pensato per essere passato ai client man mano che arrivano i byte.

Cosa produce React

Quando usi renderToReadableStream con le Suspense, React produce un HTML del genere:

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

Il <template id="B:0"> è un segnaposto dove dovrebbe andare il contenuto. Il <div hidden id="S:0"> contiene il contenuto effettivamente renderizzato. B:0 corrisponde a S:0 per numero (indice a partire da 0).

Senza JavaScript, i motori di ricerca (ti guardo, Bing) e gli LLM vedrebbero una pagina quasi vuota con solo il segnaposto del template. Il che vanifica completamente lo scopo dell’SSG!

Non vedevo un modo pulito per rimuovere queste Suspense boundary, quindi la mia soluzione è stata scrivere qualche test e una funzione resolveSuspenseBoundaries per sostituirle. Era più veloce che fare il parsing dell’HTML ed eseguire lo script con qualcosa tipo JSDOM. E, cosa ancora più importante, era necessario per quello che avevo in mente: un sito ben leggibile per motori di ricerca / LLM senza JavaScript, ma con supporto per le Suspense boundary e l’hydration sul client.

Testare la trasformazione

Ho iniziato scrivendo dei test per la trasformazione, prendendo alcuni esempi dal DOM di com’era la pagina (con JavaScript disattivato) e di come la volevo (con JavaScript attivato). Ho passato questi esempi a un LLM e gli ho fatto generare i test, cosa in cui se la cava piuttosto bene. Questi test vivono in client/src/generators/ssr/renderRoute.test.ts e verificano che la trasformazione funzioni correttamente. I test coprono:

  • Sostituzione semplice delle boundary (lista dei post del blog)
  • Boundary complesse con contenuto tra il template e il commento di chiusura
  • Boundary multiple
  • Boundary senza i commenti di marcatura
  • Casi limite

Questo tipo di “TDD” è in realtà molto utile in casi come questo, in cui hai input e output attesi ben definiti.

Da non confondere con il “TDD ovunque perché lo ha detto Robert C. Martin” (che rallenterà la velocità di sviluppo del tuo team). Non dovresti usare il TDD per la UI o per le parti del codice che cambiano continuamente!

La soluzione: resolveSuspenseBoundaries

Ora che i test erano pronti, ho fatto scrivere all’LLM la funzione resolveSuspenseBoundaries. Ho scelto cheerio per evitare la fragilità delle RegEx, anche se usare le RegEx qui ridurrebbe il tempo di SSG di circa il 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}; }

Questo assicura che, invece di vedere una pagina quasi vuota, i motori di ricerca e gli LLM vedano una pagina completamente renderizzata.

Ora abbiamo un SSG che funziona bene anche senza JavaScript! <img alt="SSG senza JavaScript per i blog di 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" }} />

A lungo termine è possibile che React cambi il formato delle Suspense. Potrei rimuovere il codice che risolve le Suspense una volta che avrò una soluzione migliore per le pagine caricate in lazy (e quindi che richiedono le Suspense boundary).

Strategia di hydration (aggiornamento: ci sono voluti 3 giorni + 1 giorno extra)

L’hydration è una bella sfida. Lo sapevo. Ma dopo un po’ di lavoro sono riuscito a farla funzionare!

Tempo totale per l’hydration: 3 giorni, più 1 giorno extra per sostituire l’approccio di dehydration.

La parte più difficile è stata ottenere quel primo hydrate minimale ma funzionante. Una volta che sono riuscito a renderizzare un “Hello World” con la navbar, ho iniziato a credere che sì, forse non mi ci sarebbe voluto un mese intero!

<img alt="L’Hello World di Foony che fa hydration correttamente con la 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" }} />

Per quel primo hydrate minimale ma funzionante avevo una sfida particolare: volevo l’hydration, ma volevo anche un buon SEO per motori di ricerca e LLM, senza che gli sviluppatori dovessero preoccuparsi delle Suspense boundary.

La sfida

L’hydration di React è estremamente letterale: se il DOM non assomiglia a quello che React si aspetta per quel primo render, ti becchi un bel messaggio di errore quasi inutile in console e React butta via tutto e fa di nuovo il render da zero. Neanche un diff per farti capire cosa è andato storto!

Nel nostro caso, l’SSG peggiorava la cosa in vari modi:

  1. Post-processavamo l’HTML per rimuovere/rendere definitive le Suspense di streaming di React 18 (ottimo per i bot).
  2. Il client non aveva sempre a disposizione esattamente gli stessi dati al tempo (t = 0) che aveva il render lato server (dati SSG, metadati del blog, ecc.).
  3. Il nostro i18n è “lazy” di default, il che significa che le traduzioni possono mancare al primo render, a meno che tu non registri quali traduzioni sono state usate per l’SSG e non le inietti prima che React faccia il render.

Cosa ha funzionato (approccio iniziale: dehydration)

All’inizio ho provato qualcosa di ingegnoso e carino: ho usato un command pattern per registrare i comandi usati per risolvere le Suspense boundary nell’HTML e ho restituito i comandi di trasformazione inversi così da poter ripristinare l’HTML nello stato che React si aspetta per l’hydration. Speravo di poter spedire molti meno byte in index.html con questo sistema a comandi. Ma, come succede spesso con le soluzioni troppo furbe, è fallito perché i browser modificano l’HTML in modi sottili, ad esempio rimuovendo o aggiungendo un ; o uno /, il che mandava fuori scala gli indici di sostituzione. Tecnicamente potresti anche tener conto di questi piccoli cambiamenti del browser, ma non avevo alcuna intenzione di mettere in produzione qualcosa di così fragile. Invece di cercare di “invertire” la trasformazione delle Suspense boundary per tornare al markup di streaming di React, ho fatto qualcosa di super semplice:

Impacchettare l’HTML originale, non risolto, in uno <script type="text">.

Questo approccio di “dehydration” funzionava, ma ho passato un giorno in più a sostituirlo con una soluzione migliore.

L’approccio migliore: sostituzione delle Suspense boundary nel critical path

Dopo la prima implementazione avevo ancora qualche problema con le Suspense boundary. È lì che ho capito che esisteva una soluzione più pulita, migliore e più semplice. Ho sostituito l’approccio di dehydration con la sostituzione delle Suspense boundary nel critical path, che:

  • Carica il critical path prima dell’hydration: i componenti precaricati durante l’SSR vengono identificati e precaricati sul client prima che venga chiamato hydrateRoot
  • È più semplice da mantenere: non richiede di toccare gli internals di React o fare il parsing dell’AST (l’approccio di dehydration aveva bisogno di fare il parsing e ripristinare l’HTML)
  • Spedisce meno byte: non includiamo più la risposta SSR originale di React in uno script tag
  • Evita un possibile flash: non c’è bisogno di fare dehydrate/rehydrate dell’HTML, eliminando un possibile flash visivo

L’implementazione tiene traccia di quali componenti lazy sono stati precaricati durante l’SSR (tramite SSRLazyComponentTracker), include i loro import path nei dati di hydration e li precarica in modo sincrono prima dell’hydration. I componenti del critical path vengono renderizzati direttamente senza Suspense boundary, corrispondendo esattamente all’output dell’SSR.

Per tutto il resto facciamo sì che il primo render sul client si comporti come SSR/SSG. Questo significa usare gli stessi input e renderli disponibili in modo sincrono prima di chiamare hydrateRoot. Lo facciamo impacchettando tutto tramite la nostra “ssg-data”.

Concretamente, le modifiche sono state:

  1. Impacchettare gli input dell’SSR in un unico script di testo

    • Durante l’SSG iniettiamo uno <script type="text/foony-ssg" id="foony-ssg-data">...</script> subito prima dell’entrypoint del modulo Vite.
    • Quello script contiene:
      • html: l’HTML risolto che effettivamente spediamo nel file statico
      • ssgData: l’SSGData serializzato usato dal wrapper SSR. Ho in mente di aggiornarlo a una Proxy o qualcosa del genere così da includere solo i dati effettivamente usati.
      • translationData: i blob chiave-valore di traduzioni che abbiamo toccato durante l’SSR
  2. Iniettare quegli input appena prima dell’hydration

    • In main.tsx, in modo sincrono:
      • impostiamo #root.innerHTML all’HTML risolto serializzato (così il DOM è esattamente ciò che vede l’hydration)
      • wrappiamo l’app in SSGDataProvider così i componenti hanno lo stesso SSGData al primo render
  3. Rendere l’i18n istantaneo iniettando i valori di traduzione

    • Registriamo gli oggetti di traduzione effettivamente usati durante l’SSR e li spediamo nello script SSG.
    • Sul client li iniettiamo direttamente nella cache di LocaleQueryer tramite un metodo dedicato LocaleQueryer.inject(), così le traduzioni sono disponibili subito.

E con questo il primo render ha gli stessi dati che aveva l’SSR!

L’hook useIsSSRMode() è già implementato in 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;
}

Questo hook restituisce true durante l’SSR e al primo render sul client (hydration), poi passa a false dopo il mount. Componenti come UserBanner, Navbar e Dialog lo usano già per evitare mismatch di hydration.

  1. Patchare React per avere diff migliori

Speravo di poter semplicemente usare hydration-overlay. Ma non è mantenuto attivamente, è supportato solo fino a React 18 e non era pronto per la produzione. Quindi ho fatto clonare il repo a un LLM per prendere ispirazione e in pochi minuti ha creato un overlay di hydration minimale. Non mi serviva niente di sofisticato--solo qualcosa che comparisse in sviluppo per capire dove le cose andavano storte.

Questo nuovo overlay è super basilare, quindi i diff non sono proprio perfetti. React rimuove i commenti, aggiunge dei ; dopo gli attributi style, modifica gli spazi e fa un paio di altre piccole cose che il nostro overlay ancora non gestisce. Il nostro overlay include anche i commenti HTML che React ignora durante l’hydration.

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

Ma è sufficiente per capire cosa c’è da sistemare.

<img alt="diff tra il nostro SSG e il primo render client per l’hydration di 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" }} />

I numeri

Per dare un’idea di cosa ha richiesto questa implementazione:

  • 2 giorni di lavoro (dall’inizio a un SSG funzionante). Poco più di 24 ore, mentre ero in vacanza.
  • 4 giorni di lavoro per far funzionare bene l’hydration senza race condition sulle traduzioni async o useMediaQuery che incasina tutto.
  • 1 giorno extra per sostituire l’approccio di dehydration con la sostituzione delle Suspense boundary nel critical path (più semplice, meno byte, nessun potenziale flash).
  • ~200 righe di codice per la generazione SSG core (GenerateShellSsgFromSitemap.ts)
  • ~120 righe per la risoluzione delle Suspense boundary (resolveSuspenseBoundaries in renderRoute.tsx) - Nota: in seguito è stato sostituito dall’approccio del critical path
  • ~50 righe di utility per l’SSR (isSSRMode.ts)
  • ~100 righe di test (renderRoute.test.ts)
  • ~150 righe di polyfill per l’SSR (setupSSREnvironment)
  • Cambiamenti minimi ai componenti esistenti (per lo più aggiungendo controlli con useIsSSRMode())

La soluzione è leggera e facile da mantenere. Non richiede la migrazione verso un framework e funziona con la nostra SPA React esistente.

Cosa portarsi a casa

A volte una soluzione su misura è meglio

Non ogni problema ha bisogno di un framework. Per Foony, una piccola soluzione SSG su misura è stata la scelta giusta. È:

  • Leggera: niente dipendenze pesanti o overhead da framework
  • Manutenibile: codice semplice che capiamo bene
  • Flessibile: facile da modificare ed estendere quando serve
  • Compatibile: funziona con la nostra SPA React esistente senza migrazione

La SSR in streaming di React ha le sue stranezze

Il renderToReadableStream di React è comodo per gestire le Suspense, ma ha le sue stranezze. Anche con await stream.allReady hai comunque le Suspense boundary nell’output. Non è un bug, è fatto apposta per lo streaming. Ma per l’SSG abbiamo bisogno di un HTML completamente risolto. Sembra quasi una mancanza da parte del team di React il fatto di non gestire questo scenario in modo pulito.

La mia soluzione è stata fare un post-processing dell’HTML e risolvere le boundary. Non è elegantissima, ma è veloce e abbastanza flessibile per il mio caso d’uso.

Il TDD può essere utile con gli LLM

La trasformazione dell’HTML è soggetta a errori. Un piccolo bug e rischi di rompere tutto l’output dell’SSG e l’esperienza dell’utente finale. Ho fatto scrivere a un LLM una batteria di test completa (a partire dai miei esempi) per assicurarmi che la trasformazione funzionasse correttamente.

Conclusione

L’SSG ora funziona per Foony. Le pagine sono completamente renderizzate per motori di ricerca e LLM e la soluzione è manutenibile e leggera. L’hydration per le route SSG ha richiesto più tempo del previsto (3 giorni) e ho passato un giorno extra a sostituire l’approccio iniziale di dehydration con la sostituzione delle Suspense boundary nel critical path. Il nuovo approccio è più semplice da mantenere, spedisce meno byte e previene possibili flash visivi dovuti al dehydrate/rehydrate dell’HTML.

Mi sorprende ancora che ci siano voluti solo 2 giorni per implementare una soluzione SSG su misura. Ma a volte la soluzione giusta è proprio quella più semplice.

I lavori futuri includono completare il matching dell’hydration e magari patchare React per avere un debugging migliore. Ma per ora Foony ha un SSG funzionante. Nelle prossime settimane terrò d’occhio Google Search Console e Bing Webmaster Tools per vedere che effetto avrà tutto questo sulla nostra SEO.

8 Ball Pool online multiplayer billiards icon