Cours JavaScript et React · Module 10: Écosystème et avenir de React

Streaming SSR et Edge Runtime

10 min de lecture
Dans cette leçon8

Imagine qu'au lieu d'envoyer le vaisseau spatial entier en un seul transport, tu l'envoies module par module - le cockpit immédiatement, tandis que les systèmes de navigation et les soutes arrivent en arrière-plan. C'est exactement ainsi que fonctionne le Streaming SSR - le serveur commence à envoyer le HTML au navigateur avant que le rendu complet soit terminé. Et l'Edge Runtime rapproche ce processus de l'utilisateur - comme des stations relais réparties à travers la galaxie.

SSR traditionnel vs Streaming SSR

Le SSR traditionnel génère tout le HTML sur le serveur avant de l'envoyer au navigateur. L'utilisateur attend le composant le plus lent :

1// SSR traditionnel - l'utilisateur attend TOUT
2// Serveur : [rendu Header][rendu Données][rendu Footer] -> envoi de l'ensemble
3// Temps : 500ms + 2000ms + 100ms = 2600ms avant de voir quoi que ce soit
4
5// Streaming SSR - fragments envoyés immédiatement
6// Serveur : [Header prêt] -> envoi ! [Données prêtes] -> envoi ! [Footer] -> envoi !
7// Temps : 500ms jusqu'au premier fragment, le reste arrive en arrière-plan

renderToPipeableStream - L'API de streaming

React 18 a introduit renderToPipeableStream, le successeur en streaming de renderToString, qui devait rendre toute la page avant d'envoyer le premier octet :

1import { renderToPipeableStream } from 'react-dom/server';
2
3// Serveur Node.js avec Streaming SSR
4app.get('/', (req, res) => {
5  const { pipe } = renderToPipeableStream(
6    <App />,
7    {
8      bootstrapScripts: ['/main.js'],
9      onShellReady() {
10        // La coque (shell) est prête - envoi immédiat
11        res.setHeader('Content-Type', 'text/html');
12        pipe(res);
13      },
14      onShellError(error) {
15        // Erreur dans la partie critique - envoi d'un fallback
16        res.statusCode = 500;
17        res.send('<h1>Server Error</h1>');
18      },
19      onAllReady() {
20        // Tout est prêt (utile pour les robots d'indexation et le SEO)
21      },
22      onError(error) {
23        console.error('Streaming error:', error);
24      }
25    }
26  );
27});

onShellReady est appelé lorsque le « shell » principal de la page est prêt - tout ce qui se trouve en dehors des limites <Suspense>. L'utilisateur voit immédiatement le squelette de la page.

renderToReadableStream - L'API Edge Runtime

Pour l'Edge Runtime (Cloudflare Workers, Vercel Edge, Deno Deploy), on utilise renderToReadableStream, qui renvoie une Web Streams API :

1import { renderToReadableStream } from 'react-dom/server';
2
3// Handler pour l'Edge Runtime
4export default async function handler(request) {
5  const stream = await renderToReadableStream(
6    <App />,
7    {
8      bootstrapScripts: ['/main.js'],
9      onError(error) {
10        console.error('Edge streaming error:', error);
11      }
12    }
13  );
14
15  return new Response(stream, {
16    headers: { 'Content-Type': 'text/html' },
17  });
18}

Différence entre les API :

  • renderToPipeableStream - Node.js Streams (runtime Node.js)
  • renderToReadableStream - Web Streams API (Edge Runtime, Deno, navigateurs)

Depuis React 19.2, renderToReadableStream fonctionne aussi dans Node.js, mais l'équipe React y recommande toujours renderToPipeableStream : les streams Node.js sont plus rapides dans Node, et les Web Streams ne prennent pas en charge la compression par défaut.

Suspense comme limites de streaming

<Suspense> définit les limites de streaming - le serveur envoie un fallback, puis le remplace par le HTML une fois prêt :

