Cours JavaScript et React · Module 10: Écosystème et avenir de React
Streaming SSR et Edge Runtime
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-planrenderToPipeableStream - 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éristique | Runtime Node.js | Edge Runtime |
|---|---|---|
| Emplacement | Une ou plusieurs régions | Serveurs CDN dans le monde entier |
| Démarrage à froid | 200-500 ms (fonction serverless froide) | 1-5 ms (isolat V8) |
| Taille du code | Limite élevée (par ex. 250 Mo sur Vercel) | 1-4 Mo après compression (Vercel, selon l'offre) |
| API | API Node.js complète | Web APIs, sans système de fichiers ni modules Node.js |
| Streaming | renderToPipeableStream | renderToReadableStream |
| Bases de données | N'importe laquelle | Pilotes 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 :
- Streaming SSR - envoie le HTML en fragments, l'utilisateur voit la page immédiatement
- renderToPipeableStream - API Node.js pour le streaming
- renderToReadableStream - API Web Streams (Edge Runtime, Cloudflare Workers, Deno)
- Frontières Suspense - définissent les limites du streaming, et un Error Boundary placé au-dessus capture les erreurs
- 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
- 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. Quelle est la principale différence entre renderToPipeableStream et renderToReadableStream ?
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 :