background blurbackground mobile blur

1/1/1970

Comment j'ai implémenté le SSG en 2 jours

Salut ! Il y a un an, j'aurais pensé que c'était impossible. Mais je viens de terminer l'implémentation du Static Site Generation (SSG) pour Foony en 2 jours, et je suis plutôt excité par le résultat. Ce n'est pas ma première tentative pour ajouter du SSG à Foony, d'ailleurs. J'ai déjà regardé NextJS, Vike, Astro, Gatsby et quelques autres solutions. J'avais même commencé une intégration avec NextJS, mais je me suis vite retrouvé coincé avec la complexité de la SPA de Foony et des milliers de fichiers. La migration aurait été un cauchemar et aurait pris des mois. Ça aurait aussi ajouté une couche de complexité pour tout le reste de l'équipe, qui aurait dû apprendre NextJS et toutes ses petites bizarreries.

Je voulais quelque chose de léger et facile à mettre en place. Un truc qui nous permette de continuer à écrire du code comme on le fait déjà, sans devoir penser au SSG (à part pour useMediaQuery... pas vraiment moyen d'y couper). Plus bas, je détaille pourquoi je suis parti sur une solution maison, les problèmes spécifiques que j'ai rencontrés (surtout avec les Suspense boundaries de React) et comment je les ai réglés.

Pourquoi pas les solutions standard ?

Quand j'ai commencé à réfléchir à ajouter du SSG à Foony, j'ai naturellement envisagé NextJS (le standard du secteur), Vike et Astro.

NextJS : trop de migration

NextJS est puissant, mais il aurait fallu migrer une énorme partie de la SPA React existante de Foony. On a des milliers de fichiers, une logique de routage complexe et pas mal d'infra maison. Passer à NextJS aurait voulu dire :

  • Réécrire tout notre système de routage
  • Restructurer la façon dont on charge les jeux et les composants
  • Des mois de travail juste pour retrouver la même base de fonctionnalités
  • Des changements potentiellement cassants pour les utilisateurs
  • Modifier la façon dont on gère les images
  • Des temps de build bien plus lents (potentiellement 5 à 30 minutes. Je n'ai pas de chiffres concrets pour le prouver, à part cette discussion de 5 ans sur GitHub)
  • Toute l'équipe qui doit apprendre un nouveau truc (NextJS), et une vélocité dev plus lente pour toujours
  • Devoir migrer le code à chaque fois que NextJS décide d'introduire des breaking changes.

J'ai même commencé une intégration avec NextJS, mais je me suis vite rendu compte que le coût de migration était beaucoup trop élevé. La complexité ne valait clairement pas le coup.

Vike : complexité similaire

Vike (anciennement vite-plugin-ssr) avait des problèmes similaires. Même si c'est plus flexible que NextJS, ça aurait quand même demandé une grosse restructuration du codebase. La courbe d'apprentissage et l'effort de migration n'étaient pas justifiés par les bénéfices.

Astro : pas la bonne architecture

Astro est super pour les sites très orientés contenu, mais Foony est une plateforme de jeux multijoueurs complexe. On a besoin de mises à jour temps réel, de connexions WebSocket, et de composants React dynamiques. L'architecture d'Astro ne colle tout simplement pas à ce qu'on construit.

La solution : du SSG sur-mesure

Porté par mon approche de "fake SSG" que j'ai implémentée quelques jours plus tôt après l'i18n, j'ai opté pour une petite solution maison, légère, dédiée au SSG de Foony.

Mon approche de "fake SSG" consistait à récupérer le contenu des articles de blog depuis les pages qui en contiennent (routes /posts et pages de jeux), et à les positionner exactement là où le client les rendrait, spécifiquement pour que les moteurs de recherche et les LLMs comprennent mieux Foony. Ça ajoutait aussi du schéma ld+json et quelques petits trucs d'optimisation SEO.

L'approche est simple :

  1. Se baser sur la SPA React existante : aucune migration, on ajoute juste la génération SSG au moment du build.
  2. Utiliser renderToReadableStream : l'API de SSR en streaming de React 18 gère nativement Suspense.
  3. Générer des fichiers HTML statiques : on pré-rend les routes au build et on les sert en fichiers statiques, en utilisant notre SitemapGenerator pour récupérer la liste des routes.
  4. Changer le minimum de choses dans le code existant : la plupart des composants fonctionnent tels quels.

L'implémentation principale se trouve dans client/src/generators/GenerateShellSsgFromSitemap.ts. Elle lit un sitemap, rend chaque route avec renderToReadableStream de React, et écrit le HTML dans des fichiers statiques. Simple, comme j'aime !

Et en plus c'est plutôt rapide. Environ 2 800 routes rendues en 10 secondes. Pas mal. C'est nettement plus rapide que NextJS, Gatsby et Astro. <img alt="Log console du SSG montrant le temps de rendu" loading="lazy" src="/img/posts/en/how-i-implemented-ssg-in-2-days.ssg_speed.webp" style={{ margin: "8px auto", height: 120, display: "block" }} />

Je pourrais parler de simplicité pendant des heures. Même si ça ne vous vaudra pas forcément une promotion dans les grosses boîtes à cause du "manque de complexité", du code simple est beau, maintenable, et en général bien meilleur pour la vélocité des développeurs. C'est quelque chose que j'admire beaucoup dans les principes Zen.

Le problème des Suspense boundaries

Donc maintenant j'avais du SSG, et le contenu apparaissait bien dans le HTML... mais mes pages étaient vides ! Comment ça ?! <img alt="Page vide avec 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" }} />

En fait, renderToReadableStream garde des Suspense boundaries, même si on fait un await stream.allReady. À mon avis, c'est parce que c'est un "stream", pensé pour être envoyé directement au client au fur et à mesure que les octets arrivent.

Ce que React génère

Quand on utilise renderToReadableStream avec Suspense, React renvoie du HTML de ce genre :

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

Le <template id="B:0"> est un placeholder à l'endroit où le contenu est censé apparaître. Le <div hidden id="S:0"> contient le contenu réellement rendu. Le B:0 correspond à S:0 par numéro (index à partir de 0).

Sans JavaScript, les moteurs de recherche (coucou Bing) et les LLMs ne verraient qu'une page quasiment vide avec juste le placeholder du template. Ça casse complètement l'intérêt du SSG !

Je n'ai pas trouvé de façon propre de supprimer ces Suspense boundaries, donc j'ai écrit quelques tests et une fonction resolveSuspenseBoundaries pour les remplacer. C'était plus rapide que parser le HTML et exécuter le script avec un truc comme JSDOM. Et surtout, c'était nécessaire pour ce que je voulais : un site lisible pour les moteurs de recherche et les LLMs sans JavaScript, mais avec support des Suspense boundaries et de l'hydratation côté client.

Tester la transformation

J'ai commencé par écrire des tests pour la transformation en récupérant des exemples dans le DOM de ce que j'avais (JavaScript désactivé) et de ce que je voulais (JavaScript activé). J'ai donné tout ça à un LLM et je lui ai fait générer les tests, ce qu'il fait plutôt bien. Ces tests sont dans client/src/generators/ssr/renderRoute.test.ts et vérifient que la transformation fonctionne correctement. Ils couvrent :

  • Le remplacement simple de boundary (listing de blog)
  • Des boundaries complexes avec du contenu entre le template et le commentaire de fermeture
  • Plusieurs boundaries
  • Des boundaries sans marqueurs de commentaire
  • Des cas limites

Ce genre de "TDD" est vraiment utile dans ce type de situation où on a des entrées et sorties attendues bien définies.

À ne pas confondre avec "faire du TDD partout parce que Robert C. Martin l'a dit" (ce qui va ralentir la vélocité de votre équipe). Vous NE devriez PAS faire du TDD pour l'UI ou les parties de votre code qui changent tout le temps !

La solution : resolveSuspenseBoundaries

Une fois les tests prêts, j'ai demandé au LLM d'écrire la fonction resolveSuspenseBoundaries. J'ai choisi cheerio pour éviter la fragilité des RegEx, même si en utilisant des RegEx ici on aurait pu réduire le temps de SSG d'environ 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}; }