1function SpaceStationDashboard() {
2  return (
3    <div>
4      {/* Rendu immédiatement - fait partie de la coque (shell) */}
5      <Header title="Station spatiale Alpha" />
6      <Navigation />
7
8      {/* Chaque section arrive en streaming indépendamment */}
9      <Suspense fallback={<Skeleton type="missions" />}>
10        <MissionList />  {/* Données chargées en 800ms */}
11      </Suspense>
12
13      <Suspense fallback={<Skeleton type="crew" />}>
14        <CrewStatus />   {/* Données chargées en 1500ms */}
15      </Suspense>
16
17      <Suspense fallback={<Skeleton type="telemetry" />}>
18        <TelemetryData />  {/* Données chargées en 2200ms */}
19      </Suspense>
20    </div>
21  );
22}
23
24// Déroulement du streaming :
25// 1. [0ms]    Le serveur envoie : Header + Navigation + 3x Skeleton
26// 2. [800ms]  Le serveur envoie : MissionList (remplace le Skeleton)
27// 3. [1500ms] Le serveur envoie : CrewStatus (remplace le Skeleton)
28// 4. [2200ms] Le serveur envoie : TelemetryData (remplace le Skeleton)

Erreurs et données dans les frontières Suspense

Suspense ne gère que l'attente des données. Si la récupération échoue, le composant lève une erreur, et c'est l'Error Boundary le plus proche qui la capture. C'est pourquoi on enveloppe une section lente dans deux couches : un Error Boundary à l'extérieur et Suspense à l'intérieur. Dans un tel composant, tu récupères commodément les données avec le hook useSuspenseQuery de TanStack Query, qui suspend le composant jusqu'à l'arrivée des données :

1import { Suspense } from 'react';
2import { ErrorBoundary } from 'react-error-boundary';
3import { useSuspenseQuery } from '@tanstack/react-query';
4
5function MissionList() {
6  // data est toujours défini : Suspense gère le chargement et l'Error Boundary les erreurs
7  const { data } = useSuspenseQuery({ queryKey: ['missions'], queryFn: fetchMissions });
8
9  return (
10    <ul>
11      {data.map(mission => (
12        <li key={mission.id}>{mission.name}</li>
13      ))}
14    </ul>
15  );
16}
17
18function MissionsSection() {
19  return (
20    <ErrorBoundary fallback={<MissionsError />}>
21      <Suspense fallback={<Skeleton type="missions" />}>
22        <MissionList />
23      </Suspense>
24    </ErrorBoundary>
25  );
26}

Pendant le chargement, tu vois le squelette de Suspense, et quand la requête échoue, l'ErrorBoundary du paquet react-error-boundary affiche MissionsError à la place de toute la section. Côté serveur, cela fonctionne de façon semblable : si un composant à l'intérieur d'une frontière Suspense lève une erreur pendant le streaming, React n'interrompt pas la réponse, il envoie le fallback de cette frontière et retente le rendu dans le navigateur. Ce n'est que si cela échoue aussi que l'erreur atteint l'Error Boundary le plus proche. Une erreur dans la coque elle-même, hors de toute frontière, déclenche onShellError.

Edge Runtime - Des calculs plus proches de l'utilisateur

L'Edge Runtime est un environnement d'exécution léger qui tourne à la « périphérie » du réseau, sur des serveurs CDN répartis dans le monde entier, près de l'utilisateur. Au lieu d'un Node.js complet, il exécute le code dans des isolats du moteur V8 (le même moteur qui exécute le JavaScript dans Chrome) et lui fournit des Web APIs : fetch, Request, Response et les Web Streams. Un isolat démarre environ cent fois plus vite qu'un processus Node.js, c'est pourquoi un démarrage à froid ne prend ici que quelques millisecondes. Voici à quoi ressemble le streaming de React dans Cloudflare Workers :