Grâce à ça, au lieu de voir une page quasiment vide, les moteurs de recherche et les LLMs voient une page entièrement rendue.

Maintenant, on a un SSG qui marche bien même sans JavaScript ! <img alt="SSG sans JavaScript pour les blogs de 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" }} />

Sur le long terme, il est possible que React change le format de Suspense. Je retirerai peut-être le code de résolution de Suspense une fois que j'aurai une meilleure solution pour les pages chargées en lazy (qui ont donc besoin de Suspense boundaries).

Stratégie d'hydratation (mise à jour : ça m'a pris 3 jours + 1 jour en plus)

L'hydratation, c'est costaud. Je le savais. Mais après un peu de travail, j'ai réussi à la faire fonctionner !

Temps total pour l'hydratation : 3 jours, plus 1 jour en plus pour remplacer l'approche de déshydratation.

La partie la plus délicate, c'était d'obtenir ce premier hydrate minimal mais fonctionnel. Une fois que j'ai réussi à rendre un "Hello World" avec la navbar, j'ai eu la confirmation que, oui, ça ne me prendrait peut-être pas un mois entier !

<img alt="Hello World de Foony hydraté avec succès avec 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" }} />

Pour ce premier hydrate minimal fonctionnel, j'avais un défi un peu particulier : je voulais l'hydratation, mais je voulais aussi un bon SEO pour les moteurs de recherche et les LLMs, sans que les développeurs aient à se soucier des Suspense boundaries.

Le défi

L'hydratation de React est extrêmement littérale : si le DOM ne ressemble pas exactement à ce que React attend pour ce premier rendu, vous avez droit à un super message d'erreur quasi inutile dans la console, et React jette tout pour tout rerendre depuis zéro. Même pas un diff pour vous dire ce qui a cloché !

Dans notre cas, le SSG rendait les choses encore plus compliquées pour plusieurs raisons :

  1. On post-traitait le HTML pour supprimer / résoudre les artefacts Suspense du streaming React 18 (ce qui est top pour les bots).
  2. Le client n'avait pas toujours exactement les mêmes données disponibles au temps (t = 0) que le rendu serveur (données SSG, métadonnées de blog, etc).
  3. Notre i18n est "lazy" par défaut, ce qui veut dire que des traductions peuvent manquer au premier rendu, sauf si on enregistre lesquelles ont été utilisées pendant le SSG et qu'on les injecte avant que React ne rende.

Ce qui a marché (approche initiale : déshydratation)

Au début, j'ai essayé un truc malin et un peu mignon : j'ai utilisé un pattern de commandes pour enregistrer les commandes utilisées pour résoudre les Suspense boundaries dans le HTML, puis renvoyer les commandes inverses pour pouvoir restaurer le HTML dans l'état attendu par React pour l'hydratation. J'espérais pouvoir envoyer beaucoup moins d'octets dans index.html avec cette méthode de commandes. Mais, comme souvent avec les solutions trop malignes, ça a échoué parce que les navigateurs modifient le HTML de façon subtile, par exemple en enlevant ou ajoutant un ; ou un /, ce qui cassait complètement les indices de remplacement. Techniquement, on pourrait probablement tenir compte de ces petits changements de navigateur, mais je n'avais aucune envie de mettre en production quelque chose d'aussi fragile. Au lieu d'essayer de "revenir en arrière" depuis la transformation des Suspense boundaries vers le markup de streaming de React, j'ai fait un truc ultra simple :

Intégrer le HTML original, non résolu, dans un <script type="text">.

Cette approche de "déshydratation" fonctionnait, mais j'ai passé une journée de plus à la remplacer par une meilleure solution.

La meilleure approche : remplacement des Suspense boundaries sur le chemin critique