1// worker.jsx - Cloudflare Workers (isolats V8, Web APIs au lieu de Node.js)
2import { renderToReadableStream } from 'react-dom/server';
3import App from './App';
4
5export default {
6  async fetch(request) {
7    // Cloudflare ajoute à la requête les données de localisation de l'utilisateur
8    const city = request.cf?.city ?? 'ville inconnue';
9
10    const stream = await renderToReadableStream(<App city={city} />, {
11      bootstrapScripts: ['/main.js'],
12    });
13
14    return new Response(stream, {
15      headers: { 'Content-Type': 'text/html; charset=utf-8' },
16    });
17  },
18};

Le worker n'a accès ni au système de fichiers ni aux modules Node.js, mais il répond depuis le serveur le plus proche de l'utilisateur. La localisation vient ici de la plateforme, via request.cf, et non de React. Dans Next.js, l'objet request.geo a disparu dans la version 15, et sur Vercel tu lis la ville avec la fonction geolocation() du paquet @vercel/functions.

Différences : Node.js vs Edge Runtime

Le tableau montre ce que tu paies pour un démarrage instantané à la périphérie du réseau. Les temps de démarrage à froid sont indicatifs et dépendent de la plateforme :

CaractéristiqueRuntime Node.jsEdge Runtime
EmplacementUne ou plusieurs régionsServeurs CDN dans le monde entier
Démarrage à froid200-500 ms (fonction serverless froide)1-5 ms (isolat V8)
Taille du codeLimite élevée (par ex. 250 Mo sur Vercel)1-4 Mo après compression (Vercel, selon l'offre)
APIAPI Node.js complèteWeb APIs, sans système de fichiers ni modules Node.js
StreamingrenderToPipeableStreamrenderToReadableStream
Bases de donnéesN'importe laquellePilotes qui passent par HTTP (par ex. Turso, Neon)

L'Edge Runtime dans Next.js 16

L'avantage du démarrage rapide se réduit, car les plateformes raccourcissent aussi le démarrage à froid de Node.js. Fluid compute sur Vercel garde le bytecode compilé en cache et préchauffe les fonctions en production, c'est pourquoi Vercel recommande aujourd'hui de migrer les fonctions de l'Edge vers Node.js. Next.js a pris le même chemin : dans la version 16, le fichier middleware.ts a été renommé proxy.ts et tourne toujours sur Node.js, et dans la version 16.3 l'export runtime = 'edge' dans les fichiers de page et de route handler est devenu obsolète. Next.js affiche un avertissement et conseille de le supprimer, car le runtime Node.js par défaut ne demande aucune configuration, et Vercel exécute ces routes sur Node.js :

1// proxy.js (Next.js 16, anciennement middleware.js) - toujours le runtime Node.js
2import { NextResponse } from 'next/server';
3
4export function proxy(request) {
5  const isDashboard = request.nextUrl.pathname.startsWith('/dashboard');
6
7  // Sans session, redirige vers la connexion avant que la page commence son rendu
8  if (isDashboard && !request.cookies.has('session')) {
9    return NextResponse.redirect(new URL('/login', request.url));
10  }
11  return NextResponse.next();
12}

Le proxy s'exécute avant le rendu de chaque route, donc dans un vrai projet restreins-le avec l'option matcher de l'objet config. L'Edge Runtime ne disparaît pas pour autant de l'écosystème : il reste l'environnement naturel des plateformes bâties sur des isolats, comme Cloudflare Workers ou Deno Deploy, et renderToReadableStream y fonctionne sans changement.

Hydratation progressive

Le Streaming SSR prend naturellement en charge l'hydratation progressive - React hydrate les composants au fur et à mesure qu'ils apparaissent :

1// Déroulement de l'hydratation progressive :
2// 1. Le serveur envoie le HTML en streaming -> le navigateur l'affiche
3// 2. Le JavaScript se charge
4// 3. React hydrate les composants déjà présents dans le DOM
5// 4. De nouveaux fragments HTML arrivent par le stream
6// 5. React les hydrate dès leur réception
7
8// Hydratation sélective - React donne la priorité
9// aux composants avec lesquels l'utilisateur interagit :
10function App() {
11  return (
12    <div>
13      {/* L'utilisateur a cliqué ici - React hydrate cette partie EN PREMIER */}
14      <Suspense fallback={<Skeleton />}>
15        <InteractivePanel />
16      </Suspense>
17
18      {/* Ceci attend son tour */}
19      <Suspense fallback={<Skeleton />}>
20        <HeavyDataTable />
21      </Suspense>
22    </div>
23  );
24}

Récapitulatif

Le Streaming SSR et l'Edge Runtime sont des outils puissants pour accélérer la livraison des applications :

  1. Streaming SSR - envoie le HTML en fragments, l'utilisateur voit la page immédiatement
  2. renderToPipeableStream - API Node.js pour le streaming
  3. renderToReadableStream - API Web Streams (Edge Runtime, Cloudflare Workers, Deno)
  4. Frontières Suspense - définissent les limites du streaming, et un Error Boundary placé au-dessus capture les erreurs
  5. Edge Runtime - du code proche de l'utilisateur et un démarrage à froid rapide, mais dans Next.js 16 l'environnement par défaut et recommandé est Node.js
  6. Hydratation progressive - React hydrate les composants au fur et à mesure qu'ils apparaissent

Le Streaming SSR transforme la livraison des applications, à l'image des systèmes modulaires d'une station spatiale - chaque module se lance indépendamment, l'utilisateur n'attend pas l'ensemble. Dans l'exemple ci-dessous, les sections arrivent dans l'ordre où leurs données sont prêtes, et si tu coches la panne du relais puis envoies une nouvelle requête, tu verras l'Error Boundary capturer l'erreur d'une section pendant que le reste de la page continue de fonctionner.

Code de cette leçon : App.jsx
1import { Component, Suspense, use, useCallback, useEffect, useState } from 'react';
2
3// === SSR EN STREAMING - une simulation dans le navigateur ===
4// La coque (en-tête et mise en page) s’affiche tout de suite, et chaque frontière
5// Suspense montre un squelette tant que ses données ne sont pas arrivées. Les sections
6// arrivent dans l’ordre où leurs données sont prêtes, pas dans leur ordre sur la page.
7
8const DATA = {
9  missions: [
10    { id: 1, name: 'Expédition vers Mars', status: 'active', eta: '47 jours' },
11    { id: 2, name: 'Sonde Europe', status: 'planifiée', eta: '182 jours' },
12    { id: 3, name: 'Base lunaire', status: 'terminée', eta: '-' },
13  ],
14  crew: [
15    { id: 1, name: 'Commandant Nova', role: 'Chef de mission', ready: true },
16    { id: 2, name: 'Astro', role: 'Navigateur', ready: true },
17    { id: 3, name: 'Ra', role: 'Guide', ready: false },
18  ],
19  telemetry: [
20    { label: 'Oxygène', value: 96 },
21    { label: 'Carburant', value: 78 },
22    { label: 'Structure', value: 99 },
23    { label: 'Liaison', value: 87 },
24  ],
25  earth: 'Le centre Terre confirme le cap. Bonne chance, équipage !',
26};
27
28const DELAYS = { missions: 600, earth: 900, crew: 1200, telemetry: 2000 };
29
30// Une promesse par section et par requête. La même promesse au rendu suivant
31// permet au hook use() de lire les données prêtes.
32const requests = new Map();
33
34// Début de la requête en cours (pour la chronologie)
35let requestStart = performance.now();
36
37function loadSection(section, requestKey, fail = false) {
38  const key = section + ':' + requestKey;
39  if (!requests.has(key)) {
40    requests.set(key, new Promise((resolve, reject) => {
41      setTimeout(() => {
42        if (fail) reject(new Error('Aucun signal du relais en orbite'));
43        else resolve(DATA[section]);
44      }, DELAYS[section]);
45    }));
46  }
47  return requests.get(key);
48}
49
50// Error Boundary : capture l’erreur levée pendant le rendu d’une section
51class RelayBoundary extends Component {
52  constructor(props) {
53    super(props);
54    this.state = { error: null };
55  }
56
57  static getDerivedStateFromError(error) {
58    return { error };
59  }
60
61  componentDidCatch(error) {
62    this.props.onError(error.message);
63  }
64
65  render() {
66    if (this.state.error) {
67      return (
68        <div style={styles.errorBox}>
69          <strong>Liaison interrompue</strong>
70          <p style={{ margin: '4px 0 8px' }}>{this.state.error.message}</p>
71          <button
72            style={styles.smallButton}
73            onClick={() => {
74              this.props.onRetry();
75              this.setState({ error: null });
76            }}
77          >
78            Réessayer
79          </button>
80        </div>
81      );
82    }
83    return this.props.children;
84  }
85}
86
87function Skeleton({ label }) {
88  return (
89    <div style={styles.skeleton}>
90      <div style={styles.shimmer} />
91      <p style={styles.skeletonLabel}>En attente de la section : {label}...</p>
92    </div>
93  );
94}
95
96function MissionPanel({ requestId, onReady }) {
97  const missions = use(loadSection('missions', requestId));
98  useEffect(() => {
99    onReady('Missions', '#00ff88');
100  }, [onReady]);
101
102  return (
103    <div style={styles.panel}>
104      <h3 style={styles.panelTitle}>Missions (600 ms)</h3>
105      {missions.map(m => (
106        <div key={m.id} style={styles.item}>
107          <span style={styles.badge}>{m.status}</span>
108          <strong>{m.name}</strong>
109          <span style={styles.muted}>ETA: {m.eta}</span>
110        </div>
111      ))}
112    </div>
113  );
114}
115
116function CrewPanel({ requestId, onReady }) {
117  const crew = use(loadSection('crew', requestId));
118  useEffect(() => {
119    onReady('Équipage', '#ffaa00');
120  }, [onReady]);
121
122  return (
123    <div style={styles.panel}>
124      <h3 style={styles.panelTitle}>Équipage (1200 ms)</h3>
125      {crew.map(c => (
126        <div key={c.id} style={styles.item}>
127          <span style={{ ...styles.dot, background: c.ready ? '#00ff88' : '#ff6b6b' }} />
128          <strong>{c.name}</strong>
129          <span style={styles.muted}>{c.role}</span>
130        </div>
131      ))}
132    </div>
133  );
134}
135
136function TelemetryPanel({ requestId, onReady }) {
137  const telemetry = use(loadSection('telemetry', requestId));
138  useEffect(() => {
139    onReady('Télémétrie', '#ff6b6b');
140  }, [onReady]);
141
142  return (
143    <div style={styles.panel}>
144      <h3 style={styles.panelTitle}>Télémétrie (2000 ms)</h3>
145      <div style={styles.telGrid}>
146        {telemetry.map(t => (
147          <div key={t.label} style={styles.telItem}>
148            <span style={styles.telLabel}>{t.label}</span>
149            <div style={styles.bar}>
150              <div style={{ ...styles.barFill, width: t.value + '%' }} />
151            </div>
152            <span>{t.value}%</span>
153          </div>
154        ))}
155      </div>
156    </div>
157  );
158}
159
160function EarthPanel({ requestKey, fail, label, onReady }) {
161  const message = use(loadSection('earth', requestKey, fail));
162  useEffect(() => {
163    onReady(label, '#00d4ff');
164  }, [onReady, label]);
165
166  return (
167    <div style={styles.panel}>
168      <h3 style={styles.panelTitle}>Liaison avec la Terre (900 ms)</h3>
169      <p style={{ margin: 0, fontSize: '12px' }}>{message}</p>
170    </div>
171  );
172}
173
174export default function App() {
175  const [request, setRequest] = useState({ id: 1, fail: false });
176  const [failNext, setFailNext] = useState(false);
177  const [attempt, setAttempt] = useState(0);
178  const [events, setEvents] = useState([{ label: 'Coque (en-tête et mise en page)', ms: 0, color: '#8892b0' }]);
179  // Une section se signale après son rendu, donc la chronologie montre le vrai ordre
180  const report = useCallback((label, color) => {
181    const ms = Math.round(performance.now() - requestStart);
182    setEvents(prev => (prev.some(e => e.label === label) ? prev : [...prev, { label, ms, color }]));
183  }, []);
184
185  const reportError = useCallback(message => {
186    report('Erreur de liaison : ' + message, '#ff6b6b');
187  }, [report]);
188
189  const startRequest = () => {
190    requestStart = performance.now();
191    setAttempt(0);
192    setEvents([{ label: 'Coque (en-tête et mise en page)', ms: 0, color: '#8892b0' }]);
193    setRequest(r => ({ id: r.id + 1, fail: failNext }));
194  };
195
196  const earthFails = request.fail && attempt === 0;
197  const earthLabel = attempt === 0 ? 'Liaison avec la Terre' : 'Liaison avec la Terre (nouvel essai)';
198
199  return (
200    <div style={styles.container}>
201      <header style={styles.header}>
202        <h1 style={styles.title}>SSR en streaming - simulation</h1>
203        <p style={styles.subtitle}>
204          La coque s’affiche tout de suite, et chaque frontière Suspense envoie sa section dès que ses données sont prêtes
205        </p>
206        <div style={styles.toolbar}>
207          <button style={styles.button} onClick={startRequest}>Nouvelle requête</button>
208          <label style={styles.checkbox}>
209            <input type="checkbox" checked={failNext} onChange={e => setFailNext(e.target.checked)} />
210            Panne du relais à la prochaine requête
211          </label>
212        </div>
213      </header>
214
215      <section style={styles.shell}>
216        <h2 style={{ color: '#00d4ff', margin: 0, fontSize: '16px' }}>Station Alpha - Tableau de bord (requête #{request.id})</h2>
217        <p style={{ color: '#8892b0', margin: '2px 0 0', fontSize: '11px' }}>Coque : envoyée immédiatement (0 ms)</p>
218      </section>
219
220      <div style={styles.mainGrid}>
221        <div key={request.id} style={styles.grid}>
222          <Suspense fallback={<Skeleton label="Missions" />}>
223            <MissionPanel requestId={request.id} onReady={report} />
224          </Suspense>
225
226          <RelayBoundary onError={reportError} onRetry={() => setAttempt(a => a + 1)}>
227            <Suspense fallback={<Skeleton label="Liaison avec la Terre" />}>
228              <EarthPanel
229                requestKey={request.id + '-' + attempt}
230                fail={earthFails}
231                label={earthLabel}
232                onReady={report}
233              />
234            </Suspense>
235          </RelayBoundary>
236
237          <Suspense fallback={<Skeleton label="Équipage" />}>
238            <CrewPanel requestId={request.id} onReady={report} />
239          </Suspense>
240
241          <Suspense fallback={<Skeleton label="Télémétrie" />}>
242            <TelemetryPanel requestId={request.id} onReady={report} />
243          </Suspense>
244        </div>
245
246        <aside style={styles.timeline}>
247          <h3 style={styles.panelTitle}>Ordre du streaming</h3>
248          {events.map(e => (
249            <div key={e.label} style={styles.timelineItem}>
250              <span style={{ ...styles.dot, background: e.color }} />
251              <span style={styles.time}>{e.ms} ms</span>
252              <span>{e.label}</span>
253            </div>
254          ))}
255        </aside>
256      </div>
257    </div>
258  );
259}
260
261const styles = {
262  container: { fontFamily: 'system-ui, sans-serif', background: '#0a0e17', color: '#e0e1dd', minHeight: '100vh', padding: '14px' },
263  header: { textAlign: 'center', marginBottom: '12px' },
264  title: { fontSize: '20px', color: '#00d4ff', margin: '0 0 4px' },
265  subtitle: { fontSize: '12px', color: '#8892b0', margin: '0 0 10px' },
266  toolbar: { display: 'flex', gap: '12px', justifyContent: 'center', alignItems: 'center', flexWrap: 'wrap' },
267  button: { padding: '6px 14px', background: '#00d4ff', color: '#0a0e17', border: 'none', borderRadius: '6px', fontWeight: 'bold', cursor: 'pointer' },
268  smallButton: { padding: '4px 10px', background: 'transparent', color: '#ff6b6b', border: '1px solid #ff6b6b', borderRadius: '6px', cursor: 'pointer', fontSize: '11px' },
269  checkbox: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#8892b0' },
270  shell: { background: 'rgba(0,212,255,0.08)', border: '1px solid rgba(0,212,255,0.2)', borderRadius: '8px', padding: '10px 12px', marginBottom: '12px' },
271  mainGrid: { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 210px', gap: '12px' },
272  grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '10px', alignContent: 'start' },
273  panel: { background: 'rgba(0,212,255,0.05)', borderRadius: '8px', padding: '12px', border: '1px solid rgba(0,212,255,0.15)' },
274  panelTitle: { fontSize: '13px', color: '#00d4ff', margin: '0 0 8px' },
275  item: { display: 'flex', alignItems: 'center', gap: '6px', padding: '5px 8px', background: 'rgba(0,0,0,0.3)', borderRadius: '4px', marginBottom: '4px', fontSize: '11px' },
276  badge: { padding: '1px 6px', borderRadius: '6px', fontSize: '9px', background: 'rgba(0,255,136,0.15)', color: '#00ff88' },
277  muted: { marginLeft: 'auto', color: '#8892b0', fontSize: '10px' },
278  dot: { width: '8px', height: '8px', borderRadius: '50%', flexShrink: 0, display: 'inline-block' },
279  skeleton: { minHeight: '110px', background: 'rgba(0,212,255,0.03)', border: '1px dashed rgba(0,212,255,0.2)', borderRadius: '8px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' },
280  shimmer: { width: '50%', height: '8px', background: 'rgba(0,212,255,0.12)', borderRadius: '4px', marginBottom: '6px' },
281  skeletonLabel: { color: '#8892b0', fontSize: '11px', margin: 0 },
282  errorBox: { minHeight: '110px', background: 'rgba(255,107,107,0.08)', border: '1px solid rgba(255,107,107,0.4)', borderRadius: '8px', padding: '12px', fontSize: '12px', color: '#ffb3b3' },
283  telGrid: { display: 'grid', gap: '6px' },
284  telItem: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px' },
285  telLabel: { color: '#8892b0', width: '60px' },
286  bar: { flex: 1, height: '8px', background: 'rgba(255,255,255,0.08)', borderRadius: '4px', overflow: 'hidden' },
287  barFill: { height: '100%', background: 'linear-gradient(90deg, #00d4ff, #00ff88)' },
288  timeline: { background: 'rgba(0,212,255,0.05)', borderRadius: '8px', padding: '12px', border: '1px solid rgba(0,212,255,0.15)', alignSelf: 'start' },
289  timelineItem: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '8px', fontSize: '11px' },
290  time: { color: '#8892b0', width: '48px', textAlign: 'right', flexShrink: 0 },
291};

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quelle est la principale différence entre renderToPipeableStream et renderToReadableStream ?

  2. 2. Quelle caractéristique est le PRINCIPAL avantage de l'Edge Runtime par rapport au Node.js traditionnel ?

Tâches pratiques dans le jeu

  • Tri vertical

    Ordonne les étapes du SSR en streaming, de la première à la dernière :

  • Tri horizontal

    Compose l'enveloppement d'un composant dans ErrorBoundary + Suspense de gauche à droite :

  • Clique dans l'ordre

    Compose la syntaxe de useSuspenseQuery avec TanStack React Query :

Articles utiles