Après la première implémentation, j'avais encore quelques soucis avec les Suspense boundaries. C'est là que je me suis rendu compte qu'il y avait une solution plus propre, meilleure et plus simple. J'ai remplacé l'approche de déshydratation par un remplacement des Suspense boundaries sur le chemin critique, qui :

  • Charge le chemin critique avant l'hydratation : les composants préchargés pendant la SSR sont identifiés et préchargés côté client avant d'appeler hydrateRoot
  • Est plus simple à maintenir : pas besoin de toucher aux entrailles de React ni de parser de l'AST (l'approche de déshydratation devait parser et restaurer du HTML)
  • Envoie moins d'octets : on n'embarque plus la réponse SSR originale de React dans une balise script
  • Évite un flash visuel potentiel : plus besoin de déshydrater / réhydrater le HTML, ce qui évite un éventuel flash visuel

L'implémentation suit les composants lazy qui ont été préchargés pendant la SSR (via SSRLazyComponentTracker), inclut leurs chemins d'import dans les données d'hydratation, et les précharge de façon synchrone avant l'hydratation. Les composants du chemin critique se rendent directement sans Suspense boundaries, ce qui correspond exactement à la sortie SSR.

Pour tout le reste, on fait en sorte que le premier rendu côté client se comporte comme de la SSR / du SSG. Ça veut dire utiliser les mêmes entrées, et rendre ces entrées disponibles de façon synchrone avant hydrateRoot. On fait ça en les embarquant dans notre "ssg-data".

Concrètement, les ajustements sont :

  1. Embarquer les entrées de la SSR dans un seul script texte

    • Pendant le SSG, on injecte un <script type="text/foony-ssg" id="foony-ssg-data">...</script> juste avant le point d'entrée Vite.
    • Ce script contient :
      • html : le HTML résolu qu'on a réellement servi dans le fichier statique
      • ssgData : le SSGData sérialisé utilisé par le wrapper SSR. Je prévois de transformer ça en Proxy ou autre, pour n'inclure que les données réellement utilisées.
      • translationData : les blobs de traductions clé-valeur utilisés pendant la SSR
  2. Injecter ces entrées juste avant l'hydratation

    • Dans main.tsx, on :
      • assigne de façon synchrone #root.innerHTML au HTML résolu sérialisé (pour que le DOM corresponde exactement à ce que voit l'hydratation)
      • encapsule l'app dans SSGDataProvider pour que les composants aient le même SSGData au premier rendu
  3. Rendre l'i18n instantané en injectant les traductions

    • On enregistre les objets de traduction réellement utilisés pendant la SSR et on les embarque dans le script SSG.
    • Côté client, on les injecte directement dans le cache de LocaleQueryer via une méthode dédiée LocaleQueryer.inject(), ce qui rend les traductions disponibles immédiatement.

Et avec ça, le premier rendu a les mêmes données que la SSR !

Le hook useIsSSRMode() est déjà implémenté dans 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;
}

Ce hook renvoie true pendant la SSR et lors du premier rendu côté client (hydratation), puis passe à false après le montage. Des composants comme UserBanner, Navbar et Dialog l'utilisent déjà pour éviter les mismatches d'hydratation.

  1. Patcher React pour avoir de meilleurs diffs

J'espérais pouvoir utiliser directement hydration-overlay. Mais il n'est pas vraiment maintenu, supporte uniquement React 18 et n'était pas prêt pour la prod. Du coup, j'ai demandé à un LLM de cloner le dépôt pour s'en inspirer, puis il a créé un mini hydration overlay en quelques minutes. Je n'avais pas besoin d'un truc sophistiqué, juste de quelque chose qui apparaisse en développement pour m'aider à voir où ça coinçait.

Ce nouvel overlay est ultra basique, donc les diffs ne sont pas tout à fait parfaits. React supprime les commentaires, ajoute des ; après les attributs style, modifie les espaces, et fait encore deux ou trois petites choses que notre overlay ne gère pas (pas encore). Notre overlay inclut aussi des commentaires HTML que React ignore pour son hydratation.

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

Mais c'est largement suffisant pour comprendre ce qu'il faut corriger.

<img alt="diff entre notre SSG et le premier rendu client pour l'hydratation 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" }} />

Les chiffres

Pour donner une idée de ce que cette implémentation représente :

  • 2 jours de travail (du début au SSG fonctionnel). Un peu plus de 24 heures, pendant des vacances.
  • 4 jours de travail pour que l'hydratation se comporte bien, sans courses asynchrones de traductions ni useMediaQuery qui vient tout casser.
  • 1 jour en plus pour remplacer l'approche de déshydratation par le remplacement des Suspense boundaries sur le chemin critique (plus simple, moins d'octets, aucun flash potentiel).
  • ~200 lignes de code pour le cœur de la génération SSG (GenerateShellSsgFromSitemap.ts)
  • ~120 lignes pour la résolution des Suspense boundaries (resolveSuspenseBoundaries dans renderRoute.tsx) - Note : ça a ensuite été remplacé par l'approche du chemin critique
  • ~50 lignes d'utilitaires SSR (isSSRMode.ts)
  • ~100 lignes de tests (renderRoute.test.ts)
  • ~150 lignes de polyfills pour la SSR (setupSSREnvironment)
  • Des changements minimes sur les composants existants (principalement l'ajout de checks useIsSSRMode())

La solution est légère et facile à maintenir. Elle ne demande aucune migration de framework et fonctionne avec notre SPA React existante.

À retenir

Parfois, une solution maison est meilleure

Tous les problèmes ne nécessitent pas un framework. Pour Foony, une petite solution SSG sur-mesure était le bon choix. Elle est :

  • Légère : pas de dépendances lourdes ni de surcouche de framework
  • Maintenable : du code simple qu'on comprend
  • Flexible : facile à modifier et à étendre selon les besoins
  • Compatible : fonctionne avec notre SPA React existante sans migration

Le streaming SSR de React a ses bizarreries

Le renderToReadableStream de React est pratique pour gérer Suspense, mais il a ses petits défauts. Même avec await stream.allReady, on se retrouve quand même avec des Suspense boundaries dans la sortie. Ce n'est pas un bug, ça fait partie du design pour le streaming. Mais pour du SSG, on a besoin d'un HTML entièrement résolu. Ça donne un peu l'impression que l'équipe React n'a pas vraiment pensé à ce cas d'usage.

Ma solution a été de post-traiter le HTML et de résoudre ces boundaries. Ce n'est pas super élégant, mais c'est rapide et suffisamment flexible pour mon cas.

Le TDD peut bien aider avec les LLMs

Transformer du HTML, c'est casse-gueule. Un seul petit bug et vous pouvez casser toute la sortie SSG et ruiner l'expérience utilisateur. J'ai utilisé un LLM pour écrire des tests complets (avec mes exemples) afin de m'assurer que la transformation fonctionnait correctement.

Conclusion

Le SSG fonctionne maintenant pour Foony. Les pages sont entièrement rendues pour les moteurs de recherche et les LLMs, et la solution reste légère et maintenable. L'hydratation des routes SSG m'a pris plus de temps que prévu (3 jours), et j'ai passé un jour de plus à remplacer l'approche de déshydratation par le remplacement des Suspense boundaries sur le chemin critique. La nouvelle approche est plus simple à maintenir, envoie moins d'octets et évite les éventuels flashes visuels liés à la déshydratation / réhydratation du HTML.

Je suis encore surpris que ça n'ait pris que 2 jours pour implémenter une solution SSG sur-mesure. Mais parfois, la bonne solution, c'est simplement la plus simple.

Pour la suite, il reste à finaliser l'alignement complet de l'hydratation et, potentiellement, à patcher React pour un meilleur debug. Mais pour l'instant, Foony a un SSG qui tourne. Je vais garder un œil sur la Google Search Console et les Bing Webmaster Tools dans les prochaines semaines pour voir l'effet sur notre SEO.

8 Ball Pool online multiplayer billiards icon